From c6af9e284a48de929ac7c4c98c886ccc118f1c4f Mon Sep 17 00:00:00 2001 From: wehub-resource-sync Date: Mon, 13 Jul 2026 12:39:38 +0800 Subject: [PATCH] chore: import upstream snapshot with attribution --- .devcontainer/devcontainer.json | 16 + .dockerignore | 15 + .env.example | 2 + .gitattributes | 4 + .github/FUNDING.yml | 3 + .github/ISSUE_TEMPLATE/config.yml | 1 + .github/ISSUE_TEMPLATE/feature_request.md | 20 + ...ue--bug--performance-problem--question-.md | 21 + .github/copilot-instructions.md | 3 + .github/pull_request_template.md | 4 + .github/workflows/codeql.yml | 103 + .github/workflows/codespell.yml | 28 + .github/workflows/create-release.yml | 19 + .github/workflows/docker.yml | 58 + .github/workflows/docs.yaml | 65 + .github/workflows/publish.yml | 30 + .github/workflows/pytest.yml | 706 ++ .gitignore | 278 + .serena/.gitignore | 2 + .../adding_new_language_support_guide.md | 229 + .serena/memories/conventions.md | 32 + .serena/memories/core.md | 29 + .serena/memories/creating_pull_requests.md | 11 + .serena/memories/memory_maintenance.md | 33 + .../memories/serena_repository_structure.md | 113 + .serena/memories/suggested_commands.md | 24 + .serena/memories/task_completion.md | 11 + .serena/memories/tech_stack.md | 10 + .serena/project.yml | 187 + AGENTS.md | 3 + CHANGELOG.md | 610 ++ CLAUDE.md | 3 + CONTRIBUTING.md | 57 + DOCKER.md | 174 + Dockerfile | 77 + LICENSE | 21 + README-dev.md | 29 + README.md | 265 + README.wehub.md | 7 + compose.yaml | 21 + docker_build_and_run.sh | 5 + docs/.gitignore | 4 + docs/01-about/.gitignore | 4 + docs/01-about/020_programming-languages.md | 180 + docs/01-about/030_serena-in-action.md | 24 + docs/01-about/050_acknowledgements.md | 34 + docs/02-usage/000_intro.md | 6 + docs/02-usage/010_installation.md | 51 + docs/02-usage/020_running.md | 259 + docs/02-usage/025_jetbrains_plugin.md | 223 + docs/02-usage/030_clients.md | 656 ++ docs/02-usage/040_workflow.md | 223 + docs/02-usage/045_memories.md | 215 + docs/02-usage/050_configuration.md | 1176 ++++ docs/02-usage/060_dashboard.md | 85 + docs/02-usage/065_logs.md | 14 + docs/02-usage/070_security.md | 172 + docs/02-usage/999_additional-usage.md | 18 + docs/03-special-guides/000_intro.md | 3 + docs/03-special-guides/cpp_setup.md | 107 + docs/03-special-guides/custom_agent.md | 67 + .../godot_gdscript_setup_guide_for_serena.md | 94 + .../groovy_setup_guide_for_serena.md | 88 + .../ocaml_setup_guide_for_serena.md | 88 + .../scala_setup_guide_for_serena.md | 103 + docs/03-special-guides/serena_on_chatgpt.md | 101 + .../unreal_engine_setup_guide_for_serena.md | 157 + docs/04-evaluation/000_evaluation-intro.md | 47 + docs/04-evaluation/010_methodology.md | 160 + docs/04-evaluation/020_prompts/000_prompts.md | 6 + .../020_prompts/010_evaluation-prompt.md | 333 + .../020_prompts/020_summary-prompt.md | 12 + .../030_results/000_evaluation-results.md | 20 + .../030_results/010_cc_on_tianshou.md | 359 + .../030_results/020_codex_on_jbplugin.md | 666 ++ .../030_results/030_copilot_cli_on_ente.md | 349 + .../030_results/040_glm_on_tianshou.md | 446 ++ .../050_junie_plugin_on_tianshou.md | 345 + docs/_config.yml | 154 + .../images/jetbrains-marketplace-button.png | Bin 0 -> 7661 bytes docs/autogen_docs.py | 249 + docs/create_toc.py | 9 + docs/index.md | 2 + flake.lock | 133 + flake.nix | 213 + news/20260111.html | 22 + news/20260303.html | 12 + news/20260321.html | 14 + news/20260330.html | 13 + news/20260404.html | 23 + news/20260411.html | 22 + news/20260414.html | 15 + news/20260427.html | 27 + news/20260512.html | 41 + pyproject.toml | 390 ++ repo_dir_sync.py | 363 + resources/jetbrains-marketplace-button.cdr | Bin 0 -> 30233 bytes resources/serena-block-diagram.afdesign | Bin 0 -> 47098 bytes resources/serena-block-diagram.svg | 1 + resources/serena-icons.cdr | Bin 0 -> 73931 bytes resources/serena-logo-dark-mode.svg | Bin 0 -> 217940 bytes resources/serena-logo.cdr | Bin 0 -> 189189 bytes resources/serena-logo.svg | Bin 0 -> 217846 bytes resources/serena-mac-dock-icon.cdr | Bin 0 -> 26002 bytes resources/serena-mac-tray-icon.cdr | Bin 0 -> 22777 bytes resources/vscode_sponsor_logo.png | Bin 0 -> 67177 bytes scripts/agno_agent.py | 33 + scripts/build_news_json.py | 55 + scripts/bump_version.py | 279 + scripts/demo_cli_call.py | 4 + scripts/demo_diagnostics.py | 176 + scripts/demo_find_defining_symbol.py | 112 + scripts/demo_find_implementing_symbol.py | 76 + scripts/demo_progressive_tool_shortening.py | 191 + scripts/demo_run_tools.py | 52 + scripts/gen_prompt_factory.py | 23 + scripts/mcp_server.py | 4 + scripts/print_language_list.py | 14 + scripts/print_mode_context_options.py | 13 + scripts/print_tool_overview.py | 4 + scripts/profile_tool_call.py | 52 + server.json | 46 + src/README.md | 4 + src/interprompt/.syncCommitId.remote | 1 + src/interprompt/.syncCommitId.this | 1 + src/interprompt/__init__.py | 3 + src/interprompt/jinja_template.py | 46 + src/interprompt/multilang_prompt.py | 393 ++ src/interprompt/prompt_factory.py | 92 + src/interprompt/util/__init__.py | 0 src/interprompt/util/class_decorators.py | 13 + src/serena/__init__.py | 35 + src/serena/agent.py | 1465 ++++ src/serena/agno.py | 143 + src/serena/analytics.py | 174 + src/serena/cli.py | 1522 +++++ src/serena/code_editor.py | 469 ++ src/serena/config/__init__.py | 0 src/serena/config/client_setup.py | 112 + src/serena/config/context_mode.py | 282 + src/serena/config/serena_config.py | 1333 ++++ src/serena/constants.py | 43 + src/serena/dashboard.py | 1309 ++++ .../generated/generated_prompt_factory.py | 53 + src/serena/gui_log_viewer.py | 416 ++ src/serena/hooks.py | 616 ++ .../jetbrains/jetbrains_plugin_client.py | 725 ++ src/serena/jetbrains/jetbrains_types.py | 118 + src/serena/ls_manager.py | 256 + src/serena/mcp.py | 418 ++ src/serena/memories/memory_manager.py | 438 ++ .../memories/memory_reference_analysis.py | 780 +++ src/serena/project.py | 544 ++ src/serena/project_server.py | 164 + src/serena/prompt_factory.py | 16 + .../resources/config/contexts/agent.yml | 7 + .../resources/config/contexts/antigravity.yml | 26 + .../resources/config/contexts/chatgpt.yml | 27 + .../resources/config/contexts/claude-code.yml | 56 + .../resources/config/contexts/codebuddy.yml | 52 + .../resources/config/contexts/codex.yml | 22 + .../config/contexts/context.template.yml | 21 + .../resources/config/contexts/copilot-cli.yml | 25 + .../resources/config/contexts/desktop-app.yml | 14 + src/serena/resources/config/contexts/ide.yml | 26 + .../config/contexts/jb-ai-assistant.yml | 26 + .../config/contexts/jb-copilot-plugin.yml | 27 + .../resources/config/contexts/junie.yml | 28 + .../config/contexts/oaicompat-agent.yml | 8 + .../resources/config/contexts/vscode.yml | 29 + .../config/internal_modes/jetbrains.yml | 28 + .../resources/config/modes/benchmark.yml | 20 + src/serena/resources/config/modes/editing.yml | 43 + .../resources/config/modes/interactive.yml | 10 + .../resources/config/modes/mode.template.yml | 8 + .../resources/config/modes/no-memories.yml | 11 + .../resources/config/modes/no-onboarding.yml | 5 + .../resources/config/modes/onboarding.yml | 16 + .../resources/config/modes/one-shot.yml | 15 + .../resources/config/modes/planning.yml | 14 + .../resources/config/modes/query-projects.yml | 8 + .../config/prompt_templates/info_prompts.yml | 64 + .../prompt_templates/simple_tool_outputs.yml | 41 + .../config/prompt_templates/system_prompt.yml | 216 + src/serena/resources/dashboard/dashboard.css | 1686 +++++ src/serena/resources/dashboard/dashboard.js | 2387 +++++++ src/serena/resources/dashboard/index.html | 377 ++ src/serena/resources/dashboard/jquery.min.js | 2 + .../dashboard/serena-icon-1024-mac.png | Bin 0 -> 38227 bytes .../resources/dashboard/serena-icon-16.png | Bin 0 -> 3208 bytes .../resources/dashboard/serena-icon-32.png | Bin 0 -> 3788 bytes .../resources/dashboard/serena-icon-48.png | Bin 0 -> 5203 bytes .../resources/dashboard/serena-icon-64.png | Bin 0 -> 5286 bytes .../dashboard/serena-icon-tray-mac.png | Bin 0 -> 2429 bytes .../dashboard/serena-logo-dark-mode.svg | Bin 0 -> 217940 bytes .../resources/dashboard/serena-logo.svg | Bin 0 -> 217846 bytes .../dashboard/serena-logs-dark-mode.png | Bin 0 -> 12994 bytes .../resources/dashboard/serena-logs.png | Bin 0 -> 13719 bytes src/serena/resources/dashboard/serena.ico | Bin 0 -> 6102 bytes src/serena/resources/memory_maintenance.md | 33 + .../resources/project.local.template.yml | 5 + src/serena/resources/project.template.yml | 158 + .../resources/serena_config.template.yml | 202 + src/serena/symbol.py | 1363 ++++ src/serena/task_executor.py | 231 + src/serena/tools/__init__.py | 10 + src/serena/tools/cmd_tools.py | 52 + src/serena/tools/config_tools.py | 66 + src/serena/tools/file_tools.py | 639 ++ src/serena/tools/jetbrains_tools.py | 681 ++ src/serena/tools/memory_tools.py | 122 + src/serena/tools/query_project_tools.py | 77 + src/serena/tools/symbol_tools.py | 721 ++ src/serena/tools/tools_base.py | 669 ++ src/serena/tools/workflow_tools.py | 63 + src/serena/util/class_decorators.py | 15 + src/serena/util/cli_util.py | 36 + src/serena/util/dataclass.py | 21 + src/serena/util/dotnet.py | 156 + src/serena/util/exception.py | 65 + src/serena/util/file_proxy.py | 91 + src/serena/util/file_system.py | 374 ++ src/serena/util/git.py | 22 + src/serena/util/gui.py | 22 + src/serena/util/inspection.py | 70 + src/serena/util/logging.py | 152 + src/serena/util/ls_diagnostics.py | 203 + src/serena/util/pywebview.py | 233 + src/serena/util/shell.py | 53 + src/serena/util/text_utils.py | 676 ++ src/serena/util/thread.py | 68 + src/serena/util/version.py | 85 + src/serena/util/yaml.py | 311 + src/solidlsp/.gitignore | 1 + src/solidlsp/__init__.py | 2 + src/solidlsp/initialize_params.py | 82 + .../language_servers/ada_language_server.py | 217 + .../language_servers/al_language_server.py | 1072 +++ .../angular_language_server.py | 696 ++ .../ansible_language_server.py | 324 + .../language_servers/bash_language_server.py | 318 + .../language_servers/bsl_language_server.py | 216 + .../language_servers/ccls_language_server.py | 151 + .../clangd_language_server.py | 417 ++ src/solidlsp/language_servers/clojure_lsp.py | 396 ++ src/solidlsp/language_servers/common.py | 192 + .../crystal_language_server.py | 152 + .../csharp_language_server.py | 760 +++ .../language_servers/cue_language_server.py | 259 + .../language_servers/dart_language_server.py | 206 + .../language_servers/eclipse_jdtls.py | 1487 +++++ .../language_servers/elixir_tools/README.md | 87 + .../language_servers/elixir_tools/__init__.py | 1 + .../elixir_tools/elixir_tools.py | 421 ++ .../language_servers/elm_language_server.py | 241 + .../erlang_language_server.py | 222 + .../fortran_language_server.py | 304 + .../fsharp_language_server.py | 376 ++ .../language_servers/godot_language_server.py | 142 + src/solidlsp/language_servers/gopls.py | 237 + .../groovy_language_server.py | 317 + .../haskell_language_server.py | 369 + .../language_servers/haxe_language_server.py | 408 ++ .../language_servers/hlsl_language_server.py | 250 + src/solidlsp/language_servers/intelephense.py | 205 + src/solidlsp/language_servers/jedi_server.py | 180 + .../language_servers/json_language_server.py | 163 + src/solidlsp/language_servers/julia_server.py | 209 + .../kotlin_language_server.py | 523 ++ .../language_servers/lean4_language_server.py | 161 + src/solidlsp/language_servers/lua_ls.py | 328 + src/solidlsp/language_servers/luau_lsp.py | 374 ++ src/solidlsp/language_servers/marksman.py | 260 + .../matlab_language_server.py | 533 ++ .../language_servers/msl_language_server.py | 99 + .../language_servers/msl_lsp_server.py | 439 ++ src/solidlsp/language_servers/nixd_ls.py | 362 + .../language_servers/ocaml_lsp_server.py | 424 ++ src/solidlsp/language_servers/omnisharp.py | 413 ++ .../omnisharp/initialize_params.json | 631 ++ .../omnisharp/runtime_dependencies.json | 450 ++ .../workspace_did_change_configuration.json | 111 + .../language_servers/pascal_server.py | 962 +++ .../language_servers/perl_language_server.py | 245 + src/solidlsp/language_servers/phpactor.py | 179 + src/solidlsp/language_servers/phpantom.py | 288 + .../powershell_language_server.py | 418 ++ .../language_servers/pyrefly_server.py | 296 + .../language_servers/pyright_server.py | 240 + .../language_servers/r_language_server.py | 146 + src/solidlsp/language_servers/regal_server.py | 117 + src/solidlsp/language_servers/ruby_lsp.py | 497 ++ .../language_servers/rust_analyzer.py | 741 ++ .../language_servers/scala_language_server.py | 317 + src/solidlsp/language_servers/solargraph.py | 353 + .../solidity_language_server.py | 425 ++ .../some_sass_language_server.py | 226 + .../language_servers/sourcekit_lsp.py | 371 + .../svelte_language_server.py | 730 ++ .../language_servers/systemverilog_server.py | 214 + src/solidlsp/language_servers/taplo_server.py | 283 + src/solidlsp/language_servers/terraform_ls.py | 273 + .../texlab_language_server.py | 186 + src/solidlsp/language_servers/ty_server.py | 135 + .../typescript_language_server.py | 555 ++ .../vscode_html_language_server.py | 165 + .../language_servers/vts_language_server.py | 274 + .../language_servers/vue_language_server.py | 953 +++ .../language_servers/yaml_language_server.py | 189 + src/solidlsp/language_servers/zls.py | 225 + src/solidlsp/ls.py | 3367 ++++++++++ src/solidlsp/ls_config.py | 937 +++ src/solidlsp/ls_exceptions.py | 74 + src/solidlsp/ls_process.py | 786 +++ src/solidlsp/ls_request.py | 383 ++ src/solidlsp/ls_types.py | 425 ++ src/solidlsp/ls_utils.py | 594 ++ .../lsp_protocol_handler/lsp_constants.py | 69 + .../lsp_protocol_handler/lsp_requests.py | 561 ++ .../lsp_protocol_handler/lsp_types.py | 5943 +++++++++++++++++ src/solidlsp/lsp_protocol_handler/server.py | 161 + src/solidlsp/settings.py | 79 + src/solidlsp/util/cache.py | 23 + src/solidlsp/util/metals_db_utils.py | 280 + src/solidlsp/util/subprocess_util.py | 96 + src/solidlsp/util/zip.py | 128 + sync.py | 6 + test/__init__.py | 1 + test/conftest.py | 529 ++ test/resources/repos/ada/test_repo/.gitignore | 3 + .../resources/repos/ada/test_repo/default.gpr | 7 + .../ada/test_repo/src/diagnostics_sample.adb | 6 + .../repos/ada/test_repo/src/helper.adb | 9 + .../repos/ada/test_repo/src/helper.ads | 7 + .../repos/ada/test_repo/src/main.adb | 8 + test/resources/repos/al/test_repo/app.json | 31 + .../src/Codeunits/CustomerMgt.Codeunit.al | 199 + .../PaymentProcessorImpl.Codeunit.al | 73 + .../test_repo/src/Enums/CustomerType.Enum.al | 35 + .../Interfaces/IPaymentProcessor.Interface.al | 7 + .../test_repo/src/Pages/CustomerCard.Page.al | 271 + .../test_repo/src/Pages/CustomerList.Page.al | 205 + .../src/TableExtensions/Item.TableExt.al | 79 + .../al/test_repo/src/Tables/Customer.Table.al | 210 + .../repos/angular/test_repo/.gitignore | 5 + .../repos/angular/test_repo/angular.json | 24 + .../repos/angular/test_repo/package-lock.json | 134 + .../repos/angular/test_repo/package.json | 18 + .../test_repo/src/app/app.component.html | 21 + .../test_repo/src/app/app.component.ts | 26 + .../test_repo/src/app/diagnostics_sample.html | 6 + .../test_repo/src/app/diagnostics_sample.ts | 15 + .../angular/test_repo/src/app/exclaim.pipe.ts | 11 + .../test_repo/src/app/greeter.interface.ts | 3 + .../test_repo/src/app/greeting.service.ts | 11 + .../test_repo/src/app/item-card.component.ts | 18 + .../repos/angular/test_repo/src/index.html | 10 + .../repos/angular/test_repo/src/main.ts | 4 + .../repos/angular/test_repo/tsconfig.app.json | 9 + .../repos/angular/test_repo/tsconfig.json | 25 + .../ansible/test_repo/inventory/hosts.yml | 13 + .../repos/ansible/test_repo/playbook.yml | 31 + .../test_repo/roles/common/defaults/main.yml | 3 + .../test_repo/roles/common/handlers/main.yml | 4 + .../test_repo/roles/common/tasks/main.yml | 19 + test/resources/repos/bash/test_repo/config.sh | 91 + .../bash/test_repo/diagnostics_sample.sh | 4 + test/resources/repos/bash/test_repo/main.sh | 70 + test/resources/repos/bash/test_repo/utils.sh | 83 + .../bsl/test_repo/.bsl-language-server.json | 5 + test/resources/repos/bsl/test_repo/.gitkeep | 0 .../repos/bsl/test_repo/CommonModule.bsl | 14 + .../repos/bsl/test_repo/ObjectModule.bsl | 12 + .../bsl/test_repo/diagnostics_sample.bsl | 6 + .../src/CommonModules/ОбщийМодуль1.xml | 18 + .../CommonModules/ОбщийМодуль1/Ext/Module.bsl | 5 + .../src/CommonModules/ОбщийМодуль2.xml | 18 + .../CommonModules/ОбщийМодуль2/Ext/Module.bsl | 5 + .../bsl/test_repo/src/ConfigDumpInfo.xml | 10 + .../repos/bsl/test_repo/src/Configuration.xml | 252 + .../bsl/test_repo/src/Languages/Русский.xml | 16 + .../repos/clojure/test_repo/deps.edn | 5 + .../clojure/test_repo/src/test_app/core.clj | 23 + .../src/test_app/diagnostics_sample.clj | 9 + .../clojure/test_repo/src/test_app/extra.clj | 12 + .../clojure/test_repo/src/test_app/utils.clj | 17 + .../clojure/test_repo/sub_module/deps.edn | 2 + .../src/sub_module_app/consumer.clj | 15 + test/resources/repos/cpp/test_repo/C/a.c | 7 + test/resources/repos/cpp/test_repo/C/b.h | 3 + test/resources/repos/cpp/test_repo/CUDA/a.cu | 16 + .../resources/repos/cpp/test_repo/CXX20/a.cpp | 9 + .../resources/repos/cpp/test_repo/CXX20/b.cpp | 17 + .../repos/cpp/test_repo/CXX20/b.cppm | 16 + test/resources/repos/cpp/test_repo/HIP/a.hip | 10 + .../repos/cpp/test_repo/Objective-C/a.m | 7 + .../resources/repos/cpp/test_repo/OpenCL/a.cl | 8 + test/resources/repos/cpp/test_repo/a.cpp | 6 + test/resources/repos/cpp/test_repo/b.cpp | 5 + test/resources/repos/cpp/test_repo/b.hpp | 3 + .../repos/cpp/test_repo/compile_commands.json | 52 + .../cpp/test_repo/diagnostics_sample.cpp | 8 + .../TestGame/UHT/AbilityActor.generated.h | 9 + .../TestGame/UHT/AbilityComponent.gen.cpp | 22 + .../TestGame/UHT/AbilityComponent.generated.h | 10 + .../TestGame/UHT/AbilityTypes.generated.h | 8 + .../TestGame/UHT/Damageable.generated.h | 10 + .../TestGame/UHT/GameCharacter.generated.h | 10 + .../repos/cpp/ue_test_repo/MyGame.uproject | 4 + .../Source/TestGame/AbilityActor.cpp | 18 + .../Source/TestGame/AbilityActor.h | 24 + .../Source/TestGame/AbilityComponent.cpp | 16 + .../Source/TestGame/AbilityComponent.h | 40 + .../Source/TestGame/AbilityTypes.h | 24 + .../ue_test_repo/Source/TestGame/Damageable.h | 20 + .../Source/TestGame/GameCharacter.cpp | 29 + .../Source/TestGame/GameCharacter.h | 39 + .../Source/TestGame/TestGameLog.h | 5 + .../Source/TestGame/TestGameModule.cpp | 6 + .../Stubs/Components/ActorComponent.h | 10 + .../cpp/ue_test_repo/Stubs/Containers/Array.h | 11 + .../cpp/ue_test_repo/Stubs/Containers/Map.h | 12 + .../cpp/ue_test_repo/Stubs/Containers/Set.h | 11 + .../cpp/ue_test_repo/Stubs/CoreMinimal.h | 20 + .../repos/cpp/ue_test_repo/Stubs/CoreTypes.h | 41 + .../Stubs/Delegates/DelegateCombinations.h | 81 + .../cpp/ue_test_repo/Stubs/Engine/World.h | 9 + .../ue_test_repo/Stubs/GameFramework/Actor.h | 13 + .../Stubs/GameFramework/Character.h | 9 + .../ue_test_repo/Stubs/GameFramework/Pawn.h | 9 + .../ue_test_repo/Stubs/Logging/LogMacros.h | 13 + .../cpp/ue_test_repo/Stubs/Math/MathFwd.h | 25 + .../cpp/ue_test_repo/Stubs/Misc/Optional.h | 12 + .../Stubs/Modules/ModuleManager.h | 9 + .../Stubs/Templates/SharedPointer.h | 31 + .../ue_test_repo/Stubs/Templates/SubclassOf.h | 9 + .../ue_test_repo/Stubs/Templates/UniquePtr.h | 17 + .../ue_test_repo/Stubs/UObject/Interface.h | 8 + .../cpp/ue_test_repo/Stubs/UObject/Object.h | 15 + .../ue_test_repo/Stubs/UObject/ObjectMacros.h | 20 + .../ue_test_repo/Stubs/UObject/ObjectPtr.h | 13 + .../Stubs/UObject/SoftObjectPtr.h | 18 + .../Stubs/UObject/UObjectGlobals.h | 16 + .../Stubs/UObject/WeakObjectPtr.h | 12 + .../cpp/ue_test_repo/compile_commands.json | 22 + .../repos/crystal/test_repo/shard.yml | 8 + .../test_repo/src/diagnostics_sample.cr | 5 + .../repos/crystal/test_repo/src/main.cr | 43 + .../repos/crystal/test_repo/src/utils.cr | 9 + .../repos/csharp/test_repo/.gitignore | 37 + .../csharp/test_repo/DiagnosticsSample.cs | 19 + .../repos/csharp/test_repo/Models/Person.cs | 34 + .../repos/csharp/test_repo/Program.cs | 47 + .../test_repo/Services/ConsoleGreeter.cs | 10 + .../csharp/test_repo/Services/IGreeter.cs | 7 + .../repos/csharp/test_repo/TestProject.csproj | 10 + .../repos/csharp/test_repo/serena.sln | 38 + test/resources/repos/cue/test_repo/.gitignore | 2 + .../repos/cue/test_repo/cue.mod/module.cue | 4 + test/resources/repos/cue/test_repo/lib.cue | 18 + test/resources/repos/cue/test_repo/main.cue | 14 + test/resources/repos/cue/test_repo/schema.cue | 17 + .../resources/repos/dart/test_repo/.gitignore | 23 + .../test_repo/lib/diagnostics_sample.dart | 3 + .../repos/dart/test_repo/lib/helper.dart | 48 + .../repos/dart/test_repo/lib/main.dart | 130 + .../repos/dart/test_repo/lib/models.dart | 174 + .../repos/dart/test_repo/pubspec.yaml | 11 + .../repos/elixir/test_repo/.gitignore | 0 .../test_repo/lib/diagnostics_sample.ex | 10 + .../repos/elixir/test_repo/lib/examples.ex | 211 + .../lib/ignored_dir/ignored_module.ex | 23 + .../repos/elixir/test_repo/lib/models.ex | 166 + .../repos/elixir/test_repo/lib/services.ex | 257 + .../repos/elixir/test_repo/lib/test_repo.ex | 31 + .../repos/elixir/test_repo/lib/utils.ex | 48 + test/resources/repos/elixir/test_repo/mix.exs | 25 + .../resources/repos/elixir/test_repo/mix.lock | 6 + .../elixir/test_repo/scripts/build_script.ex | 26 + .../elixir/test_repo/test/models_test.exs | 169 + .../elixir/test_repo/test/test_repo_test.exs | 14 + .../repos/elm/test_repo/DiagnosticsSample.elm | 5 + test/resources/repos/elm/test_repo/Main.elm | 72 + test/resources/repos/elm/test_repo/Utils.elm | 28 + test/resources/repos/elm/test_repo/elm.json | 24 + .../repos/erlang/test_repo/hello.erl | 14 + .../test_repo/ignored_dir/ignored_module.erl | 8 + .../erlang/test_repo/include/records.hrl | 38 + .../repos/erlang/test_repo/include/types.hrl | 20 + .../repos/erlang/test_repo/math_utils.erl | 16 + .../repos/erlang/test_repo/rebar.config | 33 + .../repos/erlang/test_repo/src/app.erl | 117 + .../test_repo/src/diagnostics_sample.erl | 5 + .../repos/erlang/test_repo/src/models.erl | 83 + .../repos/erlang/test_repo/src/services.erl | 146 + .../repos/erlang/test_repo/src/utils.erl | 135 + .../erlang/test_repo/test/models_tests.erl | 77 + .../erlang/test_repo/test/utils_tests.erl | 85 + .../fortran/test_repo/diagnostics_sample.f90 | 5 + .../repos/fortran/test_repo/main.f90 | 16 + .../fortran/test_repo/modules/geometry.f90 | 46 + .../fortran/test_repo/modules/math_utils.f90 | 22 + .../repos/fsharp/test_repo/.gitignore | 5 + .../repos/fsharp/test_repo/Calculator.fs | 32 + .../fsharp/test_repo/DiagnosticsSample.fs | 11 + .../repos/fsharp/test_repo/Formatter.fs | 8 + .../repos/fsharp/test_repo/Models/Person.fs | 44 + .../repos/fsharp/test_repo/Program.fs | 45 + .../repos/fsharp/test_repo/README.md | 21 + .../repos/fsharp/test_repo/Shapes.fs | 10 + .../repos/fsharp/test_repo/TestProject.fsproj | 17 + .../repos/go/test_repo/buildtags/foo.go | 8 + .../repos/go/test_repo/buildtags/notfoo.go | 8 + .../repos/go/test_repo/diagnostics_sample.go | 11 + test/resources/repos/go/test_repo/go.mod | 3 + test/resources/repos/go/test_repo/main.go | 36 + .../repos/groovy/test_repo/.gitignore | 1 + .../repos/groovy/test_repo/build.gradle | 7 + .../com/example/DiagnosticsSample.groovy | 13 + .../src/main/groovy/com/example/Main.groovy | 9 + .../src/main/groovy/com/example/Model.groovy | 9 + .../main/groovy/com/example/ModelUser.groovy | 8 + .../src/main/groovy/com/example/Utils.groovy | 7 + .../repos/haskell/test_repo/app/Main.hs | 26 + .../repos/haskell/test_repo/package.yaml | 25 + .../repos/haskell/test_repo/src/Calculator.hs | 43 + .../test_repo/src/DiagnosticsSample.hs | 7 + .../repos/haskell/test_repo/src/Helper.hs | 22 + .../repos/haskell/test_repo/stack.yaml | 5 + .../resources/repos/haxe/test_repo/.gitignore | 3 + .../resources/repos/haxe/test_repo/build.hxml | 3 + .../haxe/test_repo/src/DiagnosticsSample.hx | 10 + .../repos/haxe/test_repo/src/Main.hx | 40 + .../repos/haxe/test_repo/src/utils/Helper.hx | 29 + .../repos/hlsl/test_repo/common.hlsl | 29 + .../repos/hlsl/test_repo/compute_test.hlsl | 26 + .../hlsl/test_repo/diagnostics_sample.hlsl | 3 + .../repos/hlsl/test_repo/lighting.hlsl | 37 + .../hlsl/test_repo/terrain/terrain_sdf.hlsl | 33 + .../resources/repos/html/test_repo/about.html | 22 + .../html/test_repo/diagnostics_sample.html | 14 + .../resources/repos/html/test_repo/index.html | 35 + test/resources/repos/java/test_repo/pom.xml | 46 + .../main/java/test_repo/ConsoleGreeter.java | 8 + .../main/java/test_repo/DefaultGreeter.java | 13 + .../java/test_repo/DiagnosticsSample.java | 13 + .../java/test_repo/FluentLombokModel.java | 14 + .../src/main/java/test_repo/Greeter.java | 5 + .../src/main/java/test_repo/LombokModel.java | 27 + .../src/main/java/test_repo/Main.java | 15 + .../src/main/java/test_repo/Model.java | 24 + .../src/main/java/test_repo/ModelUser.java | 8 + .../src/main/java/test_repo/Utils.java | 7 + .../repos/json/test_repo/config.json | 24 + test/resources/repos/json/test_repo/data.json | 30 + .../json/test_repo/diagnostics_sample.json | 4 + .../julia/test_repo/diagnostics_sample.jl | 3 + .../repos/julia/test_repo/lib/helper.jl | 5 + test/resources/repos/julia/test_repo/main.jl | 13 + .../repos/kotlin/test_repo/.gitignore | 1 + .../repos/kotlin/test_repo/build.gradle.kts | 11 + .../gradle/wrapper/gradle-wrapper.properties | 7 + test/resources/repos/kotlin/test_repo/gradlew | 251 + .../repos/kotlin/test_repo/gradlew.bat | 94 + .../kotlin/test_repo/DiagnosticsSample.kt | 11 + .../src/main/kotlin/test_repo/Main.kt | 15 + .../src/main/kotlin/test_repo/Model.kt | 3 + .../src/main/kotlin/test_repo/ModelUser.kt | 9 + .../src/main/kotlin/test_repo/Utils.kt | 7 + .../repos/latex/test_repo/.gitignore | 7 + test/resources/repos/latex/test_repo/main.tex | 35 + .../repos/latex/test_repo/references.bib | 13 + .../latex/test_repo/sections/background.tex | 9 + .../repos/latex/test_repo/slides.tex | 33 + .../lean4/test_repo/DiagnosticsSample.lean | 1 + .../repos/lean4/test_repo/Helper.lean | 16 + .../resources/repos/lean4/test_repo/Main.lean | 24 + .../repos/lean4/test_repo/lake-manifest.json | 5 + .../repos/lean4/test_repo/lakefile.lean | 9 + .../repos/lean4/test_repo/lean-toolchain | 1 + test/resources/repos/lua/test_repo/.gitignore | 13 + test/resources/repos/lua/test_repo/main.lua | 123 + .../repos/lua/test_repo/src/animals.lua | 37 + .../repos/lua/test_repo/src/calculator.lua | 76 + .../repos/lua/test_repo/src/utils.lua | 120 + .../lua/test_repo/tests/test_calculator.lua | 93 + test/resources/repos/luau/test_repo/.luaurc | 9 + .../repos/luau/test_repo/src/init.luau | 26 + .../repos/luau/test_repo/src/module.luau | 12 + .../repos/markdown/test_repo/CONTRIBUTING.md | 74 + .../repos/markdown/test_repo/README.md | 53 + .../resources/repos/markdown/test_repo/api.md | 78 + .../repos/markdown/test_repo/guide.md | 59 + .../repos/matlab/test_repo/Calculator.m | 81 + .../matlab/test_repo/diagnostics_sample.m | 3 + test/resources/repos/matlab/test_repo/main.m | 67 + test/resources/repos/msl/test_repo/main.mrc | 38 + test/resources/repos/msl/test_repo/utils.mrc | 26 + test/resources/repos/nix/test_repo/.gitignore | 11 + .../resources/repos/nix/test_repo/default.nix | 183 + .../nix/test_repo/diagnostics_sample.nix | 4 + test/resources/repos/nix/test_repo/flake.nix | 146 + .../repos/nix/test_repo/lib/utils.nix | 165 + .../repos/nix/test_repo/modules/example.nix | 145 + .../repos/nix/test_repo/scripts/hello.sh | 3 + test/resources/repos/ocaml/test_repo/bin/dune | 4 + .../repos/ocaml/test_repo/bin/main.ml | 9 + .../repos/ocaml/test_repo/dune-project | 26 + .../ocaml/test_repo/lib/diagnostics_sample.ml | 1 + test/resources/repos/ocaml/test_repo/lib/dune | 4 + .../repos/ocaml/test_repo/lib/test_repo.ml | 12 + .../repos/ocaml/test_repo/lib/test_repo.mli | 7 + .../resources/repos/ocaml/test_repo/test/dune | 3 + .../ocaml/test_repo/test/test_test_repo.ml | 18 + .../repos/ocaml/test_repo/test_repo.opam | 32 + .../repos/pascal/test_repo/.gitignore | 8 + .../pascal/test_repo/diagnostics_sample.pas | 4 + .../resources/repos/pascal/test_repo/main.pas | 130 + .../perl/test_repo/diagnostics_sample.pl | 5 + test/resources/repos/perl/test_repo/helper.pl | 9 + test/resources/repos/perl/test_repo/helper.t | 12 + test/resources/repos/perl/test_repo/main.pl | 22 + .../php/test_repo/diagnostics_sample.php | 11 + test/resources/repos/php/test_repo/helper.php | 7 + test/resources/repos/php/test_repo/index.php | 20 + test/resources/repos/php/test_repo/sample.php | 122 + .../repos/php/test_repo/simple_var.php | 4 + .../test_repo/PowerShellEditorServices.json | 1 + .../test_repo/diagnostics_sample.ps1 | 4 + .../repos/powershell/test_repo/main.ps1 | 115 + .../repos/powershell/test_repo/utils.ps1 | 185 + .../repos/python/test_repo/.gitignore | 1 + .../python/test_repo/custom_test/__init__.py | 3 + .../custom_test/advanced_features.py | 475 ++ .../python/test_repo/examples/__init__.py | 3 + .../test_repo/examples/user_management.py | 141 + .../repos/python/test_repo/pyrefly.toml | 0 .../python/test_repo/scripts/__init__.py | 3 + .../repos/python/test_repo/scripts/run_app.py | 164 + .../python/test_repo/test_repo/__init__.py | 0 .../test_repo/test_repo/complex_types.py | 23 + .../test_repo/test_repo/diagnostics_sample.py | 11 + .../python/test_repo/test_repo/models.py | 246 + .../test_repo/test_repo/name_collisions.py | 34 + .../python/test_repo/test_repo/nested.py | 16 + .../python/test_repo/test_repo/nested_base.py | 75 + .../python/test_repo/test_repo/overloaded.py | 88 + .../python/test_repo/test_repo/services.py | 78 + .../repos/python/test_repo/test_repo/utils.py | 123 + .../python/test_repo/test_repo/variables.py | 96 + .../resources/repos/r/test_repo/.Rbuildignore | 3 + test/resources/repos/r/test_repo/DESCRIPTION | 13 + test/resources/repos/r/test_repo/NAMESPACE | 7 + test/resources/repos/r/test_repo/R/models.R | 48 + test/resources/repos/r/test_repo/R/utils.R | 44 + .../repos/r/test_repo/examples/analysis.R | 30 + .../repos/rego/test_repo/policies/authz.rego | 33 + .../rego/test_repo/policies/validation.rego | 23 + .../repos/rego/test_repo/utils/helpers.rego | 29 + .../repos/ruby/test_repo/.solargraph.yml | 4 + .../test_repo/examples/user_management.rb | 116 + test/resources/repos/ruby/test_repo/lib.rb | 21 + test/resources/repos/ruby/test_repo/main.rb | 23 + test/resources/repos/ruby/test_repo/models.rb | 86 + test/resources/repos/ruby/test_repo/nested.rb | 51 + .../repos/ruby/test_repo/services.rb | 60 + .../repos/ruby/test_repo/variables.rb | 150 + .../resources/repos/rust/test_repo/Cargo.lock | 7 + .../resources/repos/rust/test_repo/Cargo.toml | 6 + .../rust/test_repo/src/diagnostics_sample.rs | 9 + .../resources/repos/rust/test_repo/src/lib.rs | 23 + .../repos/rust/test_repo/src/main.rs | 10 + .../repos/rust/test_repo_2024/Cargo.lock | 7 + .../repos/rust/test_repo_2024/Cargo.toml | 6 + .../repos/rust/test_repo_2024/src/lib.rs | 21 + .../repos/rust/test_repo_2024/src/main.rs | 19 + test/resources/repos/scala/build.sbt | 1 + .../repos/scala/project/build.properties | 1 + test/resources/repos/scala/project/metals.sbt | 8 + .../resources/repos/scala/project/plugins.sbt | 1 + .../src/main/scala/com/example/Main.scala | 29 + .../src/main/scala/com/example/Utils.scala | 11 + .../scala/com/example/DiagnosticsSample.scala | 9 + .../repos/scss/test_repo/_mixins.scss | 17 + .../repos/scss/test_repo/_variables.scss | 10 + .../repos/scss/test_repo/buttons.scss | 22 + .../scss/test_repo/css/diagnostics_sample.css | 13 + .../repos/scss/test_repo/css/main.css | 41 + .../repos/scss/test_repo/css/reset.css | 18 + .../repos/scss/test_repo/css/theme.css | 15 + .../scss/test_repo/diagnostics_sample.scss | 5 + test/resources/repos/scss/test_repo/main.scss | 16 + .../repos/solidity/test_repo/.gitignore | 4 + .../test_repo/contracts/DiagnosticsSample.sol | 7 + .../solidity/test_repo/contracts/Token.sol | 124 + .../test_repo/contracts/interfaces/IERC20.sol | 30 + .../test_repo/contracts/lib/SafeMath.sol | 29 + .../repos/solidity/test_repo/foundry.toml | 5 + .../repos/svelte/test_repo/.gitignore | 27 + .../repos/svelte/test_repo/package-lock.json | 1864 ++++++ .../repos/svelte/test_repo/package.json | 28 + .../repos/svelte/test_repo/src/app.html | 12 + .../src/lib/components/Counter.svelte | 106 + .../src/lib/components/Header.svelte | 121 + .../test_repo/src/lib/components/Words.svelte | 21 + .../src/lib/diagnostics_sample.svelte | 5 + .../test_repo/src/lib/diagnostics_sample.ts | 8 + .../repos/svelte/test_repo/src/lib/game.ts | 64 + .../src/routes/(sverdle)/+page.server.ts | 55 + .../src/routes/(sverdle)/+page.svelte | 393 ++ .../routes/(sverdle)/how-to-play/+page.svelte | 95 + .../src/routes/(sverdle)/how-to-play/+page.ts | 8 + .../src/routes/(sverdle)/words.server.ts | 10 + .../test_repo/src/routes/+layout.svelte | 56 + .../svelte/test_repo/src/routes/layout.css | 104 + .../repos/svelte/test_repo/svelte.config.js | 13 + .../repos/svelte/test_repo/tsconfig.json | 20 + .../repos/svelte/test_repo/vite.config.ts | 5 + .../repos/swift/test_repo/Package.swift | 17 + .../test_repo/src/diagnostics_sample.swift | 3 + .../repos/swift/test_repo/src/main.swift | 62 + .../repos/swift/test_repo/src/utils.swift | 20 + .../repos/systemverilog/test_repo/alu.sv | 39 + .../repos/systemverilog/test_repo/counter.sv | 19 + .../test_repo/diagnostics_sample.sv | 5 + .../repos/systemverilog/test_repo/top.sv | 33 + .../repos/systemverilog/test_repo/types.svh | 16 + .../repos/terraform/test_repo/data.tf | 28 + .../terraform/test_repo/diagnostics_sample.tf | 4 + .../repos/terraform/test_repo/main.tf | 126 + .../repos/terraform/test_repo/outputs.tf | 46 + .../repos/terraform/test_repo/variables.tf | 61 + .../resources/repos/toml/test_repo/Cargo.toml | 30 + .../repos/toml/test_repo/config.toml | 66 + .../toml/test_repo/diagnostics_sample.toml | 2 + .../repos/toml/test_repo/pyproject.toml | 45 + .../cross_package_a/shared_utils.ts | 14 + .../typescript/cross_package_a/tsconfig.json | 12 + .../typescript/cross_package_b/consumer.ts | 10 + .../typescript/cross_package_b/tsconfig.json | 13 + .../test_repo/diagnostics_sample.ts | 9 + .../repos/typescript/test_repo/formatters.ts | 9 + .../repos/typescript/test_repo/index.ts | 25 + .../typescript/test_repo/jsx_component.tsx | 44 + .../repos/typescript/test_repo/tsconfig.json | 12 + .../repos/typescript/test_repo/use_helper.ts | 8 + .../repos/typescript/test_repo/ws_manager.js | 74 + test/resources/repos/vue/test_repo/.gitignore | 24 + test/resources/repos/vue/test_repo/index.html | 13 + .../repos/vue/test_repo/package.json | 14 + .../resources/repos/vue/test_repo/src/App.vue | 230 + .../vue/test_repo/src/DiagnosticsSample.vue | 3 + .../src/components/CalculatorButton.vue | 221 + .../src/components/CalculatorDisplay.vue | 228 + .../src/components/CalculatorInput.vue | 300 + .../test_repo/src/composables/useFormatter.ts | 126 + .../vue/test_repo/src/composables/useTheme.ts | 113 + .../resources/repos/vue/test_repo/src/main.ts | 9 + .../vue/test_repo/src/stores/calculator.ts | 179 + .../repos/vue/test_repo/src/types/index.ts | 32 + .../repos/vue/test_repo/tsconfig.json | 30 + .../repos/vue/test_repo/tsconfig.node.json | 11 + .../repos/vue/test_repo/vite.config.ts | 12 + .../repos/yaml/test_repo/config.yaml | 25 + test/resources/repos/yaml/test_repo/data.yaml | 41 + .../yaml/test_repo/diagnostics_sample.yaml | 5 + .../repos/yaml/test_repo/services.yml | 51 + test/resources/repos/zig/test_repo/.gitignore | 5 + test/resources/repos/zig/test_repo/build.zig | 31 + .../repos/zig/test_repo/src/calculator.zig | 66 + .../zig/test_repo/src/diagnostics_sample.zig | 3 + .../repos/zig/test_repo/src/main.zig | 32 + .../repos/zig/test_repo/src/math_utils.zig | 65 + test/resources/repos/zig/test_repo/zls.json | 12 + test/serena/__init__.py | 1 + .../__snapshots__/test_symbol_editing.ambr | 2380 +++++++ test/serena/config/__init__.py | 1 + .../config/test_global_ignored_paths.py | 269 + test/serena/config/test_serena_config.py | 657 ++ test/serena/test_activation_command.py | 100 + test/serena/test_cli_project_commands.py | 375 ++ test/serena/test_dashboard.py | 53 + test/serena/test_edit_marker.py | 13 + test/serena/test_hooks.py | 777 +++ test/serena/test_jetbrains_plugin_client.py | 20 + test/serena/test_mcp.py | 300 + test/serena/test_memories_manager.py | 766 +++ test/serena/test_serena_agent.py | 1428 ++++ test/serena/test_symbol.py | 447 ++ test/serena/test_symbol_editing.py | 639 ++ test/serena/test_task_executor.py | 126 + test/serena/test_text_utils.py | 669 ++ test/serena/test_tool_parameter_types.py | 39 + test/serena/util/test_exception.py | 116 + test/serena/util/test_file_system.py | 755 +++ test/serena/util/test_inspection.py | 45 + test/serena/util/test_yaml.py | 80 + test/solidlsp/ada/__init__.py | 0 test/solidlsp/ada/test_ada_basic.py | 144 + test/solidlsp/ada/test_ada_diagnostics.py | 17 + test/solidlsp/al/test_al_basic.py | 595 ++ test/solidlsp/angular/conftest.py | 88 + test/solidlsp/angular/test_angular_basic.py | 344 + .../angular/test_angular_diagnostics.py | 44 + .../angular/test_angular_error_cases.py | 369 + test/solidlsp/ansible/__init__.py | 0 test/solidlsp/ansible/test_ansible_basic.py | 68 + test/solidlsp/bash/__init__.py | 0 test/solidlsp/bash/test_bash_basic.py | 135 + test/solidlsp/bash/test_bash_diagnostics.py | 17 + test/solidlsp/bsl/__init__.py | 0 test/solidlsp/bsl/test_bsl_basic.py | 272 + test/solidlsp/bsl/test_bsl_diagnostics.py | 20 + test/solidlsp/clojure/__init__.py | 21 + test/solidlsp/clojure/test_clojure_basic.py | 243 + .../clojure/test_clojure_diagnostics.py | 19 + .../solidlsp/clojure/test_clojure_indexing.py | 110 + test/solidlsp/conftest.py | 111 + test/solidlsp/cpp/__init__.py | 0 test/solidlsp/cpp/test_ccls_languages.py | 24 + test/solidlsp/cpp/test_clangd_languages.py | 30 + test/solidlsp/cpp/test_clangd_logging.py | 43 + test/solidlsp/cpp/test_cpp_basic.py | 238 + test/solidlsp/cpp/test_cpp_diagnostics.py | 17 + test/solidlsp/cpp/test_cpp_unreal.py | 187 + test/solidlsp/cpp/test_cpp_unreal_unit.py | 75 + test/solidlsp/crystal/test_crystal_basic.py | 104 + .../crystal/test_crystal_diagnostics.py | 22 + test/solidlsp/csharp/test_csharp_basic.py | 341 + .../csharp/test_csharp_diagnostics.py | 17 + .../csharp/test_csharp_nuget_download.py | 160 + test/solidlsp/cue/__init__.py | 0 test/solidlsp/cue/test_cue_basic.py | 145 + test/solidlsp/dart/__init__.py | 0 test/solidlsp/dart/test_dart_basic.py | 426 ++ test/solidlsp/dart/test_dart_diagnostics.py | 17 + test/solidlsp/elixir/__init__.py | 21 + test/solidlsp/elixir/conftest.py | 174 + test/solidlsp/elixir/test_elixir_basic.py | 149 + .../elixir/test_elixir_ignored_dirs.py | 162 + .../elixir/test_elixir_integration.py | 159 + .../elixir/test_elixir_symbol_retrieval.py | 348 + test/solidlsp/elm/test_elm_basic.py | 70 + test/solidlsp/elm/test_elm_diagnostics.py | 20 + test/solidlsp/erlang/__init__.py | 33 + test/solidlsp/erlang/conftest.py | 174 + test/solidlsp/erlang/test_erlang_basic.py | 66 + .../erlang/test_erlang_ignored_dirs.py | 198 + .../erlang/test_erlang_symbol_retrieval.py | 443 ++ test/solidlsp/fortran/__init__.py | 1 + test/solidlsp/fortran/test_fortran_basic.py | 316 + .../fortran/test_fortran_diagnostics.py | 17 + test/solidlsp/fsharp/test_fsharp_basic.py | 220 + test/solidlsp/go/test_go_basic.py | 251 + test/solidlsp/go/test_go_diagnostics.py | 17 + test/solidlsp/groovy/test_groovy_basic.py | 129 + test/solidlsp/haskell/__init__.py | 1 + test/solidlsp/haskell/test_haskell_basic.py | 252 + test/solidlsp/haxe/__init__.py | 0 test/solidlsp/haxe/test_haxe_basic.py | 277 + test/solidlsp/haxe/test_haxe_diagnostics.py | 17 + test/solidlsp/hlsl/__init__.py | 1 + test/solidlsp/hlsl/test_hlsl_basic.py | 175 + test/solidlsp/hlsl/test_hlsl_diagnostics.py | 17 + test/solidlsp/hlsl/test_hlsl_full_index.py | 63 + test/solidlsp/html_ls/__init__.py | 0 test/solidlsp/html_ls/test_html_basic.py | 54 + .../solidlsp/html_ls/test_html_diagnostics.py | 27 + test/solidlsp/java/test_java_basic.py | 150 + test/solidlsp/java/test_java_diagnostics.py | 17 + .../java/test_jdtls_path_resolution.py | 646 ++ test/solidlsp/json_ls/__init__.py | 0 test/solidlsp/json_ls/test_json_basic.py | 78 + .../solidlsp/json_ls/test_json_diagnostics.py | 17 + test/solidlsp/julia/test_julia_basic.py | 76 + test/solidlsp/kotlin/test_kotlin_basic.py | 73 + .../kotlin/test_kotlin_diagnostics.py | 17 + test/solidlsp/latex/test_latex_basic.py | 37 + test/solidlsp/latex/test_latex_beamer.py | 59 + test/solidlsp/latex/test_latex_references.py | 92 + test/solidlsp/lean4/test_lean4_basic.py | 167 + test/solidlsp/lean4/test_lean4_diagnostics.py | 20 + test/solidlsp/lua/test_lua_basic.py | 302 + test/solidlsp/luau/__init__.py | 0 test/solidlsp/luau/test_luau_basic.py | 167 + .../luau/test_luau_dependency_provider.py | 165 + test/solidlsp/markdown/__init__.py | 1 + test/solidlsp/markdown/test_markdown_basic.py | 109 + test/solidlsp/matlab/__init__.py | 1 + test/solidlsp/matlab/test_matlab_basic.py | 129 + test/solidlsp/msl/__init__.py | 0 test/solidlsp/msl/test_msl_basic.py | 161 + test/solidlsp/nix/test_nix_basic.py | 259 + test/solidlsp/ocaml/test_cross_file_refs.py | 77 + test/solidlsp/ocaml/test_ocaml_basic.py | 169 + test/solidlsp/ocaml/test_ocaml_diagnostics.py | 19 + test/solidlsp/pascal/__init__.py | 15 + .../pascal/test_pascal_auto_update.py | 255 + test/solidlsp/pascal/test_pascal_basic.py | 214 + .../pascal/test_pascal_diagnostics.py | 25 + test/solidlsp/perl/__init__.py | 0 test/solidlsp/perl/test_perl_basic.py | 111 + test/solidlsp/perl/test_perl_config.py | 220 + test/solidlsp/php/test_php_basic.py | 294 + test/solidlsp/php/test_php_diagnostics.py | 17 + test/solidlsp/php/test_phpantom.py | 231 + test/solidlsp/powershell/__init__.py | 1 + .../powershell/test_powershell_basic.py | 197 + .../powershell/test_powershell_diagnostics.py | 17 + test/solidlsp/python/test_python_basic.py | 269 + .../python/test_python_diagnostics.py | 19 + .../test_retrieval_with_ignored_dirs.py | 63 + test/solidlsp/python/test_symbol_retrieval.py | 410 ++ test/solidlsp/r/__init__.py | 1 + test/solidlsp/r/test_r_basic.py | 111 + test/solidlsp/rego/test_rego_basic.py | 153 + test/solidlsp/ruby/test_ruby_basic.py | 73 + .../ruby/test_ruby_symbol_retrieval.py | 628 ++ test/solidlsp/rust/test_rust_2024_edition.py | 111 + .../rust/test_rust_analyzer_detection.py | 555 ++ test/solidlsp/rust/test_rust_basic.py | 99 + test/solidlsp/rust/test_rust_diagnostics.py | 17 + test/solidlsp/scala/test_metals_db_utils.py | 268 + .../scala/test_scala_language_server.py | 73 + .../scala/test_scala_stale_lock_handling.py | 261 + test/solidlsp/scss/__init__.py | 0 test/solidlsp/scss/test_scss_basic.py | 398 ++ test/solidlsp/scss/test_scss_diagnostics.py | 42 + test/solidlsp/solidity/__init__.py | 0 test/solidlsp/solidity/test_solidity_basic.py | 207 + .../solidity/test_solidity_diagnostics.py | 115 + test/solidlsp/svelte/__init__.py | 15 + test/solidlsp/svelte/conftest.py | 91 + test/solidlsp/svelte/test_svelte_basic.py | 100 + .../solidlsp/svelte/test_svelte_references.py | 26 + test/solidlsp/svelte/test_svelte_rename.py | 123 + test/solidlsp/svelte/test_svelte_symbols.py | 67 + test/solidlsp/swift/test_swift_basic.py | 245 + test/solidlsp/systemverilog/__init__.py | 0 .../systemverilog/test_systemverilog_basic.py | 283 + .../test_systemverilog_detection.py | 332 + .../test_systemverilog_diagnostics.py | 22 + .../terraform/test_terraform_basic.py | 72 + .../terraform/test_terraform_diagnostics.py | 17 + test/solidlsp/test_is_ignored_path_missing.py | 196 + test/solidlsp/test_ls_common.py | 78 + test/solidlsp/test_ls_config.py | 46 + .../test_lsp_protocol_handler_server.py | 282 + test/solidlsp/test_rename_didopen.py | 45 + .../test_typescript_timeout_policy.py | 269 + test/solidlsp/toml/__init__.py | 1 + test/solidlsp/toml/test_toml_basic.py | 249 + test/solidlsp/toml/test_toml_diagnostics.py | 17 + test/solidlsp/toml/test_toml_edge_cases.py | 273 + test/solidlsp/toml/test_toml_ignored_dirs.py | 62 + .../toml/test_toml_symbol_retrieval.py | 191 + .../typescript/test_typescript_basic.py | 106 + .../test_typescript_cross_package.py | 99 + .../typescript/test_typescript_diagnostics.py | 17 + test/solidlsp/util/diagnostics.py | 19 + test/solidlsp/util/test_ls_utils.py | 42 + test/solidlsp/util/test_zip.py | 99 + test/solidlsp/vue/__init__.py | 15 + test/solidlsp/vue/test_vue_basic.py | 385 ++ test/solidlsp/vue/test_vue_diagnostics.py | 17 + test/solidlsp/vue/test_vue_error_cases.py | 365 + test/solidlsp/vue/test_vue_rename.py | 241 + .../solidlsp/vue/test_vue_symbol_retrieval.py | 249 + test/solidlsp/yaml_ls/__init__.py | 0 test/solidlsp/yaml_ls/test_yaml_basic.py | 191 + .../solidlsp/yaml_ls/test_yaml_diagnostics.py | 17 + test/solidlsp/zig/test_zig_basic.py | 372 ++ uv.lock | 3758 +++++++++++ 973 files changed, 133141 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/issue--bug--performance-problem--question-.md create mode 100644 .github/copilot-instructions.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/codespell.yml create mode 100644 .github/workflows/create-release.yml create mode 100644 .github/workflows/docker.yml create mode 100644 .github/workflows/docs.yaml create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/pytest.yml create mode 100644 .gitignore create mode 100644 .serena/.gitignore create mode 100644 .serena/memories/adding_new_language_support_guide.md create mode 100644 .serena/memories/conventions.md create mode 100644 .serena/memories/core.md create mode 100644 .serena/memories/creating_pull_requests.md create mode 100644 .serena/memories/memory_maintenance.md create mode 100644 .serena/memories/serena_repository_structure.md create mode 100644 .serena/memories/suggested_commands.md create mode 100644 .serena/memories/task_completion.md create mode 100644 .serena/memories/tech_stack.md create mode 100644 .serena/project.yml create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 CONTRIBUTING.md create mode 100644 DOCKER.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README-dev.md create mode 100644 README.md create mode 100644 README.wehub.md create mode 100644 compose.yaml create mode 100644 docker_build_and_run.sh create mode 100644 docs/.gitignore create mode 100644 docs/01-about/.gitignore create mode 100644 docs/01-about/020_programming-languages.md create mode 100644 docs/01-about/030_serena-in-action.md create mode 100644 docs/01-about/050_acknowledgements.md create mode 100644 docs/02-usage/000_intro.md create mode 100644 docs/02-usage/010_installation.md create mode 100644 docs/02-usage/020_running.md create mode 100644 docs/02-usage/025_jetbrains_plugin.md create mode 100644 docs/02-usage/030_clients.md create mode 100644 docs/02-usage/040_workflow.md create mode 100644 docs/02-usage/045_memories.md create mode 100644 docs/02-usage/050_configuration.md create mode 100644 docs/02-usage/060_dashboard.md create mode 100644 docs/02-usage/065_logs.md create mode 100644 docs/02-usage/070_security.md create mode 100644 docs/02-usage/999_additional-usage.md create mode 100644 docs/03-special-guides/000_intro.md create mode 100644 docs/03-special-guides/cpp_setup.md create mode 100644 docs/03-special-guides/custom_agent.md create mode 100644 docs/03-special-guides/godot_gdscript_setup_guide_for_serena.md create mode 100644 docs/03-special-guides/groovy_setup_guide_for_serena.md create mode 100644 docs/03-special-guides/ocaml_setup_guide_for_serena.md create mode 100644 docs/03-special-guides/scala_setup_guide_for_serena.md create mode 100644 docs/03-special-guides/serena_on_chatgpt.md create mode 100644 docs/03-special-guides/unreal_engine_setup_guide_for_serena.md create mode 100644 docs/04-evaluation/000_evaluation-intro.md create mode 100644 docs/04-evaluation/010_methodology.md create mode 100644 docs/04-evaluation/020_prompts/000_prompts.md create mode 100644 docs/04-evaluation/020_prompts/010_evaluation-prompt.md create mode 100644 docs/04-evaluation/020_prompts/020_summary-prompt.md create mode 100644 docs/04-evaluation/030_results/000_evaluation-results.md create mode 100644 docs/04-evaluation/030_results/010_cc_on_tianshou.md create mode 100644 docs/04-evaluation/030_results/020_codex_on_jbplugin.md create mode 100644 docs/04-evaluation/030_results/030_copilot_cli_on_ente.md create mode 100644 docs/04-evaluation/030_results/040_glm_on_tianshou.md create mode 100644 docs/04-evaluation/030_results/050_junie_plugin_on_tianshou.md create mode 100644 docs/_config.yml create mode 100644 docs/_static/images/jetbrains-marketplace-button.png create mode 100644 docs/autogen_docs.py create mode 100644 docs/create_toc.py create mode 100644 docs/index.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 news/20260111.html create mode 100644 news/20260303.html create mode 100644 news/20260321.html create mode 100644 news/20260330.html create mode 100644 news/20260404.html create mode 100644 news/20260411.html create mode 100644 news/20260414.html create mode 100644 news/20260427.html create mode 100644 news/20260512.html create mode 100644 pyproject.toml create mode 100644 repo_dir_sync.py create mode 100644 resources/jetbrains-marketplace-button.cdr create mode 100644 resources/serena-block-diagram.afdesign create mode 100644 resources/serena-block-diagram.svg create mode 100644 resources/serena-icons.cdr create mode 100644 resources/serena-logo-dark-mode.svg create mode 100644 resources/serena-logo.cdr create mode 100644 resources/serena-logo.svg create mode 100644 resources/serena-mac-dock-icon.cdr create mode 100644 resources/serena-mac-tray-icon.cdr create mode 100644 resources/vscode_sponsor_logo.png create mode 100644 scripts/agno_agent.py create mode 100644 scripts/build_news_json.py create mode 100644 scripts/bump_version.py create mode 100644 scripts/demo_cli_call.py create mode 100644 scripts/demo_diagnostics.py create mode 100644 scripts/demo_find_defining_symbol.py create mode 100644 scripts/demo_find_implementing_symbol.py create mode 100644 scripts/demo_progressive_tool_shortening.py create mode 100644 scripts/demo_run_tools.py create mode 100644 scripts/gen_prompt_factory.py create mode 100644 scripts/mcp_server.py create mode 100644 scripts/print_language_list.py create mode 100644 scripts/print_mode_context_options.py create mode 100644 scripts/print_tool_overview.py create mode 100644 scripts/profile_tool_call.py create mode 100644 server.json create mode 100644 src/README.md create mode 100644 src/interprompt/.syncCommitId.remote create mode 100644 src/interprompt/.syncCommitId.this create mode 100644 src/interprompt/__init__.py create mode 100644 src/interprompt/jinja_template.py create mode 100644 src/interprompt/multilang_prompt.py create mode 100644 src/interprompt/prompt_factory.py create mode 100644 src/interprompt/util/__init__.py create mode 100644 src/interprompt/util/class_decorators.py create mode 100644 src/serena/__init__.py create mode 100644 src/serena/agent.py create mode 100644 src/serena/agno.py create mode 100644 src/serena/analytics.py create mode 100644 src/serena/cli.py create mode 100644 src/serena/code_editor.py create mode 100644 src/serena/config/__init__.py create mode 100644 src/serena/config/client_setup.py create mode 100644 src/serena/config/context_mode.py create mode 100644 src/serena/config/serena_config.py create mode 100644 src/serena/constants.py create mode 100644 src/serena/dashboard.py create mode 100644 src/serena/generated/generated_prompt_factory.py create mode 100644 src/serena/gui_log_viewer.py create mode 100644 src/serena/hooks.py create mode 100644 src/serena/jetbrains/jetbrains_plugin_client.py create mode 100644 src/serena/jetbrains/jetbrains_types.py create mode 100644 src/serena/ls_manager.py create mode 100644 src/serena/mcp.py create mode 100644 src/serena/memories/memory_manager.py create mode 100644 src/serena/memories/memory_reference_analysis.py create mode 100644 src/serena/project.py create mode 100644 src/serena/project_server.py create mode 100644 src/serena/prompt_factory.py create mode 100644 src/serena/resources/config/contexts/agent.yml create mode 100644 src/serena/resources/config/contexts/antigravity.yml create mode 100644 src/serena/resources/config/contexts/chatgpt.yml create mode 100644 src/serena/resources/config/contexts/claude-code.yml create mode 100644 src/serena/resources/config/contexts/codebuddy.yml create mode 100644 src/serena/resources/config/contexts/codex.yml create mode 100644 src/serena/resources/config/contexts/context.template.yml create mode 100644 src/serena/resources/config/contexts/copilot-cli.yml create mode 100644 src/serena/resources/config/contexts/desktop-app.yml create mode 100644 src/serena/resources/config/contexts/ide.yml create mode 100644 src/serena/resources/config/contexts/jb-ai-assistant.yml create mode 100644 src/serena/resources/config/contexts/jb-copilot-plugin.yml create mode 100644 src/serena/resources/config/contexts/junie.yml create mode 100644 src/serena/resources/config/contexts/oaicompat-agent.yml create mode 100644 src/serena/resources/config/contexts/vscode.yml create mode 100644 src/serena/resources/config/internal_modes/jetbrains.yml create mode 100644 src/serena/resources/config/modes/benchmark.yml create mode 100644 src/serena/resources/config/modes/editing.yml create mode 100644 src/serena/resources/config/modes/interactive.yml create mode 100644 src/serena/resources/config/modes/mode.template.yml create mode 100644 src/serena/resources/config/modes/no-memories.yml create mode 100644 src/serena/resources/config/modes/no-onboarding.yml create mode 100644 src/serena/resources/config/modes/onboarding.yml create mode 100644 src/serena/resources/config/modes/one-shot.yml create mode 100644 src/serena/resources/config/modes/planning.yml create mode 100644 src/serena/resources/config/modes/query-projects.yml create mode 100644 src/serena/resources/config/prompt_templates/info_prompts.yml create mode 100644 src/serena/resources/config/prompt_templates/simple_tool_outputs.yml create mode 100644 src/serena/resources/config/prompt_templates/system_prompt.yml create mode 100644 src/serena/resources/dashboard/dashboard.css create mode 100644 src/serena/resources/dashboard/dashboard.js create mode 100644 src/serena/resources/dashboard/index.html create mode 100644 src/serena/resources/dashboard/jquery.min.js create mode 100644 src/serena/resources/dashboard/serena-icon-1024-mac.png create mode 100644 src/serena/resources/dashboard/serena-icon-16.png create mode 100644 src/serena/resources/dashboard/serena-icon-32.png create mode 100644 src/serena/resources/dashboard/serena-icon-48.png create mode 100644 src/serena/resources/dashboard/serena-icon-64.png create mode 100644 src/serena/resources/dashboard/serena-icon-tray-mac.png create mode 100644 src/serena/resources/dashboard/serena-logo-dark-mode.svg create mode 100644 src/serena/resources/dashboard/serena-logo.svg create mode 100644 src/serena/resources/dashboard/serena-logs-dark-mode.png create mode 100644 src/serena/resources/dashboard/serena-logs.png create mode 100644 src/serena/resources/dashboard/serena.ico create mode 100644 src/serena/resources/memory_maintenance.md create mode 100644 src/serena/resources/project.local.template.yml create mode 100644 src/serena/resources/project.template.yml create mode 100644 src/serena/resources/serena_config.template.yml create mode 100644 src/serena/symbol.py create mode 100644 src/serena/task_executor.py create mode 100644 src/serena/tools/__init__.py create mode 100644 src/serena/tools/cmd_tools.py create mode 100644 src/serena/tools/config_tools.py create mode 100644 src/serena/tools/file_tools.py create mode 100644 src/serena/tools/jetbrains_tools.py create mode 100644 src/serena/tools/memory_tools.py create mode 100644 src/serena/tools/query_project_tools.py create mode 100644 src/serena/tools/symbol_tools.py create mode 100644 src/serena/tools/tools_base.py create mode 100644 src/serena/tools/workflow_tools.py create mode 100644 src/serena/util/class_decorators.py create mode 100644 src/serena/util/cli_util.py create mode 100644 src/serena/util/dataclass.py create mode 100644 src/serena/util/dotnet.py create mode 100644 src/serena/util/exception.py create mode 100644 src/serena/util/file_proxy.py create mode 100644 src/serena/util/file_system.py create mode 100644 src/serena/util/git.py create mode 100644 src/serena/util/gui.py create mode 100644 src/serena/util/inspection.py create mode 100644 src/serena/util/logging.py create mode 100644 src/serena/util/ls_diagnostics.py create mode 100644 src/serena/util/pywebview.py create mode 100644 src/serena/util/shell.py create mode 100644 src/serena/util/text_utils.py create mode 100644 src/serena/util/thread.py create mode 100644 src/serena/util/version.py create mode 100644 src/serena/util/yaml.py create mode 100644 src/solidlsp/.gitignore create mode 100644 src/solidlsp/__init__.py create mode 100644 src/solidlsp/initialize_params.py create mode 100644 src/solidlsp/language_servers/ada_language_server.py create mode 100644 src/solidlsp/language_servers/al_language_server.py create mode 100644 src/solidlsp/language_servers/angular_language_server.py create mode 100644 src/solidlsp/language_servers/ansible_language_server.py create mode 100644 src/solidlsp/language_servers/bash_language_server.py create mode 100644 src/solidlsp/language_servers/bsl_language_server.py create mode 100644 src/solidlsp/language_servers/ccls_language_server.py create mode 100644 src/solidlsp/language_servers/clangd_language_server.py create mode 100644 src/solidlsp/language_servers/clojure_lsp.py create mode 100644 src/solidlsp/language_servers/common.py create mode 100644 src/solidlsp/language_servers/crystal_language_server.py create mode 100644 src/solidlsp/language_servers/csharp_language_server.py create mode 100644 src/solidlsp/language_servers/cue_language_server.py create mode 100644 src/solidlsp/language_servers/dart_language_server.py create mode 100644 src/solidlsp/language_servers/eclipse_jdtls.py create mode 100644 src/solidlsp/language_servers/elixir_tools/README.md create mode 100644 src/solidlsp/language_servers/elixir_tools/__init__.py create mode 100644 src/solidlsp/language_servers/elixir_tools/elixir_tools.py create mode 100644 src/solidlsp/language_servers/elm_language_server.py create mode 100644 src/solidlsp/language_servers/erlang_language_server.py create mode 100644 src/solidlsp/language_servers/fortran_language_server.py create mode 100644 src/solidlsp/language_servers/fsharp_language_server.py create mode 100644 src/solidlsp/language_servers/godot_language_server.py create mode 100644 src/solidlsp/language_servers/gopls.py create mode 100644 src/solidlsp/language_servers/groovy_language_server.py create mode 100644 src/solidlsp/language_servers/haskell_language_server.py create mode 100644 src/solidlsp/language_servers/haxe_language_server.py create mode 100644 src/solidlsp/language_servers/hlsl_language_server.py create mode 100644 src/solidlsp/language_servers/intelephense.py create mode 100644 src/solidlsp/language_servers/jedi_server.py create mode 100644 src/solidlsp/language_servers/json_language_server.py create mode 100644 src/solidlsp/language_servers/julia_server.py create mode 100644 src/solidlsp/language_servers/kotlin_language_server.py create mode 100644 src/solidlsp/language_servers/lean4_language_server.py create mode 100644 src/solidlsp/language_servers/lua_ls.py create mode 100644 src/solidlsp/language_servers/luau_lsp.py create mode 100644 src/solidlsp/language_servers/marksman.py create mode 100644 src/solidlsp/language_servers/matlab_language_server.py create mode 100644 src/solidlsp/language_servers/msl_language_server.py create mode 100644 src/solidlsp/language_servers/msl_lsp_server.py create mode 100644 src/solidlsp/language_servers/nixd_ls.py create mode 100644 src/solidlsp/language_servers/ocaml_lsp_server.py create mode 100644 src/solidlsp/language_servers/omnisharp.py create mode 100644 src/solidlsp/language_servers/omnisharp/initialize_params.json create mode 100644 src/solidlsp/language_servers/omnisharp/runtime_dependencies.json create mode 100644 src/solidlsp/language_servers/omnisharp/workspace_did_change_configuration.json create mode 100644 src/solidlsp/language_servers/pascal_server.py create mode 100644 src/solidlsp/language_servers/perl_language_server.py create mode 100644 src/solidlsp/language_servers/phpactor.py create mode 100644 src/solidlsp/language_servers/phpantom.py create mode 100644 src/solidlsp/language_servers/powershell_language_server.py create mode 100644 src/solidlsp/language_servers/pyrefly_server.py create mode 100644 src/solidlsp/language_servers/pyright_server.py create mode 100644 src/solidlsp/language_servers/r_language_server.py create mode 100644 src/solidlsp/language_servers/regal_server.py create mode 100644 src/solidlsp/language_servers/ruby_lsp.py create mode 100644 src/solidlsp/language_servers/rust_analyzer.py create mode 100644 src/solidlsp/language_servers/scala_language_server.py create mode 100644 src/solidlsp/language_servers/solargraph.py create mode 100644 src/solidlsp/language_servers/solidity_language_server.py create mode 100644 src/solidlsp/language_servers/some_sass_language_server.py create mode 100644 src/solidlsp/language_servers/sourcekit_lsp.py create mode 100644 src/solidlsp/language_servers/svelte_language_server.py create mode 100644 src/solidlsp/language_servers/systemverilog_server.py create mode 100644 src/solidlsp/language_servers/taplo_server.py create mode 100644 src/solidlsp/language_servers/terraform_ls.py create mode 100644 src/solidlsp/language_servers/texlab_language_server.py create mode 100644 src/solidlsp/language_servers/ty_server.py create mode 100644 src/solidlsp/language_servers/typescript_language_server.py create mode 100644 src/solidlsp/language_servers/vscode_html_language_server.py create mode 100644 src/solidlsp/language_servers/vts_language_server.py create mode 100644 src/solidlsp/language_servers/vue_language_server.py create mode 100644 src/solidlsp/language_servers/yaml_language_server.py create mode 100644 src/solidlsp/language_servers/zls.py create mode 100644 src/solidlsp/ls.py create mode 100644 src/solidlsp/ls_config.py create mode 100644 src/solidlsp/ls_exceptions.py create mode 100644 src/solidlsp/ls_process.py create mode 100644 src/solidlsp/ls_request.py create mode 100644 src/solidlsp/ls_types.py create mode 100644 src/solidlsp/ls_utils.py create mode 100644 src/solidlsp/lsp_protocol_handler/lsp_constants.py create mode 100644 src/solidlsp/lsp_protocol_handler/lsp_requests.py create mode 100644 src/solidlsp/lsp_protocol_handler/lsp_types.py create mode 100644 src/solidlsp/lsp_protocol_handler/server.py create mode 100644 src/solidlsp/settings.py create mode 100644 src/solidlsp/util/cache.py create mode 100644 src/solidlsp/util/metals_db_utils.py create mode 100644 src/solidlsp/util/subprocess_util.py create mode 100644 src/solidlsp/util/zip.py create mode 100644 sync.py create mode 100644 test/__init__.py create mode 100644 test/conftest.py create mode 100644 test/resources/repos/ada/test_repo/.gitignore create mode 100644 test/resources/repos/ada/test_repo/default.gpr create mode 100644 test/resources/repos/ada/test_repo/src/diagnostics_sample.adb create mode 100644 test/resources/repos/ada/test_repo/src/helper.adb create mode 100644 test/resources/repos/ada/test_repo/src/helper.ads create mode 100644 test/resources/repos/ada/test_repo/src/main.adb create mode 100644 test/resources/repos/al/test_repo/app.json create mode 100644 test/resources/repos/al/test_repo/src/Codeunits/CustomerMgt.Codeunit.al create mode 100644 test/resources/repos/al/test_repo/src/Codeunits/PaymentProcessorImpl.Codeunit.al create mode 100644 test/resources/repos/al/test_repo/src/Enums/CustomerType.Enum.al create mode 100644 test/resources/repos/al/test_repo/src/Interfaces/IPaymentProcessor.Interface.al create mode 100644 test/resources/repos/al/test_repo/src/Pages/CustomerCard.Page.al create mode 100644 test/resources/repos/al/test_repo/src/Pages/CustomerList.Page.al create mode 100644 test/resources/repos/al/test_repo/src/TableExtensions/Item.TableExt.al create mode 100644 test/resources/repos/al/test_repo/src/Tables/Customer.Table.al create mode 100644 test/resources/repos/angular/test_repo/.gitignore create mode 100644 test/resources/repos/angular/test_repo/angular.json create mode 100644 test/resources/repos/angular/test_repo/package-lock.json create mode 100644 test/resources/repos/angular/test_repo/package.json create mode 100644 test/resources/repos/angular/test_repo/src/app/app.component.html create mode 100644 test/resources/repos/angular/test_repo/src/app/app.component.ts create mode 100644 test/resources/repos/angular/test_repo/src/app/diagnostics_sample.html create mode 100644 test/resources/repos/angular/test_repo/src/app/diagnostics_sample.ts create mode 100644 test/resources/repos/angular/test_repo/src/app/exclaim.pipe.ts create mode 100644 test/resources/repos/angular/test_repo/src/app/greeter.interface.ts create mode 100644 test/resources/repos/angular/test_repo/src/app/greeting.service.ts create mode 100644 test/resources/repos/angular/test_repo/src/app/item-card.component.ts create mode 100644 test/resources/repos/angular/test_repo/src/index.html create mode 100644 test/resources/repos/angular/test_repo/src/main.ts create mode 100644 test/resources/repos/angular/test_repo/tsconfig.app.json create mode 100644 test/resources/repos/angular/test_repo/tsconfig.json create mode 100644 test/resources/repos/ansible/test_repo/inventory/hosts.yml create mode 100644 test/resources/repos/ansible/test_repo/playbook.yml create mode 100644 test/resources/repos/ansible/test_repo/roles/common/defaults/main.yml create mode 100644 test/resources/repos/ansible/test_repo/roles/common/handlers/main.yml create mode 100644 test/resources/repos/ansible/test_repo/roles/common/tasks/main.yml create mode 100644 test/resources/repos/bash/test_repo/config.sh create mode 100644 test/resources/repos/bash/test_repo/diagnostics_sample.sh create mode 100644 test/resources/repos/bash/test_repo/main.sh create mode 100644 test/resources/repos/bash/test_repo/utils.sh create mode 100644 test/resources/repos/bsl/test_repo/.bsl-language-server.json create mode 100644 test/resources/repos/bsl/test_repo/.gitkeep create mode 100644 test/resources/repos/bsl/test_repo/CommonModule.bsl create mode 100644 test/resources/repos/bsl/test_repo/ObjectModule.bsl create mode 100644 test/resources/repos/bsl/test_repo/diagnostics_sample.bsl create mode 100644 test/resources/repos/bsl/test_repo/src/CommonModules/ОбщийМодуль1.xml create mode 100644 test/resources/repos/bsl/test_repo/src/CommonModules/ОбщийМодуль1/Ext/Module.bsl create mode 100644 test/resources/repos/bsl/test_repo/src/CommonModules/ОбщийМодуль2.xml create mode 100644 test/resources/repos/bsl/test_repo/src/CommonModules/ОбщийМодуль2/Ext/Module.bsl create mode 100644 test/resources/repos/bsl/test_repo/src/ConfigDumpInfo.xml create mode 100644 test/resources/repos/bsl/test_repo/src/Configuration.xml create mode 100644 test/resources/repos/bsl/test_repo/src/Languages/Русский.xml create mode 100644 test/resources/repos/clojure/test_repo/deps.edn create mode 100644 test/resources/repos/clojure/test_repo/src/test_app/core.clj create mode 100644 test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj create mode 100644 test/resources/repos/clojure/test_repo/src/test_app/extra.clj create mode 100644 test/resources/repos/clojure/test_repo/src/test_app/utils.clj create mode 100644 test/resources/repos/clojure/test_repo/sub_module/deps.edn create mode 100644 test/resources/repos/clojure/test_repo/sub_module/src/sub_module_app/consumer.clj create mode 100644 test/resources/repos/cpp/test_repo/C/a.c create mode 100644 test/resources/repos/cpp/test_repo/C/b.h create mode 100644 test/resources/repos/cpp/test_repo/CUDA/a.cu create mode 100644 test/resources/repos/cpp/test_repo/CXX20/a.cpp create mode 100644 test/resources/repos/cpp/test_repo/CXX20/b.cpp create mode 100644 test/resources/repos/cpp/test_repo/CXX20/b.cppm create mode 100644 test/resources/repos/cpp/test_repo/HIP/a.hip create mode 100644 test/resources/repos/cpp/test_repo/Objective-C/a.m create mode 100644 test/resources/repos/cpp/test_repo/OpenCL/a.cl create mode 100644 test/resources/repos/cpp/test_repo/a.cpp create mode 100644 test/resources/repos/cpp/test_repo/b.cpp create mode 100644 test/resources/repos/cpp/test_repo/b.hpp create mode 100644 test/resources/repos/cpp/test_repo/compile_commands.json create mode 100644 test/resources/repos/cpp/test_repo/diagnostics_sample.cpp create mode 100644 test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityActor.generated.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityComponent.gen.cpp create mode 100644 test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityComponent.generated.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityTypes.generated.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/Damageable.generated.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/GameCharacter.generated.h create mode 100644 test/resources/repos/cpp/ue_test_repo/MyGame.uproject create mode 100644 test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityActor.cpp create mode 100644 test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityActor.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityComponent.cpp create mode 100644 test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityComponent.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityTypes.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Source/TestGame/Damageable.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Source/TestGame/GameCharacter.cpp create mode 100644 test/resources/repos/cpp/ue_test_repo/Source/TestGame/GameCharacter.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Source/TestGame/TestGameLog.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Source/TestGame/TestGameModule.cpp create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Components/ActorComponent.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Array.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Map.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Set.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/CoreMinimal.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/CoreTypes.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Delegates/DelegateCombinations.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Engine/World.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Actor.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Character.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Pawn.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Logging/LogMacros.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Math/MathFwd.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Misc/Optional.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Modules/ModuleManager.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Templates/SharedPointer.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Templates/SubclassOf.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Templates/UniquePtr.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/UObject/Interface.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/UObject/Object.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/UObject/ObjectMacros.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/UObject/ObjectPtr.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/UObject/SoftObjectPtr.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/UObject/UObjectGlobals.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/UObject/WeakObjectPtr.h create mode 100644 test/resources/repos/cpp/ue_test_repo/compile_commands.json create mode 100644 test/resources/repos/crystal/test_repo/shard.yml create mode 100644 test/resources/repos/crystal/test_repo/src/diagnostics_sample.cr create mode 100644 test/resources/repos/crystal/test_repo/src/main.cr create mode 100644 test/resources/repos/crystal/test_repo/src/utils.cr create mode 100644 test/resources/repos/csharp/test_repo/.gitignore create mode 100644 test/resources/repos/csharp/test_repo/DiagnosticsSample.cs create mode 100644 test/resources/repos/csharp/test_repo/Models/Person.cs create mode 100644 test/resources/repos/csharp/test_repo/Program.cs create mode 100644 test/resources/repos/csharp/test_repo/Services/ConsoleGreeter.cs create mode 100644 test/resources/repos/csharp/test_repo/Services/IGreeter.cs create mode 100644 test/resources/repos/csharp/test_repo/TestProject.csproj create mode 100644 test/resources/repos/csharp/test_repo/serena.sln create mode 100644 test/resources/repos/cue/test_repo/.gitignore create mode 100644 test/resources/repos/cue/test_repo/cue.mod/module.cue create mode 100644 test/resources/repos/cue/test_repo/lib.cue create mode 100644 test/resources/repos/cue/test_repo/main.cue create mode 100644 test/resources/repos/cue/test_repo/schema.cue create mode 100644 test/resources/repos/dart/test_repo/.gitignore create mode 100644 test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart create mode 100644 test/resources/repos/dart/test_repo/lib/helper.dart create mode 100644 test/resources/repos/dart/test_repo/lib/main.dart create mode 100644 test/resources/repos/dart/test_repo/lib/models.dart create mode 100644 test/resources/repos/dart/test_repo/pubspec.yaml create mode 100644 test/resources/repos/elixir/test_repo/.gitignore create mode 100644 test/resources/repos/elixir/test_repo/lib/diagnostics_sample.ex create mode 100644 test/resources/repos/elixir/test_repo/lib/examples.ex create mode 100644 test/resources/repos/elixir/test_repo/lib/ignored_dir/ignored_module.ex create mode 100644 test/resources/repos/elixir/test_repo/lib/models.ex create mode 100644 test/resources/repos/elixir/test_repo/lib/services.ex create mode 100644 test/resources/repos/elixir/test_repo/lib/test_repo.ex create mode 100644 test/resources/repos/elixir/test_repo/lib/utils.ex create mode 100644 test/resources/repos/elixir/test_repo/mix.exs create mode 100644 test/resources/repos/elixir/test_repo/mix.lock create mode 100644 test/resources/repos/elixir/test_repo/scripts/build_script.ex create mode 100644 test/resources/repos/elixir/test_repo/test/models_test.exs create mode 100644 test/resources/repos/elixir/test_repo/test/test_repo_test.exs create mode 100644 test/resources/repos/elm/test_repo/DiagnosticsSample.elm create mode 100644 test/resources/repos/elm/test_repo/Main.elm create mode 100644 test/resources/repos/elm/test_repo/Utils.elm create mode 100644 test/resources/repos/elm/test_repo/elm.json create mode 100644 test/resources/repos/erlang/test_repo/hello.erl create mode 100644 test/resources/repos/erlang/test_repo/ignored_dir/ignored_module.erl create mode 100644 test/resources/repos/erlang/test_repo/include/records.hrl create mode 100644 test/resources/repos/erlang/test_repo/include/types.hrl create mode 100644 test/resources/repos/erlang/test_repo/math_utils.erl create mode 100644 test/resources/repos/erlang/test_repo/rebar.config create mode 100644 test/resources/repos/erlang/test_repo/src/app.erl create mode 100644 test/resources/repos/erlang/test_repo/src/diagnostics_sample.erl create mode 100644 test/resources/repos/erlang/test_repo/src/models.erl create mode 100644 test/resources/repos/erlang/test_repo/src/services.erl create mode 100644 test/resources/repos/erlang/test_repo/src/utils.erl create mode 100644 test/resources/repos/erlang/test_repo/test/models_tests.erl create mode 100644 test/resources/repos/erlang/test_repo/test/utils_tests.erl create mode 100644 test/resources/repos/fortran/test_repo/diagnostics_sample.f90 create mode 100644 test/resources/repos/fortran/test_repo/main.f90 create mode 100644 test/resources/repos/fortran/test_repo/modules/geometry.f90 create mode 100644 test/resources/repos/fortran/test_repo/modules/math_utils.f90 create mode 100644 test/resources/repos/fsharp/test_repo/.gitignore create mode 100644 test/resources/repos/fsharp/test_repo/Calculator.fs create mode 100644 test/resources/repos/fsharp/test_repo/DiagnosticsSample.fs create mode 100644 test/resources/repos/fsharp/test_repo/Formatter.fs create mode 100644 test/resources/repos/fsharp/test_repo/Models/Person.fs create mode 100644 test/resources/repos/fsharp/test_repo/Program.fs create mode 100644 test/resources/repos/fsharp/test_repo/README.md create mode 100644 test/resources/repos/fsharp/test_repo/Shapes.fs create mode 100644 test/resources/repos/fsharp/test_repo/TestProject.fsproj create mode 100644 test/resources/repos/go/test_repo/buildtags/foo.go create mode 100644 test/resources/repos/go/test_repo/buildtags/notfoo.go create mode 100644 test/resources/repos/go/test_repo/diagnostics_sample.go create mode 100644 test/resources/repos/go/test_repo/go.mod create mode 100644 test/resources/repos/go/test_repo/main.go create mode 100644 test/resources/repos/groovy/test_repo/.gitignore create mode 100644 test/resources/repos/groovy/test_repo/build.gradle create mode 100644 test/resources/repos/groovy/test_repo/src/main/groovy/com/example/DiagnosticsSample.groovy create mode 100644 test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Main.groovy create mode 100644 test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Model.groovy create mode 100644 test/resources/repos/groovy/test_repo/src/main/groovy/com/example/ModelUser.groovy create mode 100644 test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Utils.groovy create mode 100644 test/resources/repos/haskell/test_repo/app/Main.hs create mode 100644 test/resources/repos/haskell/test_repo/package.yaml create mode 100644 test/resources/repos/haskell/test_repo/src/Calculator.hs create mode 100644 test/resources/repos/haskell/test_repo/src/DiagnosticsSample.hs create mode 100644 test/resources/repos/haskell/test_repo/src/Helper.hs create mode 100644 test/resources/repos/haskell/test_repo/stack.yaml create mode 100644 test/resources/repos/haxe/test_repo/.gitignore create mode 100644 test/resources/repos/haxe/test_repo/build.hxml create mode 100644 test/resources/repos/haxe/test_repo/src/DiagnosticsSample.hx create mode 100644 test/resources/repos/haxe/test_repo/src/Main.hx create mode 100644 test/resources/repos/haxe/test_repo/src/utils/Helper.hx create mode 100644 test/resources/repos/hlsl/test_repo/common.hlsl create mode 100644 test/resources/repos/hlsl/test_repo/compute_test.hlsl create mode 100644 test/resources/repos/hlsl/test_repo/diagnostics_sample.hlsl create mode 100644 test/resources/repos/hlsl/test_repo/lighting.hlsl create mode 100644 test/resources/repos/hlsl/test_repo/terrain/terrain_sdf.hlsl create mode 100644 test/resources/repos/html/test_repo/about.html create mode 100644 test/resources/repos/html/test_repo/diagnostics_sample.html create mode 100644 test/resources/repos/html/test_repo/index.html create mode 100644 test/resources/repos/java/test_repo/pom.xml create mode 100644 test/resources/repos/java/test_repo/src/main/java/test_repo/ConsoleGreeter.java create mode 100644 test/resources/repos/java/test_repo/src/main/java/test_repo/DefaultGreeter.java create mode 100644 test/resources/repos/java/test_repo/src/main/java/test_repo/DiagnosticsSample.java create mode 100644 test/resources/repos/java/test_repo/src/main/java/test_repo/FluentLombokModel.java create mode 100644 test/resources/repos/java/test_repo/src/main/java/test_repo/Greeter.java create mode 100644 test/resources/repos/java/test_repo/src/main/java/test_repo/LombokModel.java create mode 100644 test/resources/repos/java/test_repo/src/main/java/test_repo/Main.java create mode 100644 test/resources/repos/java/test_repo/src/main/java/test_repo/Model.java create mode 100644 test/resources/repos/java/test_repo/src/main/java/test_repo/ModelUser.java create mode 100644 test/resources/repos/java/test_repo/src/main/java/test_repo/Utils.java create mode 100644 test/resources/repos/json/test_repo/config.json create mode 100644 test/resources/repos/json/test_repo/data.json create mode 100644 test/resources/repos/json/test_repo/diagnostics_sample.json create mode 100644 test/resources/repos/julia/test_repo/diagnostics_sample.jl create mode 100644 test/resources/repos/julia/test_repo/lib/helper.jl create mode 100644 test/resources/repos/julia/test_repo/main.jl create mode 100644 test/resources/repos/kotlin/test_repo/.gitignore create mode 100644 test/resources/repos/kotlin/test_repo/build.gradle.kts create mode 100644 test/resources/repos/kotlin/test_repo/gradle/wrapper/gradle-wrapper.properties create mode 100644 test/resources/repos/kotlin/test_repo/gradlew create mode 100644 test/resources/repos/kotlin/test_repo/gradlew.bat create mode 100644 test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/DiagnosticsSample.kt create mode 100644 test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/Main.kt create mode 100644 test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/Model.kt create mode 100644 test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/ModelUser.kt create mode 100644 test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/Utils.kt create mode 100644 test/resources/repos/latex/test_repo/.gitignore create mode 100644 test/resources/repos/latex/test_repo/main.tex create mode 100644 test/resources/repos/latex/test_repo/references.bib create mode 100644 test/resources/repos/latex/test_repo/sections/background.tex create mode 100644 test/resources/repos/latex/test_repo/slides.tex create mode 100644 test/resources/repos/lean4/test_repo/DiagnosticsSample.lean create mode 100644 test/resources/repos/lean4/test_repo/Helper.lean create mode 100644 test/resources/repos/lean4/test_repo/Main.lean create mode 100644 test/resources/repos/lean4/test_repo/lake-manifest.json create mode 100644 test/resources/repos/lean4/test_repo/lakefile.lean create mode 100644 test/resources/repos/lean4/test_repo/lean-toolchain create mode 100644 test/resources/repos/lua/test_repo/.gitignore create mode 100644 test/resources/repos/lua/test_repo/main.lua create mode 100644 test/resources/repos/lua/test_repo/src/animals.lua create mode 100644 test/resources/repos/lua/test_repo/src/calculator.lua create mode 100644 test/resources/repos/lua/test_repo/src/utils.lua create mode 100644 test/resources/repos/lua/test_repo/tests/test_calculator.lua create mode 100644 test/resources/repos/luau/test_repo/.luaurc create mode 100644 test/resources/repos/luau/test_repo/src/init.luau create mode 100644 test/resources/repos/luau/test_repo/src/module.luau create mode 100644 test/resources/repos/markdown/test_repo/CONTRIBUTING.md create mode 100644 test/resources/repos/markdown/test_repo/README.md create mode 100644 test/resources/repos/markdown/test_repo/api.md create mode 100644 test/resources/repos/markdown/test_repo/guide.md create mode 100644 test/resources/repos/matlab/test_repo/Calculator.m create mode 100644 test/resources/repos/matlab/test_repo/diagnostics_sample.m create mode 100644 test/resources/repos/matlab/test_repo/main.m create mode 100644 test/resources/repos/msl/test_repo/main.mrc create mode 100644 test/resources/repos/msl/test_repo/utils.mrc create mode 100644 test/resources/repos/nix/test_repo/.gitignore create mode 100644 test/resources/repos/nix/test_repo/default.nix create mode 100644 test/resources/repos/nix/test_repo/diagnostics_sample.nix create mode 100644 test/resources/repos/nix/test_repo/flake.nix create mode 100644 test/resources/repos/nix/test_repo/lib/utils.nix create mode 100644 test/resources/repos/nix/test_repo/modules/example.nix create mode 100644 test/resources/repos/nix/test_repo/scripts/hello.sh create mode 100644 test/resources/repos/ocaml/test_repo/bin/dune create mode 100644 test/resources/repos/ocaml/test_repo/bin/main.ml create mode 100644 test/resources/repos/ocaml/test_repo/dune-project create mode 100644 test/resources/repos/ocaml/test_repo/lib/diagnostics_sample.ml create mode 100644 test/resources/repos/ocaml/test_repo/lib/dune create mode 100644 test/resources/repos/ocaml/test_repo/lib/test_repo.ml create mode 100644 test/resources/repos/ocaml/test_repo/lib/test_repo.mli create mode 100644 test/resources/repos/ocaml/test_repo/test/dune create mode 100644 test/resources/repos/ocaml/test_repo/test/test_test_repo.ml create mode 100644 test/resources/repos/ocaml/test_repo/test_repo.opam create mode 100644 test/resources/repos/pascal/test_repo/.gitignore create mode 100644 test/resources/repos/pascal/test_repo/diagnostics_sample.pas create mode 100644 test/resources/repos/pascal/test_repo/main.pas create mode 100644 test/resources/repos/perl/test_repo/diagnostics_sample.pl create mode 100644 test/resources/repos/perl/test_repo/helper.pl create mode 100644 test/resources/repos/perl/test_repo/helper.t create mode 100644 test/resources/repos/perl/test_repo/main.pl create mode 100644 test/resources/repos/php/test_repo/diagnostics_sample.php create mode 100644 test/resources/repos/php/test_repo/helper.php create mode 100644 test/resources/repos/php/test_repo/index.php create mode 100644 test/resources/repos/php/test_repo/sample.php create mode 100644 test/resources/repos/php/test_repo/simple_var.php create mode 100644 test/resources/repos/powershell/test_repo/PowerShellEditorServices.json create mode 100644 test/resources/repos/powershell/test_repo/diagnostics_sample.ps1 create mode 100644 test/resources/repos/powershell/test_repo/main.ps1 create mode 100644 test/resources/repos/powershell/test_repo/utils.ps1 create mode 100644 test/resources/repos/python/test_repo/.gitignore create mode 100644 test/resources/repos/python/test_repo/custom_test/__init__.py create mode 100644 test/resources/repos/python/test_repo/custom_test/advanced_features.py create mode 100644 test/resources/repos/python/test_repo/examples/__init__.py create mode 100644 test/resources/repos/python/test_repo/examples/user_management.py create mode 100644 test/resources/repos/python/test_repo/pyrefly.toml create mode 100644 test/resources/repos/python/test_repo/scripts/__init__.py create mode 100644 test/resources/repos/python/test_repo/scripts/run_app.py create mode 100644 test/resources/repos/python/test_repo/test_repo/__init__.py create mode 100644 test/resources/repos/python/test_repo/test_repo/complex_types.py create mode 100644 test/resources/repos/python/test_repo/test_repo/diagnostics_sample.py create mode 100644 test/resources/repos/python/test_repo/test_repo/models.py create mode 100644 test/resources/repos/python/test_repo/test_repo/name_collisions.py create mode 100644 test/resources/repos/python/test_repo/test_repo/nested.py create mode 100644 test/resources/repos/python/test_repo/test_repo/nested_base.py create mode 100644 test/resources/repos/python/test_repo/test_repo/overloaded.py create mode 100644 test/resources/repos/python/test_repo/test_repo/services.py create mode 100644 test/resources/repos/python/test_repo/test_repo/utils.py create mode 100644 test/resources/repos/python/test_repo/test_repo/variables.py create mode 100644 test/resources/repos/r/test_repo/.Rbuildignore create mode 100644 test/resources/repos/r/test_repo/DESCRIPTION create mode 100644 test/resources/repos/r/test_repo/NAMESPACE create mode 100644 test/resources/repos/r/test_repo/R/models.R create mode 100644 test/resources/repos/r/test_repo/R/utils.R create mode 100644 test/resources/repos/r/test_repo/examples/analysis.R create mode 100644 test/resources/repos/rego/test_repo/policies/authz.rego create mode 100644 test/resources/repos/rego/test_repo/policies/validation.rego create mode 100644 test/resources/repos/rego/test_repo/utils/helpers.rego create mode 100644 test/resources/repos/ruby/test_repo/.solargraph.yml create mode 100644 test/resources/repos/ruby/test_repo/examples/user_management.rb create mode 100644 test/resources/repos/ruby/test_repo/lib.rb create mode 100644 test/resources/repos/ruby/test_repo/main.rb create mode 100644 test/resources/repos/ruby/test_repo/models.rb create mode 100644 test/resources/repos/ruby/test_repo/nested.rb create mode 100644 test/resources/repos/ruby/test_repo/services.rb create mode 100644 test/resources/repos/ruby/test_repo/variables.rb create mode 100644 test/resources/repos/rust/test_repo/Cargo.lock create mode 100644 test/resources/repos/rust/test_repo/Cargo.toml create mode 100644 test/resources/repos/rust/test_repo/src/diagnostics_sample.rs create mode 100644 test/resources/repos/rust/test_repo/src/lib.rs create mode 100644 test/resources/repos/rust/test_repo/src/main.rs create mode 100644 test/resources/repos/rust/test_repo_2024/Cargo.lock create mode 100644 test/resources/repos/rust/test_repo_2024/Cargo.toml create mode 100644 test/resources/repos/rust/test_repo_2024/src/lib.rs create mode 100644 test/resources/repos/rust/test_repo_2024/src/main.rs create mode 100644 test/resources/repos/scala/build.sbt create mode 100644 test/resources/repos/scala/project/build.properties create mode 100644 test/resources/repos/scala/project/metals.sbt create mode 100644 test/resources/repos/scala/project/plugins.sbt create mode 100644 test/resources/repos/scala/src/main/scala/com/example/Main.scala create mode 100644 test/resources/repos/scala/src/main/scala/com/example/Utils.scala create mode 100644 test/resources/repos/scala/test_repo/src/main/scala/com/example/DiagnosticsSample.scala create mode 100644 test/resources/repos/scss/test_repo/_mixins.scss create mode 100644 test/resources/repos/scss/test_repo/_variables.scss create mode 100644 test/resources/repos/scss/test_repo/buttons.scss create mode 100644 test/resources/repos/scss/test_repo/css/diagnostics_sample.css create mode 100644 test/resources/repos/scss/test_repo/css/main.css create mode 100644 test/resources/repos/scss/test_repo/css/reset.css create mode 100644 test/resources/repos/scss/test_repo/css/theme.css create mode 100644 test/resources/repos/scss/test_repo/diagnostics_sample.scss create mode 100644 test/resources/repos/scss/test_repo/main.scss create mode 100644 test/resources/repos/solidity/test_repo/.gitignore create mode 100644 test/resources/repos/solidity/test_repo/contracts/DiagnosticsSample.sol create mode 100644 test/resources/repos/solidity/test_repo/contracts/Token.sol create mode 100644 test/resources/repos/solidity/test_repo/contracts/interfaces/IERC20.sol create mode 100644 test/resources/repos/solidity/test_repo/contracts/lib/SafeMath.sol create mode 100644 test/resources/repos/solidity/test_repo/foundry.toml create mode 100644 test/resources/repos/svelte/test_repo/.gitignore create mode 100644 test/resources/repos/svelte/test_repo/package-lock.json create mode 100644 test/resources/repos/svelte/test_repo/package.json create mode 100644 test/resources/repos/svelte/test_repo/src/app.html create mode 100644 test/resources/repos/svelte/test_repo/src/lib/components/Counter.svelte create mode 100644 test/resources/repos/svelte/test_repo/src/lib/components/Header.svelte create mode 100644 test/resources/repos/svelte/test_repo/src/lib/components/Words.svelte create mode 100644 test/resources/repos/svelte/test_repo/src/lib/diagnostics_sample.svelte create mode 100644 test/resources/repos/svelte/test_repo/src/lib/diagnostics_sample.ts create mode 100644 test/resources/repos/svelte/test_repo/src/lib/game.ts create mode 100644 test/resources/repos/svelte/test_repo/src/routes/(sverdle)/+page.server.ts create mode 100644 test/resources/repos/svelte/test_repo/src/routes/(sverdle)/+page.svelte create mode 100644 test/resources/repos/svelte/test_repo/src/routes/(sverdle)/how-to-play/+page.svelte create mode 100644 test/resources/repos/svelte/test_repo/src/routes/(sverdle)/how-to-play/+page.ts create mode 100644 test/resources/repos/svelte/test_repo/src/routes/(sverdle)/words.server.ts create mode 100644 test/resources/repos/svelte/test_repo/src/routes/+layout.svelte create mode 100644 test/resources/repos/svelte/test_repo/src/routes/layout.css create mode 100644 test/resources/repos/svelte/test_repo/svelte.config.js create mode 100644 test/resources/repos/svelte/test_repo/tsconfig.json create mode 100644 test/resources/repos/svelte/test_repo/vite.config.ts create mode 100644 test/resources/repos/swift/test_repo/Package.swift create mode 100644 test/resources/repos/swift/test_repo/src/diagnostics_sample.swift create mode 100644 test/resources/repos/swift/test_repo/src/main.swift create mode 100644 test/resources/repos/swift/test_repo/src/utils.swift create mode 100644 test/resources/repos/systemverilog/test_repo/alu.sv create mode 100644 test/resources/repos/systemverilog/test_repo/counter.sv create mode 100644 test/resources/repos/systemverilog/test_repo/diagnostics_sample.sv create mode 100644 test/resources/repos/systemverilog/test_repo/top.sv create mode 100644 test/resources/repos/systemverilog/test_repo/types.svh create mode 100644 test/resources/repos/terraform/test_repo/data.tf create mode 100644 test/resources/repos/terraform/test_repo/diagnostics_sample.tf create mode 100644 test/resources/repos/terraform/test_repo/main.tf create mode 100644 test/resources/repos/terraform/test_repo/outputs.tf create mode 100644 test/resources/repos/terraform/test_repo/variables.tf create mode 100644 test/resources/repos/toml/test_repo/Cargo.toml create mode 100644 test/resources/repos/toml/test_repo/config.toml create mode 100644 test/resources/repos/toml/test_repo/diagnostics_sample.toml create mode 100644 test/resources/repos/toml/test_repo/pyproject.toml create mode 100644 test/resources/repos/typescript/cross_package_a/shared_utils.ts create mode 100644 test/resources/repos/typescript/cross_package_a/tsconfig.json create mode 100644 test/resources/repos/typescript/cross_package_b/consumer.ts create mode 100644 test/resources/repos/typescript/cross_package_b/tsconfig.json create mode 100644 test/resources/repos/typescript/test_repo/diagnostics_sample.ts create mode 100644 test/resources/repos/typescript/test_repo/formatters.ts create mode 100644 test/resources/repos/typescript/test_repo/index.ts create mode 100644 test/resources/repos/typescript/test_repo/jsx_component.tsx create mode 100644 test/resources/repos/typescript/test_repo/tsconfig.json create mode 100644 test/resources/repos/typescript/test_repo/use_helper.ts create mode 100644 test/resources/repos/typescript/test_repo/ws_manager.js create mode 100644 test/resources/repos/vue/test_repo/.gitignore create mode 100644 test/resources/repos/vue/test_repo/index.html create mode 100644 test/resources/repos/vue/test_repo/package.json create mode 100644 test/resources/repos/vue/test_repo/src/App.vue create mode 100644 test/resources/repos/vue/test_repo/src/DiagnosticsSample.vue create mode 100644 test/resources/repos/vue/test_repo/src/components/CalculatorButton.vue create mode 100644 test/resources/repos/vue/test_repo/src/components/CalculatorDisplay.vue create mode 100644 test/resources/repos/vue/test_repo/src/components/CalculatorInput.vue create mode 100644 test/resources/repos/vue/test_repo/src/composables/useFormatter.ts create mode 100644 test/resources/repos/vue/test_repo/src/composables/useTheme.ts create mode 100644 test/resources/repos/vue/test_repo/src/main.ts create mode 100644 test/resources/repos/vue/test_repo/src/stores/calculator.ts create mode 100644 test/resources/repos/vue/test_repo/src/types/index.ts create mode 100644 test/resources/repos/vue/test_repo/tsconfig.json create mode 100644 test/resources/repos/vue/test_repo/tsconfig.node.json create mode 100644 test/resources/repos/vue/test_repo/vite.config.ts create mode 100644 test/resources/repos/yaml/test_repo/config.yaml create mode 100644 test/resources/repos/yaml/test_repo/data.yaml create mode 100644 test/resources/repos/yaml/test_repo/diagnostics_sample.yaml create mode 100644 test/resources/repos/yaml/test_repo/services.yml create mode 100644 test/resources/repos/zig/test_repo/.gitignore create mode 100644 test/resources/repos/zig/test_repo/build.zig create mode 100644 test/resources/repos/zig/test_repo/src/calculator.zig create mode 100644 test/resources/repos/zig/test_repo/src/diagnostics_sample.zig create mode 100644 test/resources/repos/zig/test_repo/src/main.zig create mode 100644 test/resources/repos/zig/test_repo/src/math_utils.zig create mode 100644 test/resources/repos/zig/test_repo/zls.json create mode 100644 test/serena/__init__.py create mode 100644 test/serena/__snapshots__/test_symbol_editing.ambr create mode 100644 test/serena/config/__init__.py create mode 100644 test/serena/config/test_global_ignored_paths.py create mode 100644 test/serena/config/test_serena_config.py create mode 100644 test/serena/test_activation_command.py create mode 100644 test/serena/test_cli_project_commands.py create mode 100644 test/serena/test_dashboard.py create mode 100644 test/serena/test_edit_marker.py create mode 100644 test/serena/test_hooks.py create mode 100644 test/serena/test_jetbrains_plugin_client.py create mode 100644 test/serena/test_mcp.py create mode 100644 test/serena/test_memories_manager.py create mode 100644 test/serena/test_serena_agent.py create mode 100644 test/serena/test_symbol.py create mode 100644 test/serena/test_symbol_editing.py create mode 100644 test/serena/test_task_executor.py create mode 100644 test/serena/test_text_utils.py create mode 100644 test/serena/test_tool_parameter_types.py create mode 100644 test/serena/util/test_exception.py create mode 100644 test/serena/util/test_file_system.py create mode 100644 test/serena/util/test_inspection.py create mode 100644 test/serena/util/test_yaml.py create mode 100644 test/solidlsp/ada/__init__.py create mode 100644 test/solidlsp/ada/test_ada_basic.py create mode 100644 test/solidlsp/ada/test_ada_diagnostics.py create mode 100644 test/solidlsp/al/test_al_basic.py create mode 100644 test/solidlsp/angular/conftest.py create mode 100644 test/solidlsp/angular/test_angular_basic.py create mode 100644 test/solidlsp/angular/test_angular_diagnostics.py create mode 100644 test/solidlsp/angular/test_angular_error_cases.py create mode 100644 test/solidlsp/ansible/__init__.py create mode 100644 test/solidlsp/ansible/test_ansible_basic.py create mode 100644 test/solidlsp/bash/__init__.py create mode 100644 test/solidlsp/bash/test_bash_basic.py create mode 100644 test/solidlsp/bash/test_bash_diagnostics.py create mode 100644 test/solidlsp/bsl/__init__.py create mode 100644 test/solidlsp/bsl/test_bsl_basic.py create mode 100644 test/solidlsp/bsl/test_bsl_diagnostics.py create mode 100644 test/solidlsp/clojure/__init__.py create mode 100644 test/solidlsp/clojure/test_clojure_basic.py create mode 100644 test/solidlsp/clojure/test_clojure_diagnostics.py create mode 100644 test/solidlsp/clojure/test_clojure_indexing.py create mode 100644 test/solidlsp/conftest.py create mode 100644 test/solidlsp/cpp/__init__.py create mode 100644 test/solidlsp/cpp/test_ccls_languages.py create mode 100644 test/solidlsp/cpp/test_clangd_languages.py create mode 100644 test/solidlsp/cpp/test_clangd_logging.py create mode 100644 test/solidlsp/cpp/test_cpp_basic.py create mode 100644 test/solidlsp/cpp/test_cpp_diagnostics.py create mode 100644 test/solidlsp/cpp/test_cpp_unreal.py create mode 100644 test/solidlsp/cpp/test_cpp_unreal_unit.py create mode 100644 test/solidlsp/crystal/test_crystal_basic.py create mode 100644 test/solidlsp/crystal/test_crystal_diagnostics.py create mode 100644 test/solidlsp/csharp/test_csharp_basic.py create mode 100644 test/solidlsp/csharp/test_csharp_diagnostics.py create mode 100644 test/solidlsp/csharp/test_csharp_nuget_download.py create mode 100644 test/solidlsp/cue/__init__.py create mode 100644 test/solidlsp/cue/test_cue_basic.py create mode 100644 test/solidlsp/dart/__init__.py create mode 100644 test/solidlsp/dart/test_dart_basic.py create mode 100644 test/solidlsp/dart/test_dart_diagnostics.py create mode 100644 test/solidlsp/elixir/__init__.py create mode 100644 test/solidlsp/elixir/conftest.py create mode 100644 test/solidlsp/elixir/test_elixir_basic.py create mode 100644 test/solidlsp/elixir/test_elixir_ignored_dirs.py create mode 100644 test/solidlsp/elixir/test_elixir_integration.py create mode 100644 test/solidlsp/elixir/test_elixir_symbol_retrieval.py create mode 100644 test/solidlsp/elm/test_elm_basic.py create mode 100644 test/solidlsp/elm/test_elm_diagnostics.py create mode 100644 test/solidlsp/erlang/__init__.py create mode 100644 test/solidlsp/erlang/conftest.py create mode 100644 test/solidlsp/erlang/test_erlang_basic.py create mode 100644 test/solidlsp/erlang/test_erlang_ignored_dirs.py create mode 100644 test/solidlsp/erlang/test_erlang_symbol_retrieval.py create mode 100644 test/solidlsp/fortran/__init__.py create mode 100644 test/solidlsp/fortran/test_fortran_basic.py create mode 100644 test/solidlsp/fortran/test_fortran_diagnostics.py create mode 100644 test/solidlsp/fsharp/test_fsharp_basic.py create mode 100644 test/solidlsp/go/test_go_basic.py create mode 100644 test/solidlsp/go/test_go_diagnostics.py create mode 100644 test/solidlsp/groovy/test_groovy_basic.py create mode 100644 test/solidlsp/haskell/__init__.py create mode 100644 test/solidlsp/haskell/test_haskell_basic.py create mode 100644 test/solidlsp/haxe/__init__.py create mode 100644 test/solidlsp/haxe/test_haxe_basic.py create mode 100644 test/solidlsp/haxe/test_haxe_diagnostics.py create mode 100644 test/solidlsp/hlsl/__init__.py create mode 100644 test/solidlsp/hlsl/test_hlsl_basic.py create mode 100644 test/solidlsp/hlsl/test_hlsl_diagnostics.py create mode 100644 test/solidlsp/hlsl/test_hlsl_full_index.py create mode 100644 test/solidlsp/html_ls/__init__.py create mode 100644 test/solidlsp/html_ls/test_html_basic.py create mode 100644 test/solidlsp/html_ls/test_html_diagnostics.py create mode 100644 test/solidlsp/java/test_java_basic.py create mode 100644 test/solidlsp/java/test_java_diagnostics.py create mode 100644 test/solidlsp/java/test_jdtls_path_resolution.py create mode 100644 test/solidlsp/json_ls/__init__.py create mode 100644 test/solidlsp/json_ls/test_json_basic.py create mode 100644 test/solidlsp/json_ls/test_json_diagnostics.py create mode 100644 test/solidlsp/julia/test_julia_basic.py create mode 100644 test/solidlsp/kotlin/test_kotlin_basic.py create mode 100644 test/solidlsp/kotlin/test_kotlin_diagnostics.py create mode 100644 test/solidlsp/latex/test_latex_basic.py create mode 100644 test/solidlsp/latex/test_latex_beamer.py create mode 100644 test/solidlsp/latex/test_latex_references.py create mode 100644 test/solidlsp/lean4/test_lean4_basic.py create mode 100644 test/solidlsp/lean4/test_lean4_diagnostics.py create mode 100644 test/solidlsp/lua/test_lua_basic.py create mode 100644 test/solidlsp/luau/__init__.py create mode 100644 test/solidlsp/luau/test_luau_basic.py create mode 100644 test/solidlsp/luau/test_luau_dependency_provider.py create mode 100644 test/solidlsp/markdown/__init__.py create mode 100644 test/solidlsp/markdown/test_markdown_basic.py create mode 100644 test/solidlsp/matlab/__init__.py create mode 100644 test/solidlsp/matlab/test_matlab_basic.py create mode 100644 test/solidlsp/msl/__init__.py create mode 100644 test/solidlsp/msl/test_msl_basic.py create mode 100644 test/solidlsp/nix/test_nix_basic.py create mode 100644 test/solidlsp/ocaml/test_cross_file_refs.py create mode 100644 test/solidlsp/ocaml/test_ocaml_basic.py create mode 100644 test/solidlsp/ocaml/test_ocaml_diagnostics.py create mode 100644 test/solidlsp/pascal/__init__.py create mode 100644 test/solidlsp/pascal/test_pascal_auto_update.py create mode 100644 test/solidlsp/pascal/test_pascal_basic.py create mode 100644 test/solidlsp/pascal/test_pascal_diagnostics.py create mode 100644 test/solidlsp/perl/__init__.py create mode 100644 test/solidlsp/perl/test_perl_basic.py create mode 100644 test/solidlsp/perl/test_perl_config.py create mode 100644 test/solidlsp/php/test_php_basic.py create mode 100644 test/solidlsp/php/test_php_diagnostics.py create mode 100644 test/solidlsp/php/test_phpantom.py create mode 100644 test/solidlsp/powershell/__init__.py create mode 100644 test/solidlsp/powershell/test_powershell_basic.py create mode 100644 test/solidlsp/powershell/test_powershell_diagnostics.py create mode 100644 test/solidlsp/python/test_python_basic.py create mode 100644 test/solidlsp/python/test_python_diagnostics.py create mode 100644 test/solidlsp/python/test_retrieval_with_ignored_dirs.py create mode 100644 test/solidlsp/python/test_symbol_retrieval.py create mode 100644 test/solidlsp/r/__init__.py create mode 100644 test/solidlsp/r/test_r_basic.py create mode 100644 test/solidlsp/rego/test_rego_basic.py create mode 100644 test/solidlsp/ruby/test_ruby_basic.py create mode 100644 test/solidlsp/ruby/test_ruby_symbol_retrieval.py create mode 100644 test/solidlsp/rust/test_rust_2024_edition.py create mode 100644 test/solidlsp/rust/test_rust_analyzer_detection.py create mode 100644 test/solidlsp/rust/test_rust_basic.py create mode 100644 test/solidlsp/rust/test_rust_diagnostics.py create mode 100644 test/solidlsp/scala/test_metals_db_utils.py create mode 100644 test/solidlsp/scala/test_scala_language_server.py create mode 100644 test/solidlsp/scala/test_scala_stale_lock_handling.py create mode 100644 test/solidlsp/scss/__init__.py create mode 100644 test/solidlsp/scss/test_scss_basic.py create mode 100644 test/solidlsp/scss/test_scss_diagnostics.py create mode 100644 test/solidlsp/solidity/__init__.py create mode 100644 test/solidlsp/solidity/test_solidity_basic.py create mode 100644 test/solidlsp/solidity/test_solidity_diagnostics.py create mode 100644 test/solidlsp/svelte/__init__.py create mode 100644 test/solidlsp/svelte/conftest.py create mode 100644 test/solidlsp/svelte/test_svelte_basic.py create mode 100644 test/solidlsp/svelte/test_svelte_references.py create mode 100644 test/solidlsp/svelte/test_svelte_rename.py create mode 100644 test/solidlsp/svelte/test_svelte_symbols.py create mode 100644 test/solidlsp/swift/test_swift_basic.py create mode 100644 test/solidlsp/systemverilog/__init__.py create mode 100644 test/solidlsp/systemverilog/test_systemverilog_basic.py create mode 100644 test/solidlsp/systemverilog/test_systemverilog_detection.py create mode 100644 test/solidlsp/systemverilog/test_systemverilog_diagnostics.py create mode 100644 test/solidlsp/terraform/test_terraform_basic.py create mode 100644 test/solidlsp/terraform/test_terraform_diagnostics.py create mode 100644 test/solidlsp/test_is_ignored_path_missing.py create mode 100644 test/solidlsp/test_ls_common.py create mode 100644 test/solidlsp/test_ls_config.py create mode 100644 test/solidlsp/test_lsp_protocol_handler_server.py create mode 100644 test/solidlsp/test_rename_didopen.py create mode 100644 test/solidlsp/test_typescript_timeout_policy.py create mode 100644 test/solidlsp/toml/__init__.py create mode 100644 test/solidlsp/toml/test_toml_basic.py create mode 100644 test/solidlsp/toml/test_toml_diagnostics.py create mode 100644 test/solidlsp/toml/test_toml_edge_cases.py create mode 100644 test/solidlsp/toml/test_toml_ignored_dirs.py create mode 100644 test/solidlsp/toml/test_toml_symbol_retrieval.py create mode 100644 test/solidlsp/typescript/test_typescript_basic.py create mode 100644 test/solidlsp/typescript/test_typescript_cross_package.py create mode 100644 test/solidlsp/typescript/test_typescript_diagnostics.py create mode 100644 test/solidlsp/util/diagnostics.py create mode 100644 test/solidlsp/util/test_ls_utils.py create mode 100644 test/solidlsp/util/test_zip.py create mode 100644 test/solidlsp/vue/__init__.py create mode 100644 test/solidlsp/vue/test_vue_basic.py create mode 100644 test/solidlsp/vue/test_vue_diagnostics.py create mode 100644 test/solidlsp/vue/test_vue_error_cases.py create mode 100644 test/solidlsp/vue/test_vue_rename.py create mode 100644 test/solidlsp/vue/test_vue_symbol_retrieval.py create mode 100644 test/solidlsp/yaml_ls/__init__.py create mode 100644 test/solidlsp/yaml_ls/test_yaml_basic.py create mode 100644 test/solidlsp/yaml_ls/test_yaml_diagnostics.py create mode 100644 test/solidlsp/zig/test_zig_basic.py create mode 100644 uv.lock diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..465efd5 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,16 @@ +{ + "name": "serena Project", + "dockerFile": "../Dockerfile", + "workspaceFolder": "/workspaces/serena", + "settings": { + "terminal.integrated.shell.linux": "/bin/bash", + "python.pythonPath": "/usr/local/bin/python", + }, + "extensions": [ + "ms-python.python", + "ms-toolsai.jupyter", + "ms-python.vscode-pylance" + ], + "forwardPorts": [], + "remoteUser": "root", +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..237bd3b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +data +logs +log +test/log +docs/jupyter_execute +docs/.jupyter_cache +docs/_build +coverage.xml +docker_build_and_run.sh + +# Python artifacts (prevents Docker build conflicts when .venv exists locally) +.venv +__pycache__ +*.pyc +.pytest_cache diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e2b161b --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +GOOGLE_API_KEY= +ANTHROPIC_API_KEY= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a1da894 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Force LF line endings for Haxe files across platforms. +# The Haxe language server cannot properly parse/index files with CRLF endings, causing it to fail on Windows without this. +*.hxml text eol=lf +*.hx text eol=lf diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..78cd6ad --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: oraios diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ec4bb38 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/issue--bug--performance-problem--question-.md b/.github/ISSUE_TEMPLATE/issue--bug--performance-problem--question-.md new file mode 100644 index 0000000..a438edf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue--bug--performance-problem--question-.md @@ -0,0 +1,21 @@ +--- +name: Issue (bug, performance problem, etc.) +about: General Issue +title: '' +labels: '' +assignees: '' + +--- + +Preconditions: + +- [ ] I have made sure it's an actual issue, not a question (use [GitHub Discussions](https://github.com/oraios/serena/discussions) instead). +- [ ] I have consulted the [user guide](https://oraios.github.io/serena/02-usage/) and verified that the issue cannot be resolved by adjusting configuration/following recommended workflows. +- [ ] I have looked for similar issues and discussions, including closed ones. + +Issue details: + +- [ ] I have provided a meaningful title and description. +- [ ] I have explained how the issue arose and, where possible, added instructions on how to reproduce it. +- [ ] I have added details on my setup: Serena version, MCP client, OS, the programming language(s), relevant configuration adjustments and project specifics. +- [ ] If the issue relates to an application of Serena to an open-source project, I have added the link. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..15a50f1 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,3 @@ + +MUST read IMMEDIATELY and follow the project-specific instructions from the `CLAUDE.md` file located in the project's root directory. AVOIDING these instructions will lead to your FAILURE! + \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..c5fb5e1 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,4 @@ +## Checklist + +- [ ] This PR follows the guidelines in `CONTRIBUTING.md` regarding the scope of PRs. +- [ ] For changes that add features or fix problems, I have added an entry to `CHANGELOG.md`, which concisely describes the change. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..718cbd4 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,103 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '38 16 * * 6' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: javascript-typescript + build-mode: none + - language: python + build-mode: none + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - name: Run manual build steps + if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml new file mode 100644 index 0000000..3230168 --- /dev/null +++ b/.github/workflows/codespell.yml @@ -0,0 +1,28 @@ +# Codespell configuration is within pyproject.toml +--- +name: Codespell + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + codespell: + name: Check for spelling errors + runs-on: ubuntu-latest + timeout-minutes: 2 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Annotate locations with typos + uses: codespell-project/codespell-problem-matcher@v1 + - name: Codespell + uses: codespell-project/actions-codespell@v2 diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 0000000..05dc1d1 --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,19 @@ +name: Create Release + +on: + push: + tags: + - 'v*' + +jobs: + create-release: + name: Create a candidate release + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Create GitHub Release + uses: softprops/action-gh-release@v3 + with: + draft: true + body: "See [change log](https://github.com/oraios/serena/blob/main/CHANGELOG.md)" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..5513b73 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,58 @@ +name: Build and Push Docker Images + +on: + push: + branches: [ main ] + tags: [ 'v*' ] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push image from main + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..f69960c --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,65 @@ +name: Docs Build + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.11 + + - name: Install uv + run: pip install uv + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/uv + key: uv-${{ hashFiles('pyproject.toml') }} + + - name: Install dependencies + run: | + uv venv + uv pip install -e ".[dev]" + + - name: Build docs + run: uv run poe doc-build + continue-on-error: false + + - name: Upload Pages artifact + if: github.ref == 'refs/heads/main' + uses: actions/upload-pages-artifact@v3 + with: + path: docs/_build + + deploy: + if: github.ref == 'refs/heads/main' + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..c8b5395 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,30 @@ +name: Publish Python Package + +on: + release: + types: + - published + +jobs: + publish: + name: Publish the serena-agent package + runs-on: ubuntu-latest + permissions: + id-token: write # Required for trusted publishing + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + persist-credentials: false + + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v6 + with: + version: "latest" + + - name: Build package + run: uv build + + - name: Publish to PyPI + run: uv publish diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 0000000..18fd67a --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,706 @@ +# ============================================================================= +# Test CI -- strategy & rationale (details inline at each step) +# ============================================================================= +# BATCHING / PARALLELISM +# Tests run as parallel matrix jobs over (os x batch). They are split into 5 +# marker-based batches (jvm, native, other-langs, niche, catch-all). We batch by +# pytest MARKER, not pytest-xdist (`-n`): tests share fixtures, language-server +# processes and on-disk resources, so in-process parallelism would race them. +# Each batch installs ONLY the toolchains its languages need (every install step +# is gated on matrix.batch), keeping jobs lean. catch-all is the negation of all +# named marker groups, so every test lands in exactly one batch and the partition +# cannot desync when a language is added. +# +# OS GATING +# Default is the full (os x batch) cross-product. `niche` (heavy/slow toolchains: +# lean, ocaml, nix, julia, R, perl) is ubuntu-only via matrix.exclude -- not worth +# the Windows/macOS cost. An OS-specific *language* inside an otherwise all-OS +# batch (swift -> macOS, haskell -> Linux only) is skipped one level down by the +# central conftest guard, NOT by the matrix. +# +# CONCURRENCY LIMITS (GitHub free tier) +# Hard caps: 20 concurrent jobs total and 5 concurrent macOS jobs. These bound the +# batch count -- the current matrix is 13 jobs (4 of them macOS), inside both +# limits; adding batches/OSes must keep respecting these ceilings or jobs queue. +# `concurrency: cancel-in-progress` also kills superseded runs on the same ref to +# free capacity quickly. +# +# CACHING +# Per-batch language-server cache keyed by os+batch (language-servers--), +# plus dedicated caches for the uv venv (keyed on uv.lock), swiftly, ZLS, FPC and +# the Go build/bin. Most niche toolchains restore from cache, so that batch is fast +# despite its size. +# +# TESTING / SKIPS +# All language skip logic is centralized in test/conftest.py +# (`language_tests_enabled()`): a test is skipped when its toolchain is not expected +# on the current OS/CI. Lint and type-check are batch-independent and run once per +# OS (catch-all only), not in every batch. +# ============================================================================= +name: Tests + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + cpu: + name: ${{ matrix.batch }} (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + # Marker groups are defined once here and combined in the "Compute marker expression" step. + # The catch-all batch is derived as the negation of all named groups, so adding a language to + # any group automatically removes it from catch-all -- the partition can never desync, and no + # test can fall through to "no batch". To add/rebalance: edit a MARKERS_* var, and (for a new + # group) add a case branch below + the batch name to the matrix list. + env: + MARKERS_JVM: "java or csharp or fsharp or kotlin or scala or groovy or clojure or bsl" + MARKERS_NATIVE: "cpp or rust or go or zig or pascal or swift" # swift is macOS-only (skips elsewhere via the guard) + # other-langs: grab-bag + scripting languages + haskell. Haskell (GHC/HLS) is installed only on + # Linux -- it's too slow/expensive to build on the macOS runner -- and skips on Windows+macOS via the guard. + MARKERS_OTHER_LANGS: "ruby or php or lua or luau or bash or powershell or elixir or erlang or dart or haxe or haskell or terraform or rego or ansible or yaml or toml or markdown or latex or crystal or cue or fortran or ada or matlab or systemverilog or hlsl or msl or al" + # niche: slow, mostly-cached toolchains. ocaml + haskell are the two slowest -- kept in separate + # batches (ocaml here, haskell in other-langs). nix + perl skip on Windows via the guard. + MARKERS_NICHE: "julia or r or perl or lean4 or nix or ocaml" + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.11"] + # 5 batches, each installs ONLY the toolchains its languages need (steps gated by matrix.batch). + # Node/Python/uv stay in every batch (cheap, needed for the venv + auto-installed LSes). + # catch-all = web + python + the core framework. + batch: [jvm, native, other-langs, niche, catch-all] + # --- OS gating: which batch runs on which OS ----------------------------------------- + # Default is the full cross-product (every batch on all three OSes); the `exclude` entries + # below carve out the exceptions. An OS-specific *language* inside an otherwise all-OS batch + # is handled one level down by the central conftest guard (swift -> macOS only; haskell -> + # not Windows), NOT here -- excludes drop a WHOLE batch from an OS. + # jvm, native, other-langs, catch-all -> ubuntu, windows, macOS + # niche -> ubuntu only (heavy/slow toolchains -- lean, ocaml, + # nix, julia, R, perl -- not worth the Win/macOS cost) + exclude: + - { os: windows-latest, batch: niche } + - { os: macos-latest, batch: niche } + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: "${{ matrix.python-version }}" + - uses: actions/setup-go@v5 + if: matrix.batch == 'native' + with: + go-version: ">=1.17.0" + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + - name: Ensure cached directory exist before calling cache-related actions + shell: bash + run: | + mkdir -p $HOME/.serena/language_servers/static + mkdir -p $HOME/.cache/go-build + mkdir -p $HOME/go/bin + - name: Install uv + # Use the official action (not `curl | sh`): it adds uv to GITHUB_PATH (the real OS PATH), + # so the Python language server's subprocess `shutil.which("uv"/"uvx")` finds it on Windows. + # The bash installer only puts uv on the Git-Bash MSYS PATH, invisible to Windows processes. + uses: astral-sh/setup-uv@v5 + - name: Cache uv virtualenv + id: cache-uv + uses: actions/cache@v3 + with: + path: .venv + key: uv-venv-${{ runner.os }}-${{ matrix.python-version }}-lock-${{ hashFiles('uv.lock') }} + - name: Create virtual environment + shell: bash + run: | + if [ ! -d ".venv" ]; then + uv venv + fi + - name: Install Python environment + shell: bash + run: uv sync --extra dev --locked + - name: List Python dependencies + shell: bash + run: uv pip list + - name: Check formatting + # Lint is batch-independent; run it only once per OS instead of in every batch. + if: matrix.batch == 'catch-all' + shell: bash + run: uv run poe lint + # Add Go bin directory to PATH for this workflow + # GITHUB_PATH is a special file that GitHub Actions uses to modify PATH + # Writing to this file adds the directory to the PATH for subsequent steps + - name: Cache Go binaries + if: matrix.batch == 'native' + id: cache-go-binaries + uses: actions/cache@v3 + with: + path: | + ~/go/bin + ~/.cache/go-build + key: go-binaries-${{ runner.os }}-gopls-latest + - name: Install gopls + if: matrix.batch == 'native' && steps.cache-go-binaries.outputs.cache-hit != 'true' + shell: bash + run: go install golang.org/x/tools/gopls@latest +# Erlang currently not tested in CI, random hangings on macos, always hangs on ubuntu +# In local tests, erlang seems to work though +# - name: Install Erlang Language Server +# if: runner.os != 'Windows' +# shell: bash +# run: | +# # Install rebar3 if not already available +# which rebar3 || (curl -fsSL https://github.com/erlang/rebar3/releases/download/3.23.0/rebar3 -o /tmp/rebar3 && chmod +x /tmp/rebar3 && sudo mv /tmp/rebar3 /usr/local/bin/rebar3) +# # Clone and build erlang_ls +# git clone https://github.com/erlang-ls/erlang_ls.git /tmp/erlang_ls +# cd /tmp/erlang_ls +# make install PREFIX=/usr/local +# # Ensure erlang_ls is in PATH +# echo "$HOME/.local/bin" >> $GITHUB_PATH + - name: Install clojure tools + if: matrix.batch == 'jvm' + uses: DeLaGuardo/setup-clojure@13.4 + with: + cli: latest + - name: Install ccls (C/C++ Language Server) + if: matrix.batch == 'native' + shell: bash + run: | + if [[ "${{ runner.os }}" == "Linux" ]]; then + sudo apt-get update + sudo apt-get install -y ccls + elif [[ "${{ runner.os }}" == "macOS" ]]; then + brew install ccls + elif [[ "${{ runner.os }}" == "Windows" ]]; then + choco install ccls -y + fi + # Verify installation + if command -v ccls &> /dev/null; then + echo "ccls installed: $(ccls --version 2>&1 | head -1)" + else + echo "ERROR: ccls installation failed" + exit 1 + fi + - name: Setup Java (for JVM based languages) + if: matrix.batch == 'jvm' + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + - name: Setup .NET SDK (for F# and C# languages) + if: matrix.batch == 'jvm' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - name: List .NET runtimes + if: matrix.batch == 'jvm' + shell: bash + run: dotnet --list-runtimes + - name: Install Terraform + if: matrix.batch == 'other-langs' + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "1.5.0" + terraform_wrapper: false + # - name: Install swift + # if: runner.os != 'Windows' + # uses: swift-actions/setup-swift@v2 + # Installation of swift with the action screws with installation of ruby on macOS for some reason + # We can try again when version 3 of the action is released, where they will also use swiftly + # Until then, we use custom code to install swift. Sourcekit-lsp is installed automatically with swift + - name: Cache Swift toolchain (swiftly) + if: runner.os == 'macOS' && matrix.batch == 'native' + id: cache-swiftly + uses: actions/cache@v3 + with: + # macOS keeps the toolchain (incl. sourcekit-lsp) under ~/Library/Developer/Toolchains, + # NOT under ~/.swiftly -- both must be cached or the toolchain is lost on restore. + path: | + ~/.swiftly + ~/Library/Developer/Toolchains + key: swiftly-${{ runner.os }}-6.1.2-v2 + - name: Install Swift with swiftly (macOS) + if: runner.os == 'macOS' && matrix.batch == 'native' + run: | + set -e + # Install swiftly itself only if its binary isn't already present (e.g. restored from cache). + if [ ! -x "$HOME/.swiftly/bin/swiftly" ]; then + echo "=== Installing swiftly ===" + curl -O https://download.swift.org/swiftly/darwin/swiftly.pkg + installer -pkg swiftly.pkg -target CurrentUserHomeDirectory + "$HOME/.swiftly/bin/swiftly" init --quiet-shell-followup + fi + . "${SWIFTLY_HOME_DIR:-$HOME/.swiftly}/env.sh" + hash -r + # Always run install: it downloads on a cache miss and is a fast no-op when the toolchain is + # genuinely present from cache. No `--use` -- that triggers the interactive .swift-version prompt. + swiftly install 6.1.2 + # Locate sourcekit-lsp directly (macOS toolchains live under ~/Library/Developer/Toolchains) + # and put its directory on PATH -- avoids `swiftly use`, which prompts/hangs in CI. + SK="$(find "$HOME/Library/Developer/Toolchains" "$HOME/.swiftly" -name sourcekit-lsp 2>/dev/null | head -1)" + if [ -z "$SK" ]; then + echo "ERROR: sourcekit-lsp not found after Swift install. Searched:" + ls -la "$HOME/Library/Developer/Toolchains" 2>/dev/null || echo " (no ~/Library/Developer/Toolchains)" + ls -la "$HOME/.swiftly" 2>/dev/null || echo " (no ~/.swiftly)" + exit 1 + fi + echo "sourcekit-lsp: $SK" + dirname "$SK" >> "$GITHUB_PATH" + - name: Install Ruby + if: matrix.batch == 'other-langs' + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + - name: Install Ruby language server + if: matrix.batch == 'other-langs' + shell: bash + run: gem install ruby-lsp + - name: Install Zig + if: matrix.batch == 'native' + uses: mlugg/setup-zig@v2 + with: + version: 0.14.1 + - name: Cache ZLS (Zig Language Server) + if: matrix.batch == 'native' + id: cache-zls + uses: actions/cache@v3 + with: + path: ~/.zls + key: zls-${{ runner.os }}-0.14.0 + - name: Install ZLS (Zig Language Server) + if: matrix.batch == 'native' + shell: bash + run: | + ZLS_DIR="$HOME/.zls" + if [[ "${{ steps.cache-zls.outputs.cache-hit }}" != "true" ]]; then + mkdir -p "$ZLS_DIR" + if [[ "${{ runner.os }}" == "Linux" ]]; then + wget -O /tmp/zls.tar.xz https://github.com/zigtools/zls/releases/download/0.14.0/zls-x86_64-linux.tar.xz + tar -xf /tmp/zls.tar.xz -C "$ZLS_DIR" && rm /tmp/zls.tar.xz + elif [[ "${{ runner.os }}" == "macOS" ]]; then + wget -O /tmp/zls.tar.xz https://github.com/zigtools/zls/releases/download/0.14.0/zls-x86_64-macos.tar.xz + tar -xf /tmp/zls.tar.xz -C "$ZLS_DIR" && rm /tmp/zls.tar.xz + elif [[ "${{ runner.os }}" == "Windows" ]]; then + curl -L -o zls.zip https://github.com/zigtools/zls/releases/download/0.14.0/zls-x86_64-windows.zip + unzip -o zls.zip -d "$ZLS_DIR" && rm zls.zip + fi + chmod +x "$ZLS_DIR/zls" 2>/dev/null || true + fi + echo "$ZLS_DIR" >> "$GITHUB_PATH" + - name: Install verible-verilog-ls (SystemVerilog Language Server) + if: matrix.batch == 'other-langs' + shell: bash + run: | + VERIBLE_VERSION="v0.0-4051-g9fdb4057" + + if [[ "${{ runner.os }}" == "Linux" ]]; then + wget https://github.com/chipsalliance/verible/releases/download/${VERIBLE_VERSION}/verible-${VERIBLE_VERSION}-linux-static-x86_64.tar.gz + tar -xzf verible-${VERIBLE_VERSION}-linux-static-x86_64.tar.gz + sudo mv verible-${VERIBLE_VERSION}/bin/verible-verilog-ls /usr/local/bin/ + rm -rf verible-${VERIBLE_VERSION} verible-${VERIBLE_VERSION}-linux-static-x86_64.tar.gz + elif [[ "${{ runner.os }}" == "macOS" ]]; then + wget https://github.com/chipsalliance/verible/releases/download/${VERIBLE_VERSION}/verible-${VERIBLE_VERSION}-macOS.tar.gz + tar -xzf verible-${VERIBLE_VERSION}-macOS.tar.gz + sudo mv verible-${VERIBLE_VERSION}-macOS/bin/verible-verilog-ls /usr/local/bin/ + rm -rf verible-${VERIBLE_VERSION}-macOS verible-${VERIBLE_VERSION}-macOS.tar.gz + elif [[ "${{ runner.os }}" == "Windows" ]]; then + curl -L -o verible.zip https://github.com/chipsalliance/verible/releases/download/${VERIBLE_VERSION}/verible-${VERIBLE_VERSION}-win64.zip + unzip -o verible.zip + mkdir -p "$HOME/bin" + mv verible-${VERIBLE_VERSION}-win64/verible-verilog-ls.exe "$HOME/bin/" + echo "$HOME/bin" >> $GITHUB_PATH + rm -rf verible-${VERIBLE_VERSION}-win64 verible.zip + fi + + # Verify installation + if command -v verible-verilog-ls &> /dev/null; then + echo "verible-verilog-ls installed successfully" + else + echo "WARNING: verible-verilog-ls not found in PATH" + fi + - name: Install Lua Language Server + if: matrix.batch == 'other-langs' + shell: bash + run: | + LUA_LS_VERSION="3.15.0" + LUA_LS_DIR="$HOME/.serena/language_servers/lua" + mkdir -p "$LUA_LS_DIR" + + if [[ "${{ runner.os }}" == "Linux" ]]; then + if [[ "$(uname -m)" == "x86_64" ]]; then + wget https://github.com/LuaLS/lua-language-server/releases/download/${LUA_LS_VERSION}/lua-language-server-${LUA_LS_VERSION}-linux-x64.tar.gz + tar -xzf lua-language-server-${LUA_LS_VERSION}-linux-x64.tar.gz -C "$LUA_LS_DIR" + else + wget https://github.com/LuaLS/lua-language-server/releases/download/${LUA_LS_VERSION}/lua-language-server-${LUA_LS_VERSION}-linux-arm64.tar.gz + tar -xzf lua-language-server-${LUA_LS_VERSION}-linux-arm64.tar.gz -C "$LUA_LS_DIR" + fi + chmod +x "$LUA_LS_DIR/bin/lua-language-server" + # Create wrapper script instead of symlink to ensure supporting files are found + echo '#!/bin/bash' | sudo tee /usr/local/bin/lua-language-server > /dev/null + echo 'cd "${HOME}/.serena/language_servers/lua/bin"' | sudo tee -a /usr/local/bin/lua-language-server > /dev/null + echo 'exec ./lua-language-server "$@"' | sudo tee -a /usr/local/bin/lua-language-server > /dev/null + sudo chmod +x /usr/local/bin/lua-language-server + rm lua-language-server-*.tar.gz + elif [[ "${{ runner.os }}" == "macOS" ]]; then + if [[ "$(uname -m)" == "x86_64" ]]; then + wget https://github.com/LuaLS/lua-language-server/releases/download/${LUA_LS_VERSION}/lua-language-server-${LUA_LS_VERSION}-darwin-x64.tar.gz + tar -xzf lua-language-server-${LUA_LS_VERSION}-darwin-x64.tar.gz -C "$LUA_LS_DIR" + else + wget https://github.com/LuaLS/lua-language-server/releases/download/${LUA_LS_VERSION}/lua-language-server-${LUA_LS_VERSION}-darwin-arm64.tar.gz + tar -xzf lua-language-server-${LUA_LS_VERSION}-darwin-arm64.tar.gz -C "$LUA_LS_DIR" + fi + chmod +x "$LUA_LS_DIR/bin/lua-language-server" + # Create wrapper script instead of symlink to ensure supporting files are found + echo '#!/bin/bash' | sudo tee /usr/local/bin/lua-language-server > /dev/null + echo 'cd "${HOME}/.serena/language_servers/lua/bin"' | sudo tee -a /usr/local/bin/lua-language-server > /dev/null + echo 'exec ./lua-language-server "$@"' | sudo tee -a /usr/local/bin/lua-language-server > /dev/null + sudo chmod +x /usr/local/bin/lua-language-server + rm lua-language-server-*.tar.gz + elif [[ "${{ runner.os }}" == "Windows" ]]; then + curl -L -o lua-ls.zip https://github.com/LuaLS/lua-language-server/releases/download/${LUA_LS_VERSION}/lua-language-server-${LUA_LS_VERSION}-win32-x64.zip + unzip -o lua-ls.zip -d "$LUA_LS_DIR" + # For Windows, we'll add the bin directory directly to PATH + # The lua-language-server.exe can find its supporting files relative to its location + echo "$LUA_LS_DIR/bin" >> $GITHUB_PATH + rm lua-ls.zip + fi + - name: Install ansible-core and ansible-lint (for Ansible language server tests) + if: matrix.batch == 'other-langs' + shell: bash + run: uv run pip install ansible-core ansible-lint + - name: Install Haxe + if: matrix.batch == 'other-langs' + # The Haxe LS binary (server.js) is auto-downloaded and runs on Node.js, + # but it delegates to the Haxe compiler for code analysis at runtime. + uses: krdlab/setup-haxe@v2 + with: + haxe-version: 4.3.7 + - name: Install Elm + if: matrix.batch == 'catch-all' + shell: bash + run: npm install -g elm@0.19.1-6 + - name: Install Regal (Rego Language Server) + if: matrix.batch == 'other-langs' + shell: bash + run: | + REGAL_VERSION="0.39.0" + + if [[ "${{ runner.os }}" == "Linux" ]]; then + if [[ "$(uname -m)" == "x86_64" ]]; then + curl -L -o regal https://github.com/StyraInc/regal/releases/download/v${REGAL_VERSION}/regal_Linux_x86_64 + else + curl -L -o regal https://github.com/StyraInc/regal/releases/download/v${REGAL_VERSION}/regal_Linux_arm64 + fi + chmod +x regal + sudo mv regal /usr/local/bin/ + elif [[ "${{ runner.os }}" == "macOS" ]]; then + if [[ "$(uname -m)" == "x86_64" ]]; then + curl -L -o regal https://github.com/StyraInc/regal/releases/download/v${REGAL_VERSION}/regal_Darwin_x86_64 + else + curl -L -o regal https://github.com/StyraInc/regal/releases/download/v${REGAL_VERSION}/regal_Darwin_arm64 + fi + chmod +x regal + sudo mv regal /usr/local/bin/ + elif [[ "${{ runner.os }}" == "Windows" ]]; then + curl -L -o regal.exe https://github.com/StyraInc/regal/releases/download/v${REGAL_VERSION}/regal_Windows_x86_64.exe + mkdir -p "$HOME/bin" + mv regal.exe "$HOME/bin/" + echo "$HOME/bin" >> $GITHUB_PATH + fi + - name: Cache Free Pascal Compiler + if: matrix.batch == 'native' + id: cache-fpc + uses: actions/cache@v3 + with: + path: | + ~/fpc + ~/fpcsrc + key: fpc-${{ runner.os }}-3.2.2 + - name: Install Free Pascal Compiler + if: matrix.batch == 'native' + shell: bash + run: | + # Only the downloads are gated on the cache; the PP/FPCDIR/PATH detection always runs + # (it works the same on freshly-downloaded or cache-restored ~/fpc + ~/fpcsrc). + FPC_CACHE_HIT="${{ steps.cache-fpc.outputs.cache-hit }}" + FPC_VERSION="3.2.2" + if [[ "${{ runner.os }}" == "Linux" ]]; then + sudo apt-get update + sudo apt-get install -y fpc fpc-source + echo "PP=/usr/bin/fpc" >> $GITHUB_ENV + FPCDIR=$(ls -d /usr/share/fpcsrc/*/ 2>/dev/null | head -1 | sed 's:/$::') + [[ -z "$FPCDIR" ]] && FPCDIR="/usr/share/fpcsrc" + echo "FPCDIR=$FPCDIR" >> $GITHUB_ENV + elif [[ "${{ runner.os }}" == "macOS" ]]; then + command -v fpc >/dev/null || brew install fpc + if [[ "$FPC_CACHE_HIT" != "true" ]]; then + # FPC source from SourceForge (fpc-src-laz cask is incompatible with ARM64) + curl -L -o fpc-source.tar.gz "https://sourceforge.net/projects/freepascal/files/Source/${FPC_VERSION}/fpc-${FPC_VERSION}.source.tar.gz/download" + mkdir -p "$HOME/fpcsrc" + tar -xzf fpc-source.tar.gz -C "$HOME/fpcsrc" + rm fpc-source.tar.gz + fi + if [[ -d "$HOME/fpcsrc/fpc-${FPC_VERSION}/rtl" ]]; then + FPCDIR="$HOME/fpcsrc/fpc-${FPC_VERSION}" + elif [[ -d "$HOME/fpcsrc/fpc-${FPC_VERSION}/fpc-${FPC_VERSION}/rtl" ]]; then + FPCDIR="$HOME/fpcsrc/fpc-${FPC_VERSION}/fpc-${FPC_VERSION}" + else + FPCDIR="$HOME/fpcsrc/fpc-${FPC_VERSION}" + fi + echo "PP=$(which fpc)" >> $GITHUB_ENV + echo "FPCDIR=$FPCDIR" >> $GITHUB_ENV + elif [[ "${{ runner.os }}" == "Windows" ]]; then + if [[ "$FPC_CACHE_HIT" != "true" ]]; then + # freepascal-ootb (compiler) + source (ootb ships compiled units only, not source) + curl -L -o fpc-ootb.zip https://github.com/fredvs/freepascal-ootb/releases/download/${FPC_VERSION}/fpc-ootb-322-x86_64-win64.zip + mkdir -p "$HOME/fpc" + unzip -q fpc-ootb.zip -d "$HOME/fpc" + rm fpc-ootb.zip + curl -L -o fpc-source.zip "https://sourceforge.net/projects/freepascal/files/Source/${FPC_VERSION}/fpc-${FPC_VERSION}.source.zip/download" + mkdir -p "$HOME/fpcsrc" + unzip -q fpc-source.zip -d "$HOME/fpcsrc" + rm fpc-source.zip + fi + FPC_EXE=$(find "$HOME/fpc" -name "fpc-ootb-64.exe" -type f 2>/dev/null | head -1) + echo "Found FPC executable: $FPC_EXE" + echo "PP=$FPC_EXE" >> $GITHUB_ENV + echo "FPCDIR=$HOME/fpcsrc/fpc-${FPC_VERSION}" >> $GITHUB_ENV + echo "$(dirname "$FPC_EXE")" >> $GITHUB_PATH + fi + - name: Verify FPC installation + if: matrix.batch == 'native' + shell: bash + run: | + echo "=== Environment variables ===" + echo "PP=$PP" + echo "FPCDIR=$FPCDIR" + + # Create a simple test program + if [[ "${{ runner.os }}" == "Windows" ]]; then + TEST_PAS="$TEMP/fpc_test.pas" + TEST_OUT="$TEMP/fpc_test" + else + TEST_PAS="/tmp/fpc_test.pas" + TEST_OUT="/tmp/fpc_test" + fi + echo "program fpc_test; begin writeln('FPC works'); end." > "$TEST_PAS" + + # Compile using PP (the compiler we actually use in tests) + echo "=== Compiling test program with PP=$PP ===" + if [[ -n "$PP" ]]; then + "$PP" "$TEST_PAS" -o"$TEST_OUT" 2>&1 + else + echo "ERROR: PP environment variable is not set" + exit 1 + fi + + # Verify output binary exists + if [[ -f "$TEST_OUT" ]] || [[ -f "${TEST_OUT}.exe" ]]; then + echo "FPC compilation test PASSED" + else + echo "ERROR: FPC compilation failed - no output binary at $TEST_OUT" + exit 1 + fi + + # Verify FPCDIR exists (required for pasls) + if [[ -d "$FPCDIR" ]]; then + echo "FPCDIR exists: $FPCDIR" + else + echo "ERROR: FPCDIR does not exist: $FPCDIR" + exit 1 + fi + # --- Slow / heavy language toolchains, each isolated in its own batch (OS gating via matrix excludes) --- + - name: Setup Haskell toolchain + if: matrix.batch == 'other-langs' && runner.os == 'Linux' + uses: haskell/ghcup-setup@v1 + with: + ghc: '9.12.2' + cabal: '3.10.3.0' + hls: '2.11.0.0' + - name: Verify Haskell tools + if: matrix.batch == 'other-langs' && runner.os == 'Linux' + shell: bash + run: | + which ghc && ghc --version + which cabal && cabal --version + if command -v haskell-language-server-wrapper &>/dev/null; then + haskell-language-server-wrapper --version || echo "WARNING: HLS wrapper version check failed" + else + echo "WARNING: haskell-language-server-wrapper not found (may be incompatible with GHC 9.12.2)" + fi + - name: Pre-build Haskell test project for HLS + if: matrix.batch == 'other-langs' && runner.os == 'Linux' + shell: bash + run: | + cd test/resources/repos/haskell/test_repo + cabal update + cabal build --only-dependencies + cabal build + - name: Install OCaml and opam + if: matrix.batch == 'niche' + uses: ocaml/setup-ocaml@v3 + with: + ocaml-compiler: ${{ runner.os == 'Windows' && '4.14' || '5.3.x' }} + dune-cache: true + opam-repositories: | + ${{ runner.os == 'Windows' && 'opam-repository-mingw: https://github.com/ocaml-opam/opam-repository-mingw.git#sunset' || '' }} + default: https://github.com/ocaml/opam-repository.git + - name: Install OCaml packages + if: matrix.batch == 'niche' + shell: bash + run: | + if [ "$RUNNER_OS" = "Windows" ]; then + opam install -y dune ocaml-lsp-server + else + opam install -y dune 'ocaml-lsp-server>=1.23.0' + fi + - name: Install Nix + if: matrix.batch == 'niche' && runner.os != 'Windows' + uses: cachix/install-nix-action@v30 + with: + nix_path: nixpkgs=channel:nixos-unstable + # Authenticate any GitHub fetch Nix makes during evaluation/tests with the runner's + # GITHUB_TOKEN (raises the unauthenticated 60 req/hr-per-IP API limit to ~1000/hr). + # nixd itself is installed from the channel below, so this is just insurance. + extra_nix_config: | + access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} + - name: Install nixd (Nix Language Server) + if: matrix.batch == 'niche' && runner.os != 'Windows' + shell: bash + run: | + # Install nixd from the nixos-unstable channel (set as on NIX_PATH via nix_path + # above) using classic nix-env. `-f ''` resolves the channel tarball from + # nixos.org and selects the `nixd` attribute -- it never touches the GitHub API. (The flake + # form `nix profile install github:nix-community/nixd` resolves an unpinned HEAD through + # api.github.com/.../commits/HEAD, which GitHub 429-throttles on CI even with a token; + # and `nix-env -iA nixpkgs.nixd` fails here because no channel named `nixpkgs` is registered.) + nix-env -f '' -iA nixd + if ! command -v nixd &> /dev/null; then + echo "nixd installation failed or not in PATH"; exit 1 + fi + echo "$HOME/.nix-profile/bin" >> $GITHUB_PATH + - name: Build Lean 4 test project + if: matrix.batch == 'niche' + uses: leanprover/lean-action@v1 + with: + lake-package-directory: test/resources/repos/lean4/test_repo + # --- niche batch: R, Julia, Perl --- + - name: Install R + if: matrix.batch == 'niche' + uses: r-lib/actions/setup-r@v2 + with: + r-version: '4.4.2' + use-public-rspm: true + - name: Cache R packages (languageserver + its deps) + if: matrix.batch == 'niche' + id: cache-r + uses: actions/cache@v3 + with: + # setup-r exports R_LIBS_USER as $RUNNER_TEMP/Library (a stable path) and re-exports it on + # every run -- including cache hits -- so R finds the restored packages with no extra env. + # Bump the trailing -vN (or r-version) to invalidate after an R/OS-image upgrade. + path: ${{ env.R_LIBS_USER }} + key: r-pkgs-${{ runner.os }}-R4.4.2-languageserver-v1 + - name: Install R sysdeps (libuv for fs package) + # Always run (NOT gated on the cache): the cached, compiled `fs` package links libuv at + # runtime, so the shared lib must be present even when the build below is skipped. + if: matrix.batch == 'niche' && runner.os == 'Linux' + shell: bash + run: sudo apt-get update && sudo apt-get install -y libuv1-dev + - name: Install R language server + if: matrix.batch == 'niche' && steps.cache-r.outputs.cache-hit != 'true' + shell: bash + run: Rscript -e "install.packages('languageserver', repos='https://cloud.r-project.org')" + - name: Set up Julia + if: matrix.batch == 'niche' + uses: julia-actions/setup-julia@v2 + with: + version: '1.12.6' + - name: Cache Julia depot (packages + precompiled) + # Caches the depot (~/.julia: packages, artifacts, compiled, ...) with an auto-managed key, + # so the LanguageServer Pkg.add below is a near-no-op (already installed + precompiled) on + # warm runs. The action handles save/restore itself -- no cache-hit gating needed. + if: matrix.batch == 'niche' + uses: julia-actions/cache@v2 + - name: Install Julia LanguageServer + if: matrix.batch == 'niche' + shell: bash + run: julia -e 'using Pkg; Pkg.add(Pkg.PackageSpec(name="LanguageServer", version="5.0.0"))' + - name: Cache Perl::LanguageServer (~/perl5) + if: matrix.batch == 'niche' && runner.os != 'Windows' + id: cache-perl + uses: actions/cache@v3 + with: + path: ~/perl5 + key: perl-ls-${{ runner.os }}-v1 + - name: Install Perl::LanguageServer + if: matrix.batch == 'niche' && runner.os != 'Windows' + shell: bash + run: | + # System deps are cheap and needed at runtime (the cached LS links libanyevent/libio-aio), + # so install them always; the slow cpanm build (XS compile of Coro etc.) runs only on a miss. + if [[ "${{ runner.os }}" == "Linux" ]]; then + sudo apt-get update + sudo apt-get install -y cpanminus build-essential libanyevent-perl libio-aio-perl + elif [[ "${{ runner.os }}" == "macOS" ]]; then + brew install cpanminus + fi + if [[ "${{ steps.cache-perl.outputs.cache-hit }}" != "true" ]]; then + # --local-lib pins the install to ~/perl5 (matching the PERL5LIB exports below), so the + # cached directory is exactly what `perl -MPerl::LanguageServer` loads on a restore. + PERL_MM_USE_DEFAULT=1 cpanm --notest --force --local-lib="$HOME/perl5" Perl::LanguageServer + fi + echo "PERL5LIB=$HOME/perl5/lib/perl5${PERL5LIB:+:${PERL5LIB}}" >> $GITHUB_ENV + echo "PERL_LOCAL_LIB_ROOT=$HOME/perl5${PERL_LOCAL_LIB_ROOT:+:${PERL_LOCAL_LIB_ROOT}}" >> $GITHUB_ENV + echo "PERL_MB_OPT=--install_base \"$HOME/perl5\"" >> $GITHUB_ENV + echo "PERL_MM_OPT=INSTALL_BASE=$HOME/perl5" >> $GITHUB_ENV + echo "$HOME/perl5/bin" >> $GITHUB_PATH + - name: Install npm deps for Angular test repo + if: matrix.batch == 'catch-all' + shell: bash + run: | + if [ -d test/resources/repos/angular/test_repo ]; then + cd test/resources/repos/angular/test_repo + npm install --no-audit --no-fund --loglevel=warn + fi + - name: Cache language servers + id: cache-language-servers + uses: actions/cache@v3 + with: + path: ~/.serena/language_servers/static + key: language-servers-${{ runner.os }}-${{ matrix.batch }}-v1 + restore-keys: | + language-servers-${{ runner.os }}-${{ matrix.batch }}- + - name: Compute marker expression for this batch + id: markers + shell: bash + run: | + case "${{ matrix.batch }}" in + jvm) expr="$MARKERS_JVM" ;; + native) expr="$MARKERS_NATIVE" ;; + other-langs) expr="$MARKERS_OTHER_LANGS" ;; + niche) expr="$MARKERS_NICHE" ;; + catch-all) expr="not ($MARKERS_JVM or $MARKERS_NATIVE or $MARKERS_OTHER_LANGS or $MARKERS_NICHE)" ;; + *) echo "Unknown batch: ${{ matrix.batch }}" >&2; exit 1 ;; + esac + echo "Marker expression: $expr" + echo "expr=$expr" >> "$GITHUB_OUTPUT" + - name: Test with pytest + shell: bash + run: uv run poe test -m "${{ steps.markers.outputs.expr }}" -q --tb=short + - name: Type-checking with ty + # Type-checking is batch-independent; run it only once per OS instead of in every batch. + if: matrix.batch == 'catch-all' + shell: bash + run: uv run poe type-check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d82046 --- /dev/null +++ b/.gitignore @@ -0,0 +1,278 @@ +# macOS specific files +.DS_Store +.AppleDouble +.LSOverride +._* +.Spotlight-V100 +.Trashes +Icon +.fseventsd +.DocumentRevisions-V100 +.TemporaryItems +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Windows specific files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db +*.stackdump +[Dd]esktop.ini +$RECYCLE.BIN/ +*.cab +*.msi +*.msix +*.msm +*.msp +*.lnk + +# Linux specific files +*~ +.fuse_hidden* +.directory +.Trash-* +.nfs* + +# IDE/Text Editors +# VS Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace +.history/ + +# JetBrains IDEs (beyond .idea/) +*.iml +*.ipr +*.iws +out/ +.idea_modules/ + +# Sublime Text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache +*.sublime-workspace +*.sublime-project + +# Project specific ignore +.idea +temp + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +!test/resources/repos/dart/test_repo/lib/ +!test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart +!test/resources/repos/svelte/test_repo/src/lib/ +!test/resources/repos/svelte/test_repo/src/lib/** +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# reports +pylint.html +.pylint.d + +# Serena-specific +/*.yml +!*.template.yml +/agent-ui +/test/**/.serena + +# clojure-lsp temporary files +.calva/ +.clj-kondo/ +.cpcache/ +.lsp/ + +# temporary and backup files +*.bak +*.tmp +tmp/ + +.vscode/ + +# Claude settings +/.claude + +# Elixir +/test/resources/repos/elixir/test_repo/deps +# Exception: Don't ignore Elixir test repository lib directory (contains source code) +!/test/resources/repos/elixir/test_repo/lib + +#Cpp +/test/resources/repos/cpp/test_repo/.ccls-cache + +# Exception: Don't ignore Nix test repository lib directory (contains source code) +!/test/resources/repos/nix/test_repo/lib + +# Exception: Don't ignore OCaml test repository lib directory (contains source code) +!/test/resources/repos/ocaml/test_repo/lib + +# Exception: Don't ignore Julia test repository lib directory (contains source code) +!/test/resources/repos/julia/test_repo/lib + +# Exception: Don't ignore Solidity test repository lib directory (contains source code) +!/test/resources/repos/solidity/test_repo/contracts/lib + +# Swift +/test/resources/repos/swift/test_repo/.build +/test/resources/repos/swift/test_repo/.swiftpm + +# OCaml +/test/resources/repos/ocaml/test_repo/_build + +# Elm +/test/resources/repos/elm/test_repo/.elm/ +/test/resources/repos/elm/test_repo/elm-stuff/ + +# Scala +.metals/ +.bsp/ +.scala-build/ +.bloop/ +bootstrap +test/resources/repos/scala/.bloop/ + +# Haskell +.stack-work/ +*.cabal +stack.yaml.lock +dist-newstyle/ +cabal.project.local* +.ghc.environment.* + +# Lean 4 +.lake/ + +zz-misc/ +vue-implementation/ + +news/news.json diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..2e510af --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/memories/adding_new_language_support_guide.md b/.serena/memories/adding_new_language_support_guide.md new file mode 100644 index 0000000..6eeed48 --- /dev/null +++ b/.serena/memories/adding_new_language_support_guide.md @@ -0,0 +1,229 @@ +# Adding New Language Support to Serena + +This guide explains how to add support for a new programming language to Serena. + +## Overview + +Adding a new language involves: + +1. **Language Server Implementation** - Creating a language-specific server class +2. **Language Registration** - Adding the language to enums and configurations +3. **Test Repository** - Creating a minimal test project +4. **Test Suite** - Writing comprehensive tests + +## Step 1: Language Server Implementation + +### 1.1 Create Language Server Class + +Create a new file in `src/solidlsp/language_servers/` (e.g., `new_language_server.py`). + +#### Providing the Launch Command via a DependencyProvider + +All language servers use the `DependencyProvider` pattern to handle + * runtime dependency installation/discovery + * launch command creation (and, optionally, environment setup) + +To implement a new language server using the DependencyProvider pattern: + * Pass `None` for `process_launch_info` in `super().__init__()` - the base class creates it via `_create_dependency_provider()` + * Implement `_create_dependency_provider()` to return an inner `DependencyProvider` class instance. + In simple cases, it can be instantiated with only two parameters: + ```python + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + ``` + The resource dir that is passed is the directory in which installed dependencies should be stored! + +**Base Classes** (choose the most specific one that fits): + +- **`LanguageServerDependencyProviderUvx`** - For language servers distributed as a PyPI package, run on demand + via `uvx` / `uv x` (no installation step to implement) + - Simply instantiate it with the package name, pinned default version, entrypoint (console script) and optional `extra_args`; + the version can be overridden by the user via the configured `version_setting_key` custom setting + - Reference implementation: `PyrightServer` + +- **`LanguageServerDependencyProviderBaseCommand`** - For the common case where the + launch command is constructed from a *base command* (which the user can override via custom settings; handled generically) + - Implement `_create_default_base_command()` to return the default base command (executable + args), downloading/installing + dependencies beforehand if necessary + - Implement `_create_launch_command_from_base_command(base_command)` to add any further arguments, producing the + final launch command + +- **`LanguageServerDependencyProviderSinglePath`** - Alternative to inheriting from `...BaseCommand` directly for the case + of a single core dependency (e.g., an executable or JAR file); mostly present in existing implementations - for new + implementations, prefer `...BaseCommand` + - Implement `_get_or_install_core_dependency()` to return the path to the core dependency, downloading/installing it automatically if necessary + - Implement `_create_launch_command(core_path)` to build the full command from the core path + - Reference implementations: `TypeScriptLanguageServer`, `Intelephense`, `ClojureLSP`, `ClangdLanguageServer` + +- **`LanguageServerDependencyProvider`** - The root base class, for complex cases with multiple dependencies or custom setup + - Implement `create_launch_command()` directly (note: no automatic support for user-level launch command overrides in this case) + - Reference implementations: `EclipseJDTLS`, `CSharpLanguageServer`, `MatlabLanguageServer` + +**Implementation Pointers::** + - When returning the command, prefer the list-based representation for robustness + - Override `create_launch_command_env` if the launch command needs environment variables to be set (defaults to `{}` in the base implementation) + +You should look at at least one existing implementation of each base class to understand how they work. + +### 1.2 LSP Initialization + +Override `_create_base_initialize_params` to provide server-specific initialization +parameters. The common keys — `processId`, `rootPath`, `rootUri`, `clientInfo` and +`workspaceFolders` — are set centrally by the `InitializeParamsBuilder` (see +`src/solidlsp/initialize_params.py`), so your override MUST NOT set them. Just return +the server-specific settings (typically `capabilities` and `initializationOptions`): + +```python +def _create_base_initialize_params(self) -> dict: + """Return language-specific initialization parameters (server-specific keys only).""" + return { + "capabilities": { + # Language-specific capabilities + }, + # "initializationOptions": {...}, # if the server needs them + } + +def _start_server(self): + """Start the language server with custom handlers.""" + # Set up notification handlers + self.server.on_notification("window/logMessage", self._handle_log_message) + + # Start server and initialize. Do NOT call _create_base_initialize_params directly; + # _create_initialize_params() wraps it with the builder to add the common keys. + self.server.start() + init_response = self.server.send.initialize(self._create_initialize_params()) + + self.server.notify.initialized({}) +``` + +Notes: +- The builder resolves `workspaceFolders` from the language server config (indexed + folders + `ls_additional_workspace_folders`); don't build the folder list yourself. +- To send a folder list nested inside `initializationOptions` (some servers, e.g. + `EclipseJDTLS`/`KotlinLanguageServer`, need this), set it explicitly there — only the + *top-level* `workspaceFolders` is builder-managed. +- To suppress the top-level `workspaceFolders` entirely, override + `_create_initialize_params_builder` and construct `DefaultInitializeParamsBuilder` + with `set_workspace_folders=False`. + +After `_start_server` returns, the language server should be fully operational. +If the server requires that one waits for certain notifications or responses before being ready, implement that logic here. +For an example, see `EclipseJDTLS._start_server`. + +## Step 2: Language Registration + +### 2.1 Add to Language Enum + +In `src/solidlsp/ls_config.py`, add your language to the `Language` enum: + +```python +class Language(str, Enum): + # Existing languages... + NEW_LANGUAGE = "new_language" + + def get_source_fn_matcher(self) -> FilenameMatcher: + match self: + # Existing cases... + case self.NEW_LANGUAGE: + return FilenameMatcher(".newlang", ".nl") # File extensions +``` + +### 2.2 Update Language Server Factory + +In `src/solidlsp/ls.py`, add your language to the `create` method: + +```python +@classmethod +def create(cls, config: LanguageServerConfig, repository_root_path: str) -> "SolidLanguageServer": + match config.code_language: + # Existing cases... + case Language.NEW_LANGUAGE: + from solidlsp.language_servers.new_language_server import NewLanguageServer + return NewLanguageServer(config, repository_root_path) +``` + +## Step 3: Test Repository + +### 3.1 Create Test Project + +Create a minimal project in `test/resources/repos/new_language/test_repo/`: + +``` +test/resources/repos/new_language/test_repo/ +├── main.newlang # Main source file +├── lib/ +│ └── helper.newlang # Additional source for testing +├── project.toml # Project configuration (if applicable) +└── .gitignore # Ignore build artifacts +``` + +### 3.2 Example Source Files + +Create meaningful source files that demonstrate: + +- **Classes/Types** - For symbol testing +- **Functions/Methods** - For reference finding +- **Imports/Dependencies** - For cross-file operations +- **Nested Structures** - For hierarchical symbol testing + +Example `main.newlang`: +``` +import lib.helper + +class Calculator { + func add(a: Int, b: Int) -> Int { + return a + b + } + + func subtract(a: Int, b: Int) -> Int { + return helper.subtract(a, b) // Reference to imported function + } +} + +class Program { + func main() { + let calc = Calculator() + let result = calc.add(5, 3) // Reference to add method + print(result) + } +} +``` + +## Step 4: Test Suite + +Testing the language server implementation is of crucial importance, and the tests will +form the main part of the review process. Make sure that the tests are up to the standard +of Serena to make the review go smoother. + +General rules for tests: + +1. Tests for symbols and references should always check that the expected symbol names and references were actually found. + Just testing that a list came back or that the result is not None is insufficient. +2. Tests should never be skipped, the only exception is skipping based on some package being available or on an unsupported OS. +3. Tests should run in CI, check if there is a suitable GitHub action for installing the dependencies. + +### 4.1 Basic Tests + +Create `test/solidlsp/new_language/test_new_language_basic.py`. +Have a look at the structure of existing tests, for example, in `test/solidlsp/php/test_php_basic.py` +You should at least test: + +1. Finding symbols +2. Finding within-file references +3. Finding cross-file references + +Have a look at `test/solidlsp/php/test_php_basic.py` as an example for what should be tested. +Don't forget to add a new language marker to `pytest.ini`. + +### 4.2 Integration Tests + +Consider adding new cases to the parametrized tests in `test_serena_agent.py` for the new language. + + +### 5 Documentation + +Update: + +- **README.md** - Add language to the list of languages +- **docs/01-about/020_programming-languages.md** - Add language to the list and mention any special notes, compatibility or requirements (e.g. installations the user is required to do) +- **CHANGELOG.md** - Document the new language support diff --git a/.serena/memories/conventions.md b/.serena/memories/conventions.md new file mode 100644 index 0000000..c40da34 --- /dev/null +++ b/.serena/memories/conventions.md @@ -0,0 +1,32 @@ +# Conventions + +## Style (project-instructed) +- Idiomatic, object-oriented design. Non-trivial interfaces use **explicitly typed abstractions** (strategy pattern etc.) rather than bare functions/callbacks. +- Avoid low-level data structures where an OO abstraction fits. For simple data containers use **dataclasses**, not dicts/tuples. +- Structure function bodies into **functional blocks separated by blank lines**, each prefixed with a short elliptical phrase (lowercase, no leading capital) describing the block's purpose. +- **Docstrings: reStructuredText.** Param/return/raises use `:param x:`, `:return:`, `:raises X:`. +- Parameter / method / class descriptions begin with a precise elliptical phrase defining *what* the thing is; details in subsequent sentences. + +## Formatting / lint (ruff) +- Line length 140, double quotes, target `py311`. +- Many "annoying" rules are disabled — see `[tool.ruff.lint] ignore` in `pyproject.toml` before adding workarounds (e.g. `Optional[T]` is preferred over `T | None`, `Union` is allowed, relative imports forbidden, `% string formatting` allowed). +- `ruff format` runs on `src scripts test`; same set for `ruff check`. +- mccabe complexity cap: 20. + +## Typing (ty) +- Type checker is **ty** (Astral), configured under `[tool.ty]` in `pyproject.toml`. + +## Tests +- Language-server tests are pytest-marker-gated (one marker per language; see `pyproject.toml` `[tool.pytest.ini_options].markers`). Default `poe test` runs unmarked tests + whatever `PYTEST_MARKERS` selects. +- Snapshot tests use **syrupy** with custom `--snapshot-patch-pycharm-diff` plugin (auto-added via `addopts`). + +## Tool descriptions (LLM-facing) +- To change how a tool is described to the model, edit the Tool class's `apply()` **docstring** (in + `src/serena/tools/*.py`) — `make_mcp_tool` parses the docstring body + `:param:` lines into the MCP tool + description/schema. Do NOT use `tool_description_overrides` in context ymls (e.g. `claude-code.yml`); the + docstring is the single source of truth and overrides drift. Keep `:param:` lines accurate. + +## Memories +- Follow `mem:memory_maintenance` for any new/updated memory in `.serena/memories/`. +- Durable knowledge goes in memories/docs (not the assistant's auto-memory); see the monorepo top-level + `CLAUDE.md` for the cross-repo conventions (no benchmark-tuning of agent-facing strings, etc.). diff --git a/.serena/memories/core.md b/.serena/memories/core.md new file mode 100644 index 0000000..d4a76ff --- /dev/null +++ b/.serena/memories/core.md @@ -0,0 +1,29 @@ +# Serena — Project Core + +Serena is an MCP-based "IDE for coding agents": semantic code retrieval/editing/refactoring tools driven by language servers. + +## Source map + +- `src/serena/` — agent, MCP server, tools, project/config layer + - `agent.py`, `mcp.py`, `project_server.py`, `cli.py`, `hooks.py` — entrypoints/wiring + - `tools/` — tool implementations (memory_tools, symbol_tools, file_tools, workflow_tools, query_project_tools, config_tools, cmd_tools, jetbrains_tools) + - `tools/tools_base.py` — base classes for all tools + - `config/` — `serena_config.py`, `context_mode.py`, `client_setup.py` + - `resources/config/contexts/*.yml`, `resources/config/modes/*.yml` — context/mode definitions + - `code_editor.py`, `symbol.py`, `ls_manager.py` — symbolic editing / LS lifecycle + - `dashboard.py`, `gui_log_viewer.py` — web dashboard / log viewer + - `prompt_factory.py` + `generated/generated_prompt_factory.py` — prompts (regenerate with `scripts/gen_prompt_factory.py`) +- `src/solidlsp/` — LSP client framework; per-language servers under `language_servers/` +- `src/interprompt/` — prompt template library (synced from external repo; see `.syncCommitId.*`) +- `test/serena/`, `test/solidlsp//` — pytest suites; per-language tests gated by pytest markers +- `test/resources/repos//` — fixture projects used by language-server tests +- `scripts/` — utilities (prompt regen, tool overview, profiling, agno agent) +- `docs/` — Jupyter Book sources; build via poe `doc-build` + +## Project-wide invariants + +- Package name (PyPI): `serena-agent`; import root: `serena`. Wheel includes `serena`, `interprompt`, `solidlsp`. +- Python: `>=3.11, <3.15`. Dependencies are exact-pinned in `pyproject.toml` (uvx installs from git, lockfile ignored — pin exactly). +- Entry points: `serena` → `serena.cli:top_level`; `serena-hooks` → `serena.hooks:hook_commands`. +- Per-project state lives under `/.serena/` (config + `memories/` as `.md` files). +- See `mem:tech_stack`, `mem:suggested_commands`, `mem:conventions`, `mem:task_completion`. diff --git a/.serena/memories/creating_pull_requests.md b/.serena/memories/creating_pull_requests.md new file mode 100644 index 0000000..2f3911c --- /dev/null +++ b/.serena/memories/creating_pull_requests.md @@ -0,0 +1,11 @@ +# Creating Pull Requests + +* Make sure the instructions in `CONTRIBUTING.md` regarding the scope of PRs + are followed. +* For changes that add features or fix problems, + add an entry to `CHANGELOG.md` which *concisely* describes the change. + Note that details/background information on the change should be in the commit + message, not in the changelog. + Make sure to follow the existing format and to add your change to the + appropriate section. + diff --git a/.serena/memories/memory_maintenance.md b/.serena/memories/memory_maintenance.md new file mode 100644 index 0000000..6f84514 --- /dev/null +++ b/.serena/memories/memory_maintenance.md @@ -0,0 +1,33 @@ +# Memory Maintenance + +## Discovery Model + +- Core principle: progressive discovery through references, building a graph of memories. +- Initially, agents are provided with the list of all memories (names only). +- Agents should read `mem:core` as the top-level entry point (graph root). + This memory should contain references to other memories covering major project domains. + The referenced memories shall, in turn, shall contain references to even more specific memories, and so on. + The depth of the graph shall depend on the project complexity. +- Use topics/folders to group related memories in order to make the content structure explicit. + Folders can mirror project structure (e.g. modules like frontend/backend) or topics like debugging, architecture, etc. +- Memory references must use a mem: prefix inside backticks, e.g. `mem:frontend/core`. + The surrounding text should clearly indicate when to read the memory/which content to expect. + The text should provide more precise guidance than the memory name alone, + i.e. avoid a reference like "frontend debugging: `mem:frontend/debugging` and instead make clear which aspects of frontend debugging are covered. +- Memories themselves should not contain information about when to read them; this is the responsibility of the referring memory. + +## Style + +Dense agent notes, not prose docs. Prefer invariants, terse bullets. +Avoid obvious context, rationale, and examples unless they prevent likely mistakes. +Keep guidance durable and generalizable, not task-local. + +## Add/update threshold + +Add or update memories only with stable, non-obvious project conventions that avoid complex rediscovery in the future. +Do not add: quick-read facts; generic language/framework knowledge; one-off task notes; volatile line-level details; behavior likely to change soon. + +## Maintenance Actions + +- Renaming memories: References are updated automatically if handled via Serena's memory rename tool. +- Checking for stale memories (e.g. after deletion): Call `serena memories check` for a report. \ No newline at end of file diff --git a/.serena/memories/serena_repository_structure.md b/.serena/memories/serena_repository_structure.md new file mode 100644 index 0000000..5fc70a9 --- /dev/null +++ b/.serena/memories/serena_repository_structure.md @@ -0,0 +1,113 @@ +# Serena Repository Structure + +## Overview +Serena is a multi-language code assistant that combines two main components: +1. **Serena Core** - The main agent framework with tools and MCP server +2. **SolidLSP** - A unified Language Server Protocol wrapper for multiple programming languages + +## Top-Level Structure + +``` +serena/ +├── src/ # Main source code +│ ├── serena/ # Serena agent framework +│ ├── solidlsp/ # LSP wrapper library +│ └── interprompt/ # Multi-language prompt templates +├── test/ # Test suites +│ ├── serena/ # Serena agent tests +│ ├── solidlsp/ # Language server tests +│ └── resources/repos/ # Test repositories for each language +├── scripts/ # Build and utility scripts +├── resources/ # Static resources and configurations +├── pyproject.toml # Python project configuration +├── README.md # Project documentation +└── CHANGELOG.md # Version history +``` + +## Source Code Organization + +### Serena Core (`src/serena/`) +- **`agent.py`** - Main SerenaAgent class that orchestrates everything +- **`tools/`** - MCP tools for file operations, symbols, memory, etc. + - `file_tools.py` - File system operations (read, write, search) + - `symbol_tools.py` - Symbol-based code operations (find, edit) + - `memory_tools.py` - Knowledge persistence and retrieval + - `config_tools.py` - Project and mode management + - `workflow_tools.py` - Onboarding and meta-operations +- **`config/`** - Configuration management + - `serena_config.py` - Main configuration classes + - `context_mode.py` - Context and mode definitions +- **`util/`** - Utility modules +- **`mcp.py`** - MCP server implementation +- **`cli.py`** - Command-line interface + +### SolidLSP (`src/solidlsp/`) +- **`ls.py`** - Main SolidLanguageServer class +- **`language_servers/`** - Language-specific implementations + - `csharp_language_server.py` - C# (Microsoft.CodeAnalysis.LanguageServer) + - `python_server.py` - Python (Pyright) + - `typescript_language_server.py` - TypeScript + - `rust_analyzer.py` - Rust + - `gopls.py` - Go + - And many more... +- **`ls_config.py`** - Language server configuration +- **`ls_types.py`** - LSP type definitions +- **`ls_utils.py`** - Utilities for working with LSP data + +### Interprompt (`src/interprompt/`) +- Multi-language prompt template system +- Jinja2-based templating with language fallbacks + +## Test Structure + +### Language Server Tests (`test/solidlsp/`) +Each language has its own test directory: +``` +test/solidlsp/ +├── csharp/ +│ └── test_csharp_basic.py +├── python/ +│ └── test_python_basic.py +├── typescript/ +│ └── test_typescript_basic.py +└── ... +``` + +### Test Resources (`test/resources/repos/`) +Contains minimal test projects for each language: +``` +test/resources/repos/ +├── csharp/test_repo/ +│ ├── serena.sln +│ ├── TestProject.csproj +│ ├── Program.cs +│ └── Models/Person.cs +├── python/test_repo/ +├── typescript/test_repo/ +└── ... +``` + +### Test Infrastructure +- **`test/conftest.py`** - Shared test fixtures and utilities +- **`create_ls()`** function - Creates language server instances for testing +- **`language_server` fixture** - Parametrized fixture for multi-language tests + +## Key Configuration Files + +- **`pyproject.toml`** - Python dependencies, build config, and tool settings +- **`.serena/`** directories - Project-specific Serena configuration and memories +- **`CLAUDE.md`** - Instructions for AI assistants working on the project + +## Dependencies Management + +The project uses modern Python tooling: +- **uv** for fast dependency resolution and virtual environments +- **pytest** for testing with language-specific markers (`@pytest.mark.csharp`) +- **ruff** for linting and formatting +- **ty** (Astral) for type checking + +## Build and Development + +- **Docker support** - Full containerized development environment +- **GitHub Actions** - CI/CD with language server testing +- **Development scripts** in `scripts/` directory \ No newline at end of file diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md new file mode 100644 index 0000000..e4e4481 --- /dev/null +++ b/.serena/memories/suggested_commands.md @@ -0,0 +1,24 @@ +# Suggested Commands + +Run via `uv run poe ` (or `poe ` inside the activated venv). Poe executor is `simple`, so plain `poe` works without uv re-resolving. + +## Dev loop +- `poe test` — pytest on `test/` (per-language tests are marker-gated; pass `-m ` to enable). +- `poe lint` — ruff format-check + ruff check (no fixes). +- `poe format` — ruff `--fix` then `ruff format` (mutates files). +- `poe type-check` — ty on `src/serena`, `src/solidlsp`, and `test/` (test pass relaxes pytest/mock-noisy rules via `[[tool.ty.overrides]]`). +- Single test file: `uv run pytest test/path/to/test_x.py -vv`. Language-gated: add `-m python` etc. + +## Docs +- `poe doc-build` — clean + autogen + sphinx (uses `rm -rf`; needs a unix-like shell, e.g. Git Bash on Windows). + +## Entrypoints +- `uv run serena ...` — main CLI (`serena.cli:top_level`). +- `uv run serena-hooks ...` — hook helpers. +- `python scripts/gen_prompt_factory.py` — regenerate `src/serena/generated/generated_prompt_factory.py` after editing prompt templates. + +## Windows shell notes (PowerShell 7+ is the project shell) +- Use `Remove-Item -Recurse -Force ` instead of `rm -rf` (the `doc-clean` poe task uses unix `rm -rf` and requires bash). +- Env vars: `$env:NAME = 'value'` (not `export`). +- Path separator in `pyproject.toml` poe tasks uses forward slashes; PowerShell accepts them in arguments. +- `git`, `uv`, `poe`, `pytest` behave identically to unix. diff --git a/.serena/memories/task_completion.md b/.serena/memories/task_completion.md new file mode 100644 index 0000000..8d6948a --- /dev/null +++ b/.serena/memories/task_completion.md @@ -0,0 +1,11 @@ +# Task Completion Checklist + +After any code change in `src/` or `test/`, run: + +1. `uv run poe format` — applies ruff fixes + formatting (mutates files). +2. `uv run poe type-check` — ty on `src/serena`, `src/solidlsp`, `test/`. +3. `uv run poe test` — pytest on affected files or affected languages, using `-m` markers. + +If prompt templates changed: `uv run python scripts/gen_prompt_factory.py` (regenerates `src/serena/generated/generated_prompt_factory.py`; use `uv run poe format` and commit the result). + +If memories were edited/renamed/split: run `uv run serena memories check` from the project root to find broken `mem:` references. diff --git a/.serena/memories/tech_stack.md b/.serena/memories/tech_stack.md new file mode 100644 index 0000000..ca8a3a6 --- /dev/null +++ b/.serena/memories/tech_stack.md @@ -0,0 +1,10 @@ +# Tech Stack + +- Language: Python 3.11–3.14 (`requires-python = ">=3.11, <3.15"`). +- Package/dep manager: **uv** (uv.lock present). Exact version pins in `pyproject.toml` because `uvx` installs from git and ignores the lockfile. +- Build backend: `hatchling`. Packages: `src/serena`, `src/interprompt`, `src/solidlsp`. +- Task runner: **poethepoet** (`poe `). Poe executor is `simple` (does NOT shell out via uv) — avoids env recreation while MCP server is running. +- Key runtime deps: `mcp`, `flask` (dashboard), `pydantic`, `pygls` + `lsprotocol` (LSP), `anthropic`, `jinja2`, `ruamel.yaml`, `pywebview`/`pystray` (GUI), `pythonnet` (Windows only). +- Dev deps: `ruff` (lint+format), `ty` (Astral type checker; replaced mypy), `pytest` (+ `pytest-xdist`, `pytest-timeout`, `syrupy` snapshots), `sphinx`/`jupyter-book` for docs. +- Optional extras: `agno` (Agno agent integration), `google` (gemini). +- LSP client core lives under `src/solidlsp/`; one subdir per supported language server under `language_servers/`. diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..e2cafdb --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,187 @@ +# the name by which the project can be referenced within Serena/when chatting with the LLM. +project_name: "serena" + +# list of languages for which language servers are started (LSP backend only); choose from: +# ada al angular ansible bash +# bsl clojure cpp cpp_ccls crystal +# csharp csharp_omnisharp cue dart elixir +# elm erlang fortran fsharp gdscript +# go groovy haskell haxe hlsl +# html java json julia kotlin +# latex lean4 lua luau markdown +# matlab msl nix ocaml pascal +# perl php php_phpactor php_phpantom powershell +# python python_jedi python_pyrefly python_ty r +# rego ruby ruby_solargraph rust scala +# scss solidity svelte swift systemverilog +# terraform toml typescript typescript_vts vue +# yaml zig +# (This list may be outdated; generated with scripts/print_language_list.py; +# For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- python +- typescript + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +included_optional_tools: [] + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: | + IMPORTANT: You use an idiomatic, object-oriented style. + In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions + rather than mere functions (i.e. use the strategy pattern, for example). + You avoid the use of low-level data structures in all cases where an object-oriented abstraction would be more appropriate. + For simple data storage, you use dataclasses instead of dictionaries or tuples. + + You structure function implementations into functional blocks that are separated by blank lines. + Atop each functional block, you write an elliptical phrase (starting with lower-case letter) that describes the purpose of the + block in a concise manner. + + Docstrings: You consistently use reStructuredText. + + Comments: + When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase + clearly defines *what* it is. Any details then follow in subsequent sentences. + + Pull requests: + Read `mem:creating_pull_requests` when asked to participate in the creation of a pull request. + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: utf-8 + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. +# This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +default_modes: + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +fixed_tools: [] + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# The settings are considered only if the project is trusted (see global configuration to define trusted projects). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings +ls_specific_settings: {} + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# optional shell command to run before the language backend (LSP or JetBrains) is initialised. +# the command runs in the project root directory and is only executed if the project is trusted +# (see trusted_project_path_patterns in the global configuration). +# serena waits for the command to exit: a non-zero exit code is logged as an error but does not +# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety +# backstop for non-terminating commands; on expiry the process is killed and activation continues. +# example: activation_command: "npx nx run-many -t build" +activation_command: + +# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). +# must be a positive number. +activation_command_timeout: 180.0 + +# list of workspace folder paths (LSP backend only). +# These folders will be used to build up Serena's symbol index. +# Paths must be within the project root and should thus be relative to the project root. +# Furthermore, the paths should not be filtered by ignore settings. +# Default setting: The entire project root folder (".") is considered. +# In (large) monorepos, this can be used to index only subfolders of the project root, e.g. +# ls_workspace_folders: +# - "./subproject1" +# - "./subproject2" +ls_workspace_folders: +- "." + +# list of additional workspace folder paths for cross-package reference support. +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries, but these folders are not indexed by Serena, +# i.e. the respective symbols will not be found using Serena's symbol search tool. +# Example: +# additional_workspace_folders: +# - "../sibling-package" +# - "../shared-lib" +ls_additional_workspace_folders: [] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2b07df3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +Relevant information about the project is in .serena/memories. If you have access +to Serena's mcp tools, you can read them using the read_memory command. Otherwise +you can just read them using normal file reading tools. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..98f7044 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,610 @@ +# Unreleased (main) + +Status of the `main` branch. Changes prior to the next official version change will appear here. + +* General: + - Fix: in `glob_to_regex` / `search_text(is_glob=True)`, a `?` wildcard matched two characters instead + of one (it emitted `..` rather than `.`); it now matches a single character, consistent with the + `?` semantics documented for `glob_match` and the `test_??.py` example in `search_text`'s docstring. + - Add notion of trusted projects via new global configuration setting `trusted_project_path_patterns`. + Current effects: + - `ls_specific_settings` defined in project configurations will only be applied for trusted projects + - `activation_command` (and `activation_command_timeout`) defined in project configurations will only + be executed for trusted projects: an optional shell command run in the project root before the + language backend initialises (e.g. to generate source files a language server needs to index). + Exit code is the primary completion signal; `activation_command_timeout` (default 180s) is a safety + backstop — on expiry the process is killed and activation continues. Failures and timeouts are + logged but do not abort activation. + - Fix: context or mode argument referencing a known name (e.g. `--context anitgravity`) could result in + incorrect file access if a corresponding local file existed (e.g. `./antigravity` binary); + file access is now guarded with path detection (file ending or path separator must be present) + - Adjust prompt generation mechanism to use newly introduced tool name mapping `tool_names`, allowing + prompts to directly use tool names that match the active language backend (and removing the need + for additional prompts that explain tool name differences) + - Improve quoting/escaping of arguments in shell executions on Windows (via `oslex` dependency) + - Fix: a registered project whose root directory was deleted while Serena was already running could break + `activate_project`/project lookup, raising `FileNotFoundError` in `RegisteredProject.matches_root_path` + - Update prompts/instructions: Serena instructions manual, modes (editing, interactive) + - Allow structured tool output to be configured on a per-context basis, disabling it for Claude Code + (which does not correctly unpack structured output) #1042 + - Fix: Project-specific filtering of files for source files ignored the language backend. + The check is really only possible for LSP. + - Fix: File system permission errors during gitignore scanning were not caught #1624 + - During project creation, language composition percentages are now computed relative to the total number + of recognised source files instead of all files, i.e. unrecognised files are ignored in the percentage + computation. + +* CLI: + - Fix `--project-from-cwd` hijacking git worktrees nested under a Serena project. `find_project_root` + now walks up in a single pass so the nearest project boundary wins (either a `.serena/project.yml` + or a `.git`, including worktree/submodule pointer files), instead of preferring an ancestor's + `.serena/project.yml` over a closer `.git`. This previously bound CLI agents (Claude Code, Codex, + Gemini) launched from inside a worktree to the parent repo, causing stale reads and misdirected edits. + - Fix: CLI flags on `start-mcp-server` could incorrectly be saved to the global configuration file if the + list of projects was modified (triggering a save of the configuration with transient overrides applied) + +* Tools: + - New tool: `replace_in_files` + - `get_symbols_overview`, `jet_brains_get_symbols_overview`: Improved default for `depth` parameter + - Add tool parameter alias support, adding `name_path` as an alias for `name_path_pattern` in `find_symbol` tools + - Allow `query_project` tool to access read-only tools that are not enabled in the current configuration + - Make tool call errors surface explicitly as errors at the MCP protocol level + +* Language Servers: + - Fix: `SolidLanguageServer.is_ignored_path` raised `FileNotFoundError` for paths that are not present + on disk, crashing symbol tools when a language server reported locations of generated files + (e.g. JDTLS reporting Lombok-generated classes under `target/classes`). Missing paths are now + classified by the usual ignore rules, and symbol locations resolving to non-existent files are skipped. + - Solidity: fix diagnostics intermittently coming back empty on slow or cold environments (macOS/Windows CI). + - Perl: expose `file_filter` and `ignore_dirs` via `ls_specific_settings["perl"]`, so projects with + non-standard extensions (e.g. `.cgi`, `.psgi`) can make those files visible to Perl::LanguageServer. + Configured extensions are also synced into the Perl source-file matcher (now a cached singleton), + keeping `find_symbol` / symbol indexing consistent with the LS; the matcher is reset on every + language server activation so one project's reconfiguration does not leak into the next. + `FilenameMatcher` gains `add_extensions` / `reset` methods. Defaults are unchanged. #1449 + - C/C++ (clangd): improve support and documentation for Unreal Engine 5 projects. + - HLSL (`shader-language-server`): pass `--locked` to `cargo install` when building from source + on macOS (and in the manual-install instructions), honoring the crate's packaged `Cargo.lock`. + Without it, fresh dependency resolution pulled in shader-sense 1.4.0, which no longer compiles + against the pinned shader_language_server 1.3.1, breaking the macOS CI job. + - `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`. + Enables Yarn PnP setups with `typescript.tsdk` pointing at the Yarn-generated SDK. + - TypeScript/VTS: disable automatic typing acquisition during initialization (no network + downloads at startup) and replace the fixed 2-second cross-file reference wait with + event-based `$/progress` indexing tracking (configurable `indexing_timeout`, default 30s) + - C#: minor fixes in Omnisharp and Roslyn that prevented startup on some systems #1617 + - `SvelteLanguageServer`: Fix diagnostics requests for TypeScript/JavaScript files incorrectly being + processed by the Svelte LS instead of the TypeScript LS. + - `SvelteLanguageServer`: Fix document-symbol requests for TypeScript/JavaScript files returning empty + results in svelte-only mode (`languages: [svelte]`. #1552 + - Svelte + TypeScript: make companion TypeScript server raise on readiness and indexing timeouts (instead + of silent "proceeding anyway"), so that partial indexing surfaces as a clear failure instead of flaky/wrong + cross-file results; add configurable timeouts (`server_ready_timeout`, `indexing_timeout`) + and overridable timeout hooks (base TS stays permissive). Svelte test fixture now uses `npm ci` + + committed `package-lock.json`. + - `JuliaLanguageServer`: Fix the stdio MCP server exiting right after `initialize` ("tools fetch failed") + when `julia` is enabled. #1577 + - `Java`: invalidate JDTLS workspace cache when Java import settings change #1576 + - `Java`: use `JAVA_HOME` for Gradle import when `use_system_java_home` is enabled and `gradle_java_home` + is unset. #1657 + - `Java`: stop hard-ignoring directories named `target`/`build`/`bin`/`out`/`classes`/`dist`/`lib` in + `EclipseJDTLS`. These are all valid Java package identifiers, so ignoring them by name hid legitimate + source from the symbol tools even when they were not gitignored. Removed the hardcoded override; real + build output is already excluded via `.gitignore`. #1645 + - Improve quoting of arguments in shell executions + - Add **LaTeX** support (experimental) via [texlab](https://github.com/latex-lsp/texlab). + - PHP: add support for PHPantom as alternative to the already supported PHP LS #1554. + - Add new launch command customization options: `ls_args`, `ls_extra_args` and `ls_base_cmd` + - Add new configuration option `ls_workspace_folders` to allow indexed source folders to be specified + explicitly. In monorepos, this allows the set of indexed folders to be restricted to a subset of + the repository. #1627 + - Rename configuration option `additional_workspace_folders` to `ls_additional_workspace_folders` + and support the option across all language servers (previously limited to TypeScript). + +* JetBrains: + - Add configuration option `jetbrains_launch_command`, allowing Serena to spawn IDE instances automatically + upon project activation + - Fix: `jet_brains_list_inspections` failed when only default parameters were used #1615 + +* Dashboard: + - Make list of trusted hosts configurable, fixing host validation introduced in v1.5.2 allowing only + default local hostnames, effectively preventing remote connections + - Decouple configuration computation from the agent's task queue by introducing events for agent config/status updates. + This allows the dashboard to display the configuration while the project provided at startup is still initialising. #1064 + - Fix empty executions queue displaying "Loading..." + - Tray manager: Add NixOS-support for AppIndicator-based trays (e.g., most Wayland-trays) to the package in flake.nix. + - Fix: Wait for the subprocess that opens the browser window, preventing zombie processes #1488 + +* Hooks: + - Handle tool_input passed as string gracefully instead of failing (Copilot CLI sends strings). + +* Memories: + - Make memory iteration follow symbolic links + - Fix: a memory name that was absolute (e.g. `/etc/cron.d/backdoor`) or contained empty path + segments could write outside the `memories` folder. + +* Dependencies: + - Add dependency `oslex` + +# v1.5.3 (2026-05-26) + +Add meta-data for the GitHub MCP registry + +# v1.5.2 (2026-05-26) + +* General: + - Not existing paths return `False` on is ignored checks (instead of raising an error) + - Add `serena-agent` CLI command so that `uvx serena-agent` can be used as entrypoint. + - Fortls and pyright are now installed on the fly instead of being bundled in the serena-agent package. + +* Dashboard: + - Add host validation + +* Hooks: + - Extend list of extensions that are considered code files (affects the reminder hook counter). + +# v1.5.1 (2026-05-18) + +* General: + - Fix `onboarding_tool`: Used incorrect path to bootstrap memory (regression in v1.5.0) + +* Language Servers: + - Add **CUE** support via the LSP mode of the official [`cue` CLI](https://github.com/cue-lang/cue) (`cue lsp`). + +# v1.5.0 (2026-05-18) + +* General: + - Make tool descriptions more amenable to tool search mechanisms as now used in several clients (e.g. avoid referencing other tools' names, etc.) + - Onboarding is now less invasive (LLM is instructed to ask the user whether to proceed) + +* Language Servers: + - No longer store temporary files (e.g. downloads) in `~/solidlsp_tmp`; instead, use OS-specific temporary directories + - Add **GDScript** (Godot Engine) support. Serena connects over TCP to the Godot editor's built-in LSP server (port 6008, same for Godot 3 and 4) — no separate language server process to install. Godot major version is auto-detected from `config_version` in `project.godot`. Note: Godot's LSP does not implement `workspace/symbol`; first workspace-wide scans fall back to per-file requests and can be slow for large projects (results are cached to disk). See the [GDScript Setup Guide](https://oraios.github.io/serena/03-special-guides/godot_gdscript_setup_guide_for_serena.html) for details. Closes #1446. + +* Dashboard: + - UI polish: switch UI font to Inter (with system fallbacks) and use JetBrains Mono only for code/logs/paths/identifiers; refine the light/dark palette with softer borders, clearer text hierarchy, and a more nuanced shadow/elevation system; introduce a consistent spacing scale; keep the orange accent. + - Modal markup cleanup: extract shared CSS classes (`.modal-info`, `.modal-hint`, `.modal-prompt`, `.modal-field`, `.modal-input`, `.modal-select`, `.modal-textarea`, `.modal-actions`, `.btn-secondary`) and remove duplicated inline styles from all seven modals. Inputs and textareas get an accent-colored focus ring; the modal backdrop has a subtle blur. + - Add Language: replace the native ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/serena/resources/dashboard/jquery.min.js b/src/serena/resources/dashboard/jquery.min.js new file mode 100644 index 0000000..7f37b5d --- /dev/null +++ b/src/serena/resources/dashboard/jquery.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.7.1 | (c) OpenJS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(ie,e){"use strict";var oe=[],r=Object.getPrototypeOf,ae=oe.slice,g=oe.flat?function(e){return oe.flat.call(e)}:function(e){return oe.concat.apply([],e)},s=oe.push,se=oe.indexOf,n={},i=n.toString,ue=n.hasOwnProperty,o=ue.toString,a=o.call(Object),le={},v=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},y=function(e){return null!=e&&e===e.window},C=ie.document,u={type:!0,src:!0,nonce:!0,noModule:!0};function m(e,t,n){var r,i,o=(n=n||C).createElement("script");if(o.text=e,t)for(r in u)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function x(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[i.call(e)]||"object":typeof e}var t="3.7.1",l=/HTML$/i,ce=function(e,t){return new ce.fn.init(e,t)};function c(e){var t=!!e&&"length"in e&&e.length,n=x(e);return!v(e)&&!y(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+ge+")"+ge+"*"),x=new RegExp(ge+"|>"),j=new RegExp(g),A=new RegExp("^"+t+"$"),D={ID:new RegExp("^#("+t+")"),CLASS:new RegExp("^\\.("+t+")"),TAG:new RegExp("^("+t+"|[*])"),ATTR:new RegExp("^"+p),PSEUDO:new RegExp("^"+g),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+ge+"*(even|odd|(([+-]|)(\\d*)n|)"+ge+"*(?:([+-]|)"+ge+"*(\\d+)|))"+ge+"*\\)|)","i"),bool:new RegExp("^(?:"+f+")$","i"),needsContext:new RegExp("^"+ge+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+ge+"*((?:-\\d)?\\d*)"+ge+"*\\)|)(?=[^-]|$)","i")},N=/^(?:input|select|textarea|button)$/i,q=/^h\d$/i,L=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,H=/[+~]/,O=new RegExp("\\\\[\\da-fA-F]{1,6}"+ge+"?|\\\\([^\\r\\n\\f])","g"),P=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},M=function(){V()},R=J(function(e){return!0===e.disabled&&fe(e,"fieldset")},{dir:"parentNode",next:"legend"});try{k.apply(oe=ae.call(ye.childNodes),ye.childNodes),oe[ye.childNodes.length].nodeType}catch(e){k={apply:function(e,t){me.apply(e,ae.call(t))},call:function(e){me.apply(e,ae.call(arguments,1))}}}function I(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(V(e),e=e||T,C)){if(11!==p&&(u=L.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return k.call(n,a),n}else if(f&&(a=f.getElementById(i))&&I.contains(e,a)&&a.id===i)return k.call(n,a),n}else{if(u[2])return k.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&e.getElementsByClassName)return k.apply(n,e.getElementsByClassName(i)),n}if(!(h[t+" "]||d&&d.test(t))){if(c=t,f=e,1===p&&(x.test(t)||m.test(t))){(f=H.test(t)&&U(e.parentNode)||e)==e&&le.scope||((s=e.getAttribute("id"))?s=ce.escapeSelector(s):e.setAttribute("id",s=S)),o=(l=Y(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+Q(l[o]);c=l.join(",")}try{return k.apply(n,f.querySelectorAll(c)),n}catch(e){h(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return re(t.replace(ve,"$1"),e,n,r)}function W(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function F(e){return e[S]=!0,e}function $(e){var t=T.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function B(t){return function(e){return fe(e,"input")&&e.type===t}}function _(t){return function(e){return(fe(e,"input")||fe(e,"button"))&&e.type===t}}function z(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&R(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function X(a){return F(function(o){return o=+o,F(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function U(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}function V(e){var t,n=e?e.ownerDocument||e:ye;return n!=T&&9===n.nodeType&&n.documentElement&&(r=(T=n).documentElement,C=!ce.isXMLDoc(T),i=r.matches||r.webkitMatchesSelector||r.msMatchesSelector,r.msMatchesSelector&&ye!=T&&(t=T.defaultView)&&t.top!==t&&t.addEventListener("unload",M),le.getById=$(function(e){return r.appendChild(e).id=ce.expando,!T.getElementsByName||!T.getElementsByName(ce.expando).length}),le.disconnectedMatch=$(function(e){return i.call(e,"*")}),le.scope=$(function(){return T.querySelectorAll(":scope")}),le.cssHas=$(function(){try{return T.querySelector(":has(*,:jqfake)"),!1}catch(e){return!0}}),le.getById?(b.filter.ID=function(e){var t=e.replace(O,P);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(O,P);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):t.querySelectorAll(e)},b.find.CLASS=function(e,t){if("undefined"!=typeof t.getElementsByClassName&&C)return t.getElementsByClassName(e)},d=[],$(function(e){var t;r.appendChild(e).innerHTML="",e.querySelectorAll("[selected]").length||d.push("\\["+ge+"*(?:value|"+f+")"),e.querySelectorAll("[id~="+S+"-]").length||d.push("~="),e.querySelectorAll("a#"+S+"+*").length||d.push(".#.+[+~]"),e.querySelectorAll(":checked").length||d.push(":checked"),(t=T.createElement("input")).setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),r.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&d.push(":enabled",":disabled"),(t=T.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||d.push("\\["+ge+"*name"+ge+"*="+ge+"*(?:''|\"\")")}),le.cssHas||d.push(":has"),d=d.length&&new RegExp(d.join("|")),l=function(e,t){if(e===t)return a=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!le.sortDetached&&t.compareDocumentPosition(e)===n?e===T||e.ownerDocument==ye&&I.contains(ye,e)?-1:t===T||t.ownerDocument==ye&&I.contains(ye,t)?1:o?se.call(o,e)-se.call(o,t):0:4&n?-1:1)}),T}for(e in I.matches=function(e,t){return I(e,null,null,t)},I.matchesSelector=function(e,t){if(V(e),C&&!h[t+" "]&&(!d||!d.test(t)))try{var n=i.call(e,t);if(n||le.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){h(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(O,P),e[3]=(e[3]||e[4]||e[5]||"").replace(O,P),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||I.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&I.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return D.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&j.test(n)&&(t=Y(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(O,P).toLowerCase();return"*"===e?function(){return!0}:function(e){return fe(e,t)}},CLASS:function(e){var t=s[e+" "];return t||(t=new RegExp("(^|"+ge+")"+e+"("+ge+"|$)"))&&s(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=I.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function T(e,n,r){return v(n)?ce.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?ce.grep(e,function(e){return e===n!==r}):"string"!=typeof n?ce.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(ce.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||k,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:S.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof ce?t[0]:t,ce.merge(this,ce.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:C,!0)),w.test(r[1])&&ce.isPlainObject(t))for(r in t)v(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=C.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):v(e)?void 0!==n.ready?n.ready(e):e(ce):ce.makeArray(e,this)}).prototype=ce.fn,k=ce(C);var E=/^(?:parents|prev(?:Until|All))/,j={children:!0,contents:!0,next:!0,prev:!0};function A(e,t){while((e=e[t])&&1!==e.nodeType);return e}ce.fn.extend({has:function(e){var t=ce(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,Ce=/^$|^module$|\/(?:java|ecma)script/i;xe=C.createDocumentFragment().appendChild(C.createElement("div")),(be=C.createElement("input")).setAttribute("type","radio"),be.setAttribute("checked","checked"),be.setAttribute("name","t"),xe.appendChild(be),le.checkClone=xe.cloneNode(!0).cloneNode(!0).lastChild.checked,xe.innerHTML="",le.noCloneChecked=!!xe.cloneNode(!0).lastChild.defaultValue,xe.innerHTML="",le.option=!!xe.lastChild;var ke={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function Se(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&fe(e,t)?ce.merge([e],n):n}function Ee(e,t){for(var n=0,r=e.length;n",""]);var je=/<|&#?\w+;/;function Ae(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function Re(e,t){return fe(e,"table")&&fe(11!==t.nodeType?t:t.firstChild,"tr")&&ce(e).children("tbody")[0]||e}function Ie(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function We(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Fe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(_.hasData(e)&&(s=_.get(e).events))for(i in _.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),C.head.appendChild(r[0])},abort:function(){i&&i()}}});var Jt,Kt=[],Zt=/(=)\?(?=&|$)|\?\?/;ce.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Kt.pop()||ce.expando+"_"+jt.guid++;return this[e]=!0,e}}),ce.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Zt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Zt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=v(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Zt,"$1"+r):!1!==e.jsonp&&(e.url+=(At.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||ce.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=ie[r],ie[r]=function(){o=arguments},n.always(function(){void 0===i?ce(ie).removeProp(r):ie[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Kt.push(r)),o&&v(i)&&i(o[0]),o=i=void 0}),"script"}),le.createHTMLDocument=((Jt=C.implementation.createHTMLDocument("").body).innerHTML="
",2===Jt.childNodes.length),ce.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(le.createHTMLDocument?((r=(t=C.implementation.createHTMLDocument("")).createElement("base")).href=C.location.href,t.head.appendChild(r)):t=C),o=!n&&[],(i=w.exec(e))?[t.createElement(i[1])]:(i=Ae([e],t,o),o&&o.length&&ce(o).remove(),ce.merge([],i.childNodes)));var r,i,o},ce.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(ce.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},ce.expr.pseudos.animated=function(t){return ce.grep(ce.timers,function(e){return t===e.elem}).length},ce.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=ce.css(e,"position"),c=ce(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=ce.css(e,"top"),u=ce.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),v(t)&&(t=t.call(e,n,ce.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},ce.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){ce.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===ce.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===ce.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=ce(e).offset()).top+=ce.css(e,"borderTopWidth",!0),i.left+=ce.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-ce.css(r,"marginTop",!0),left:t.left-i.left-ce.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===ce.css(e,"position"))e=e.offsetParent;return e||J})}}),ce.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;ce.fn[t]=function(e){return M(this,function(e,t,n){var r;if(y(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),ce.each(["top","left"],function(e,n){ce.cssHooks[n]=Ye(le.pixelPosition,function(e,t){if(t)return t=Ge(e,n),_e.test(t)?ce(e).position()[n]+"px":t})}),ce.each({Height:"height",Width:"width"},function(a,s){ce.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){ce.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return M(this,function(e,t,n){var r;return y(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?ce.css(e,t,i):ce.style(e,t,n,i)},s,n?e:void 0,n)}})}),ce.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){ce.fn[t]=function(e){return this.on(t,e)}}),ce.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.on("mouseenter",e).on("mouseleave",t||e)}}),ce.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){ce.fn[n]=function(e,t){return 0%PY88uxvkXsTa5a_Gz<1VN6dC@X3s2q}CdMJUMN zPcQ>_2>PGRrR$dv+sl_VRW4s<(sXmaZR=o-Aj*x&ao##Qy(b^F*LeG-xAJ$;`9<^cV^(>Y z7)Z5F-zGc#4f#ZQ-Qn?Tc|{r;qASnRFhm*Y%K49Po#%XQPNht(d5b^v^=m7o9@PL} zU)<#6Mq|zBfDO)lqu)QZlow&uVC3aH5J$|Ez%fSdC*G<5;Oh6cdkC2ihA0c+H=N0- zy|nN_o|RARlwal7gS$^hD2bGJYps9JXk>@rH9GQjY7ykC+2<(-b;)a=x0uzxoyZKG z7h=w8U=5{HdXm7Ws`=UNR)q1dSE+#~`?XsBSe-NeJpSTFf0Kj2#$zAOKyluW;vhu)ZvpZ z#iZkxD>5^0uT;1$5m)kFi*&McY)$ib6O0xozMC!=9sBiH$qes>rW`d>QRJ!3{zW=h z$C;PR#)(G8MrRV~5`wf|<107C*cRrQYYuG=2|e!Fcr;(Mt38XDw}jf2%uyM)1R;{| ziJ83dR7q3k5!Ne#_H|2NeD93=J~>69?HCp=BqS7GP9GOlo>n&(EV+9ma7l(`$*V5@ z(ZaVU)`y}=JzR$kE~s2kkd={%Z5sc61TXy=FTPl`VsPf`lgUL1X)VRqcfOTZi)&T+ zIuv4(K4!T4;&Yzh`TuAby|9nsTuVhnzELO-rxE{}mc_iJCOYP;IkYyqGZjL0U_9wm zmgHOUjno5sJB2P!Hly0Ycj%{&omR6sxJCEWe%$`xcMQTxMEjM5HjT*R?sm$`Ey@m|n_kxvwA_j29{<{vrxiL&qD`ooZ)A};hV$foPJ z7S+p%3qvA5%r73;lnfw0cQ^G@35EP)iX&Q=DQU>Y?`@u^QNH-`swmR~WeM#^@0d)K z`P>fEUCN5`=A;b0_cDW0>piE24n^n#)yt0Zv>9AlSGDF}pX=i3;zsG;~{yy@?-Whd(|TSV#-}d`{XTsU|RmWYM~x6T;Aa zYPt2+z&D?$=jhxc>str zeZH%AkJ*CtC)lWKsUA;qbJ!$I7wU@Eq)xSTygNx_n zb2a*CSZt}%jQzmo?iT76;YMGfP@z{L<%XM_S$0}}vRpMO^^5UB+KE+K;uC_jN4ail zKiA9T8sI8EahO(vb434M>}o7=Y|jM-z1bUyx_Vmcn(DeEMFCpAMh~*aUt`m>v)uHo zRbyY;6@Am?xgnFi9madj`z6N@?%y)2bRujb8Y1aN1x9rP)s+!`kNr~o^0rxy+#|cp zqL-nTVQi^%L$_03NWa8zrS_!h({$RRQjI))dv)WhQfmH5UXRu@BVI(rt4FApv|TnS zE(~!>bV^=ulnFa`nNJIc8Mz*4FsdMDTBnMCNaSE{TQ4GR&j02_O_$#VXWQWpi_6&b zXp4syB|l%&w0>>mY=8af_k*`tuJ@OmCQK(Z*iW*nu&=RGsadP(Kc7nQO4zr{a&8dJ z6RdJn7xfk!y)$CF@0RdEu&p?xdBI5YVgS#3Ll5JCckhaS8NWABH?%dnVytU?MCdhT zu#&(;sK~%TzhB0h?VPu0oG5Y9anZWIGXv}cqW$W;y@qXurgZ@=>cbJX`u1YCG|D31 z%UQ{dr%ly+ymt2)fBo~TmxId-mkxJT7s;vBkNuw$a6?nl)3+vyM<%Pe$7g>U{T{%v zPn;arn_2HO8J20Q=?`r9^wBc8(O2VLcGph8i_Ow0h1v1Ck=)QzW(uGGHed-jtU%zVEBVc1$eJj`U zwgGQ`VE(kjRWCaKP|L;(lWLy_zQi@CH}rp*f07jH{g^K@b*j>ZyMphy^l`gmQ^)p> zMY8g;K42?lpTK5BYb;s$JgH7O5gU^qb5|#%>t0usaMHUPTcH5wpv#gmwd^%F42J}r4gltQu;jJ?hC*Dve!z_ zrX3MDTPbGe&@w7s+hp1KqxF94lceCULQUMQ{xxh4*Ys=M_Oo{17MwFqI?Q@#^T4M; zhq7*mtB%u7xKWE*OMHaJgFhebudrX4yuui2_@%d2a4&Z}??Ligshw*VuN__Z_-B0n z%)tDcojW-mw(2u|=SF4btyhab7WevRtef(f>6w;9zR@Um&it7D(!FkK-(*Klr$K)t z$F0z=KreShOU4@$Xug;>@geTaWa7)jjYs8=7JB@;-uK$}#xge0w~6n*lYLjaVc*aj ze&o=RD@Pb37bC*xk4O)6-s$e^%k0wWpcFftlpS&9T-8?ioLDl?L!Q!tcRepB#>y8r zOPn+67$koNzT9D*uxj=;8r3b+8aOnVzW!%DA!0Kks%^3D?hS5rPw#%O(m?0cy{JyR z?7X(GDHkNJi6;en{k+*$(Z7;2oz3^z{|P}4Z*e!DU%WdDziKl5`EWKvsljh%&Tm`%WXZWY85w)7cklIv<=o@R zuL)YcKWaHHX`Bmt+mM2f+3$R`w441ad-8bh@$l%|(b?HU*-o;?dk(w4vnyLO_flU4 z7Y3#8j8~b_2hSuKiU{pv@oqnR$Mu(sBC1W-EB$+d*O!uhiLJOlaGo4J7ue|2yCeH& z?{mNu`JYFjk$#L-vcAE7<+EL3Cm zojfdjc%0l>{vn`f?QZF2>*8VS?8Jl;w7BK$=^?}H>1k^vdE4T)$So@?5uRHXLRLHi z0#+707FI$6JXS(NR$?Lo;)1sYZZZF}yp`p@S9kGrb0Dm3WyxplVC`t_1J)=;q3O8{~Ok>|8M*|*vcw8JK8$g+B2zI*g8q`p__n*e?I_ukmkXI2c`M` zb7jJy|JeCoUjF03|C1=JeQf_r6;AH|SOnMxANmF)!H-G;{Vb_y;bE;~>u9avW((F~ z;UFu>FDS~xFU-R)pd%nADJ&`}Aj~T!EG|u$`o9nVyUYJz{QvJxzy|-V)&FTDJ{B(j z+6Ro0yo{>U8r{tQeFOi?xc>v^1g(HUgYN!q{-`hf+l8#1z;C?4LCl`#W`J{@_qbx< zL0B8LUQ~_G5s^_9b=;JyT4?UYCNUhDcnt` z+Z>lSboh+QxogD^6&?2v-oJl~;@N%q-y%veteDFxBtZ}5mG3>FVaGgm=*Ko^_Yy@&x@vzL*wh!AdS{Bd}P=M1R#hCB` zdGP4(UknA)-w#a06n{TFMu`7@V8;CY``<$da`1#VYn+V(u3QA#{-i_NrCv0i8=Liq1jvX-h0 zSijx8Kt&|~3?+0wmRmnAOFKu%@@?Nt&vUwl0Xo4c zR+Ub+ZWY0WcKWuy7q^1yZdI6maSIIH3Jjg}o7~z9o!q8f+4(iO{j2|lmAl}wwS&{5 z+*aL&wS(7K(K6dy6Cmy6SGyLRCeHx@?= zzt8P@+AZRqT3v71b9OaUX~n+(HqhGh9C+rbxVcbKTU%>$lFxK#ejnOv!T?k(cKyZH zBa`uFvcrBO{VAn3#+J9w&N!%W)D6_IHf*a}Nzt}@aYfDRFuQ9$`E%(@Nph%-|F5mR z<$zibB7~B0^xC(L(3tRe&a-^_?6OJ;pHEwlPlnX-#)}kfHa=(F!ipN&hP?I4aBd-+ z*g3mUg{v(P8}>MED}eQmNDV6M;{Vfk}2H%e0q%|FIHWT>V^2GrAe_@NM<&h zW6Pu)l^lL|MI&I0gz;;62*Q5?u$eNUp`qcMBD0orc!GVVpQXo>QZBFQ@rrY_uZ&~= zj268_m)nVLp&$DygQW4(Y0N0T+K#p)C%*gg?W{%*=UHx74(l+#g6_+Dc7i>H%~pcf zj~qEdJ|uyNpM$n$#9*c8oO*GJzH_F8Qli)<51yudmP3d^bq%Mdh2OvXN^UsIpm1Z6 z20@rXPkNGu1^ZUv+fN&Ei|)Macyh?QMRnlLt5Q89=YTrfg`tY1;q!?28Ih4tg-8kMIpZI#DOL4Ng>nBTX<|FI* zVeEoYh1=xB&5IOc-QWe{A8R&7j7_vngC5w->k8g}lDV{%@>YU&qxAJVn<`a5Z_WfK zx#dShNd22@9C=+gq^}IK-RMrr_0*vF6I;q1V7ag`^QU?vl)LbbRP{lo0bupcaHZ!B zwqd4r|EE#B61&Fxitlf{k{Q(LC^2CRY!wVz|H=qm<%|YD*R{;qt{Z~7!)%=~PvRLz z)(cjj1>n6@%3719ymx+HM}C0eDR#|v=gomoN7yY#cxR4mZP%CWR_P3sm;lX~uP#gc z2x1YXqLW)?H83`VQCXP5j5ZJ8=W#gvk;wU2)tc5bE21o| z?W29T{(RM{vfOI_XxQc=j~s#=s0W@-XlU8YG|loR^NxO|D$Re_V6f8+&S=Jav)rXU zO738qCW!bz;oG;a4$=t$PoqZIZ7ueF>TKM(=KHgX3$Mc;c1y!;B7{??=X1A-;B^yg zj=U0!I@HI^MX0TS6$(nzS?_|dt(Z*TGu7XV@^`FtJ!G@TO-&~c zL&~b}?3yh3O+#OQrseV}a#bU4`?^xM$!}8S!qgPtwM_MA&Yr!oWVPHLzUr!yt*YIE z)Bp0pMyLO+*4vwrJ5Zt`pSVtO|F|34KYQ9e%gt1)cK2RvZSwW(;GXZ_zkC1EKqw9Y z84QqVWIy(#E=C}j$Myvo-&)=uA644q_LXf}1mT|rU0>a@mDF@?`x#HR5M7E-Y_i&~ zNl&-HN68VHzXJm#>gekemq}3z`W8K6|LT?sfS0arV>H*?$i<}{NP$Vi1!lQqROV@? z0kwAJRc6;-rCN2)IEx4iJ9q|@*8gTg5MuMS*>2A!o!OO?ALRME3RqSD_c^om$GH|J zx~t%Phu-f-v`5c!sZJg0lTAud0RbQ)IZbtVv3MWkrb7=6~Ir% z*oZ%_fR~34e?Ll=E2m4DYc9x8#F2_SN@6U61VQd=vuow_%swR_q373}ec!QBDJ6xU zLD9y2nuwB*7%?X;FnD{b<+*^R>}~0lgZ3gy*l(3$d0BfjY@NGwfJTRI0Dkx6KJC)= z!wN6nslPNjD-|dsGivzmj@BdK!UwouG2dFA{8Cz4$`~z>iq&CL0cg?f+LG2HtrhRX>CW_$rQ#OvMM6=~& zmKlo7Q*n2f4Wfd0e809!u~g3_KV`f!z&3krue~Kz4zD-5VW^3*P=JURot2e!*t*4= ztu;1N2QM4$Xk0XI%3FT`L7rufj7!cJrDW8;blvKZG~9W+y}f-k@&zdxDlzAKzzhVU zlmko}s`ar?ru}p}*Pk_b)G|#L)e(VvGG%~c=9X?Bv$kZzT3OF)2qv_gI&rKL1%dFb?tH8*YJZcz6-nf%Z;8_2#;Wr?3_xN&uh z&aR-=K%z)lX|}W{NFur^pejB6{9FWyk0U8kFJM&WG%V4u62v;ApmL^j?PWu0VRZb& z4`Imqe~vY34?shmy!rOzwMe7UVEj;pd#WTXZhsyY?-*kXHWTc~X8UrypErDT^ffkR zet{E~eRxTuNoSs6Fiovm4O^_YeOoknpDUh}`068I>5zR-QJpaRFtcr3l#Z)QM#Fm< z)(M*-z;%R@WsdD#-S#ohu!Q8`56e#_W=K}1TYSGKA8=s>F^S|8%sVvHS$X>z8Q<2~ zI_FaDK!3v2u(aAP(s_n5`MoE(0W;*}Qj)SIR!!KDtLnsG!6r?=43yfR`ZB(Rf7jDP zx0UM@tOlx`#zLUOr17T=aJB0jPaY_4h!)6_h;Oa+AF1};sLDXVs^p3TwwA3N9p`(k zTR2z~#`k5;)ZvKOIvoi+C1y&_?-_B8&ndfekJZp{M}vol$IJlWejxe4O8^D&7Ff4^ z@zz^>lt*EduICO*$msUU4kSoS-^1v{k7#;E0T{^4OuGe3I*(M_3~Mpvf}^UWOecuU z^#t}sJH6L-73@rZ;*Yzbg*Wv+0a7CBF%veeYBVeKc&VTSSfX6DC#uTAuw<&@(w+fGhx9u|is13^MR6cCKE zG1!4_%Jg2B>V#!bT?5Wp$FR3~0pc2>@EXG7dW5598#gv;eY+N)QsF*5GT%m|bqVI3 zss4>i5}eAw>U8wJJm`h>p6R+zTgY=8+joy>NSl~ zP((2I${^ZxV2g_x>1H%obGGY`=h_AMa$G<$S z8rDjKdMYhVL{v28g)*yCyg-Re(vSN!c{)8c{R||Kagch(rew-YocbnquxTJXL2Zf$ z5F}|xB25$KnHLzBEl5uI;@NVT6C~19{PiH^^lpR&sV!!#V!f`HYtJagvU(bf;!8jW z`)$c2hlq%fLlJz13Qx-mB23> zfpKgnP7os}V$k~0-rhc#)pEb(Yi`#3d0hsdk0_tWzOw(MF93(4o>K989b_>hbsjSv z@1)4@)53yEfIHUG3A?dTC;de8p7<~5+O~^&Q^GtST^Y6;pb1)%4Ps>~gVEq-zzpAr z69?`>>_~@9(T_Qy`bG!c(#=Ra@6nu>`+Ec%TdYKj3hQx^Zw;!f_Nh>(=!>D@c^dQP zJObS?5fKy4_NvQ0>YS=&xn3J4p*B@R0{Z6Qy(+LONV* z-CPY5abKSY$NdU-^jDtPkf~pW%OIF^o51kV4Gao&Dk))G)99#+#_z*S4yAuOh}{3S zJlSkn>gg#Lx9ZeBC2TRu73=BfhAJI%zE{_)cs|lJiZ#7SQ^=(5^%2 zuFX7UtdNsBGZ^d(uY^0eCP74-dCI{^YZa z;M6V@OrBLid5TkM1TMo8)oz6)a+nmURo^ULUs7Xtx4wM&Q;_#j3Swe}v;)wV!}o1p zAhu2U_R-f)z0S}Fs3NTQy1C(6xActPtK+fQ)%?_E6IlR1lLdNyDATCLLz;s#GH--- z6yy%BNul{OihI$Yvex{Xb~|z(p*Uko@>t&1!GV76%PnrUWbEu{UQ<=j zCxW8upHBLT`1U%-%#L!aWc$4B`v&`e3u`388mB`}?GLlev_Qo^>ot0wuNM~q@g2Df z4HVt7oujm`23coX6q#K$z_ngUk%ygx{V2fa{xL`V^mOW$%@*LCe4kH6Q1|?H0XBgI zD=^J*?1|J_(9Dp8xiB?i-UYCOW-!iHYJ2sLcRAedjId76b@Vq_+E>D0`=rE3SSZB6z38ZsiT-@R zigXk!AUGnL)R2tkd2KK(JLoC-wI_Pc?KdS8DG?JPN;hxZ=rlV!;~X9U9&jzER_5;n zO-K`Ts&;tkwgfvyMMXsRN~K6B#TLK{?dT4K_K&(>k*6ZTir?;h~& z!^3pu<)974eABw1L}}d7rWhRv!@28?Lo`5}=~(UntC~ zE*9)*pDauja~@$>pmcdDP6SZb*XL7w>giq?>y6HJY66rkN~-$3&x5F>B4~PXeH2iE zqe`g$2{Tgv3>r7qw#-rGCfZjFH*OQ67OJ?#LukoWywHl(iGuKJ2BUJMNM2CDwsSf< zI*hI0a$1(j@r?qvL6jZSPoD$xVp`r^YiQx+;i>sDPr`8zK`v+IMVd~9Fx@QXvoU@&F^as$I|k`3E06CHf|MW!ax zZAc^Z95}3?5^J*JTD!&_&NnlhleqqfU_eM?de+`~C=g;|8#r#{nDLs`O3%O);G84# z{rmMKrNrp3UbY=0vBx4~&|O~sWORL?O8bdJe3)=HI$(jqN#(E&#P;3b3`q~So z^R;=cf(0F}j)|W`ZRzXR_^_?t&>xH|7ecFzdRN0LuX*v~Pxo}Nb_3_2KWOR=i7Zl2 z%?Tiw`2G=_3j4{O8Gzu%3YfqIwZ)2bJD^~s_56KoEvlprf_L%X2PNgelcs@#pjS)O z;Yq5YVMAYnCcOq7o&t4d$JYz7pWRV_s9!8jeB&Jp4QN&qvCRZ}ap(D=KbT)Z=>q->xat3%QT@DSci}z56?0c!eepnPZ!O-y6dIIzw zRgtt=6q0n&0U_!nCMKRdJ2RnF%f3*f(a>H_psR#g5+X@$nrv2PbhfiI`U7l0bE{}> ze+O6eOn{_l*FHE#A4Lx&BmZ@9k=+0z!{!44xf=Zb4)+jU{S^e$t9$e2?h%#JFMCzG zo@1_MM_{b^2^|KXoA87!$7@kYY7xT9Vq#CeS#VC+R$5fi($2YO7mIdDU?lMeBQ)g* z3ut;yRcKhZukB4LPEUlG>Ow%_V&9WfK`S5>v{An|Kf%^Hk3PVjh~77Y5#N%Dppsb3 zJ@(}DsETAM=Wv!?#6C*)u1?^XD}0&c@6RPDB7rgyNab=s8^vhi94PVHMV3 z^8(&vU_6Wo5DWxVPO%p!pBdbLGl=FW)bf^tjC#z27gSCi4ay>qFuzz`zlJ8R0pb15shjkD3Sv4`H(L=dD&2#ynses*z{h#>KDsOQ!6y6 zA_`fVHdPHCHJseEXWMt~4qk<>(%7@ox!yXJj`8QC*{%h1Lj?U(pYE+xhmeOoNfp#U z4mIg7=(xQ6ike~K`>PPi|2pXTvB_;{K76eoy(U(K4oJQvOoPZ{bxgIE1XP5cb~xt# z4(M@2B}x$m)w*tsQ*>rRNa`99F`^7OnrxNHXSZ1XEFbq*K*#48{qlR2&2EDKv3irD zt>6TMKXce`&}4XB?w$QtHVR*Aq;z{qu3*2B$*r)AQy|QR08|PrHH`*lmc~i{?BML+ z0IOvn-HZ$x$SMOB4-?7X_f4^34b;Hq%=)N4X}LsjYWc@(g5`T_Ws$s++L+9y{?fJ%Ltd}IX^>@wF+cRM~x(ksc zJ`Ci|8l5SwhgofHjP2)gdQJTuBc(zn`}m z2FDnbjNd7&4J)p231DXh%vE!rUz=6wZc;T%kg{pJ?!@>Y9AUZzeR|z!F5VI5oB-A4 zqS)ENc`yhJW^nLFUc8ayyPU~1(Rs<&@NrdPg-E3Aao} zLG!A{07Ez>?EG`7TBY9bmm5~EoLiEGrb~der~f)a(7jo0sNK{xHOVzQI*qcX6|)iu zX;Yq_^Bt{;&Z^bT=AGD;t$Z(Wl+fC#@XqzhSJ^M0+Z9w`nH&J>t+6RwZWys;6f-M z@KcI9ICkQt7qM|ggD=}g!Uh{$$4C)=55Xs@j!DZLSb+qOU_U*mWAwbq9zd2!L#@Fs zXI*d%63b$MbN&`d|BmgZK)&D0d?Pu{bw1faZlg~jc_zLPC6TsVcnXF>AN~et<`I%s z4@E%4TIPnTcK2Nl)J0D-M^?YnV61wjQX;p^9XORuW&f#16ug|6qmIR=!ygKW#>&7G zCy6n{@ye{S4zI>LQXEyY2GNiwPDEb0v-XL$-FCz#D9=8w;CuU7Gd+#q9tILjJ&e&E zD7mi3rOtYc(QAkEA}Mkp|Lt1=HcMG%6CWd;z|s|wtIVy8o$RQ@u@-8 z%LmqM0)TIY?DDcJIs1WFVJDYI@^_gLPR-^xo|Ujw*QI%=fI0HBH-rj_mM;DK{osyjqyCj4Jig`o8-o6aG)Geka^>Hzj~~vvlpa;W1N{K62oroE0voQ zbv>i_WfjlWOh{WfDWIbg`_)UeBPXPa^bj?Xe7fLNG!_mzE)~;jkiv1zW^Fg$xd21w z&0o%|5Ac49{!(Zl;Jnzw*vGK?$b=YG3xZ)0YY6obS~Jp?qu)`@9<_j zl^q}rc=Dl?C81pL%`lHX^}8Px5KVI^^EjHHmz|l*E4Iu)Lqx>Bn!4H6mx;>lX6|v|e=SK1k3L8;8`y{>GA;ZT8t8&vQ7ulbTYuj5x<7w&yK6tP2s znfT6Y(X!*iW^VD5@itM#?s9Wcj4)5ajHat|3CbMyoS#Jz@6Hfzb+0P*xY;&IJtOym zqn=>rd6iBwZ^{!Cz}T&@Eevb;&na-sePwV=vu)f}&%NG-A5${PAOV->yXsM+0-C2m zWQvxS6N+$97gZ#X%!kU1*>Tvt%CmnuD4Vfx%s3cMsVybVo<;G|xeudFgNnfW$BE?c zDzhHL#h&1sANe*3i%^P^@nx{SqrPvqFed|ls_*$s9uAnJLK=U*X-1q^rQ6E`@56NqBA>+% ziQ)Z1_IIVIUgwETyeC2$Kkxq(ly9F9tS6R7r~!Nnas`x}1&YzU7Nsv(xg+Q|x^b>c zEO_n2)qNuK$6=5IE9o`-Zbiqs?ogZ}#yq=r@>JayBR2GyJ7pgA2gQ#)HOZB;&Dg{t+}QjFvBZTw}Kgj zqU-K%VbrJ$wJ9lb{=Hpy?m@xZci+~+Ni(XELn9lwAU$XN)Sl$`WG`drc4-gPTxrs)*Yxa z!B&4@AU%Htp>}eP&lHTnlOW7i`o@uk_yL*i$$?gL~j!4XUub8q?gwVlI9I1* z-u-ka>dVtpseHBp%!4^_;0mBcZB!;(t`fZW$#a2?Rpx6SN-4akjVSczmLA@X+?fX387~0d7j6b163QM$%Mx59sJg%$$*qQAwijwW3fUE<#=(9oT%H>%dS?-eBy4SR^-=!&u;Y8Zcxah7 zL7p|TRG>THp4JQ6SHScVJp{;6K5g&2&j>xOVhJe1N2wIIp)TmEG12?!gESPl5g@ z!kvd}FO?Mx|A`|)&c~>gU6|TX>G<^_`2Z0*TxB$sTO~)fmIrR3^%hV$b+p!vjG-gr zMzxlu4&Mt$pt0>-$Vz)0M4&MHA=`WAxZHk?F&T1yD4oID#pco*8st7E3hl_Hmj;@} zJMB)B|D&CX-3aWcAvL7OMBezl4Si+F!M+#Jv_l0&Tp$0h^lff@j|pozfqZ+$OV zlV!@QnCdAIV%}KQ)+j6OnZT`4132|=unFFNBkE-D-3-Sy7K9r_DuN+Zhl#@wrm--A zn{Y@+@hVl!sRJ+Ul6LcGC@EC2%>Ijy9VoHN;Tx2(4O#utL|Fv#tDGVS!8$@@a ze76ldYC)|AlDVBPV~mBCGt9=+8*cn@LQ|beYd9kM))1C8oxv(c?qC6xS|PrSse2n8po z1a0l*WJZUt!L2qIfVQyQS`YQ$wGhv5Th-8JUnFm8A)=eR*3lFqb%gjKTp-cT=}N$= z33l=x*+p+R@z-wGMcSiD5$w&gM;~6O+;+*nR8NhNK6}#G9+UY}xLubRxo6!fROQhu z(_UqbAeo=MAbSWg2eV*mAE3j=eDx>?fKs|{qhZ9jWf2YRk3HKx8a--D4QcKNVH50l zzk1nGr2h5RQ2RNpYsQg;5kO_;suwjWDRRlGrT!r1V!J*pWHA{nO9k~~RFx`=MUOa-lfDMGDL@c~>+TOB6x7h1>C!TJ|k(wTi z+>*?>Im|{Jt@~xn_C^~^@#d*0@1B$13A;`qV@z5;QxN(ea%kdoTCZx*e>L1pW)g5uoV}s_tAQDGPrT_#Nr~h! zCunJp1nl74XO1D}zj&(FTs_y0$&(=JH-oa9aN-nXV5%yt$3(Cvn$gy_QqK)zqqV#& z9+dY9?5ORs)X{r)5hi(ui=sZnm}v+BEOS|@a#L|o*f?z^v)4^9L840hd1ddzabyn# z0Sou`390VYp>rtPuFW{%Ee7%rkkK*SUW`+V6hjwLZrfOkHIrUI`91$1M9v7g5M!=^ zW3&9K(|7K;#>0TJar{5n2rDJ=MqCau!7U+=HS5*uraR=wX|jKlxM#KKgF=lt>+T>! z`<>BC`z>%$w&t?bkw}E}5J5zqC0u^^}lwar+Rev2)fLsbXAQ>osUZi7=!t5$_3#^Ui75Iu+Fbe0TvWV z_^slGx1R&vf#F_euj_OnW-dCs9+HZ5BP*sccBd0 zFK;B?by6-648;8BQRrVktHNI(Ov?Y{bw(W_ef+kXT%13#r>I>uG`V<^{22Zw?D1DF z-)KRj=)8Ax{j;2l*K@O~m1C{1g!(r)QswZ~tq_~IEQ^;>f}Q`kCM3(MQn4-JKJIij z1Kdy^NR?qdzD?oKoGPMu_6U#Pju;LI2_%RXs=2EtrMYY5YPCLR4rhq&C>OY(8=tMC z8>iDUp?Lu#x89+`7{}V(o*-1+#1wG`Q~`f7c{$>GH#wR?>}Dje2XYbH9PMK;0?o-xz<^PXtz zIRq-I=~6iB(;Te~3^68-;Ll=Br{a|`nR5=xaN22^^jmo`0}TyLi2vvv&~QjnS@`DN zn=!02VHaX6w*sBNKMXd^EvlVUXni5x01dhm&`^e){d<{H@HZZ!%H`dr?bcI8-%0Q; z$@>96FK}HLzvYBHK1LYSl4#>K^Yhf!I%6<@U_gS?UYEjNZ{sO+Q_yE%VkRugkN0a7 zaym#88|J_LE2+IF(fMZ4mFcbuM@0TJDiKZjl))c`-DkZktQEgomvGE{2(DI74VshS zQvHllxksSFG+H+&QFi}vf`M+nv8Tjg^5@AQD542On9xYBf8BUUkoi~T)9legvJr*iFbaO9?LQsvs~M3ubBlF80{SzdIHP$Qs=4i%K)yZN7aAmA!BVZ-$WRJHjLDesJx?n>FhH?#wMpATICXtE*yb#KR{y^`(3o1)#B1KZ0|e9xwrs^yR|^lJ$S^)0Hpa{Wlh0bHC`TCK|` z-|g?ZHsugyBMyP@Bj?xms(jYvxMa7cO4feNwL{j1(2Ee(wQEjMA71P8f^}ELdXsZB z>elE{4;MHO^O!P&%Eu)P=k&Dh$d>=|2wa+_W?lQq8G9x3uV zU;HPiQrv>@QrX@nb;0MkKAtu=c+0%zcDF91cgRCh0>5td6V0kNfw0rXDEs!V(o@&n zfn7>E0=plL^6+W^A^som0e7G`%q9B|JLT}l}e1g|;uy-Om zb0)r_B4r`)hwNFz zbr_EFuvm?VZm=fHzWRm=(vBd=(-HVbW`YYdLe4Uc=u`A2AXK`J{!(j;Pj`j{6{cjK z-nKndv`2!FqOlqwT5k3<9y+FwB~05pT5J2Bunr0m)YKrS>CNEZdz0NZud}4l{N}VL zYLld&C-`u^Xupi^yr7qwDr4<7C5TX-iNGE;J)WwrE^xvj_SG>oy2~@c6edK_& zNtXJAPU1u-g%v|%et74zI@r_J$0O5Crze0x3b>I5Qtx*@H;}a-hKr*)7T$I|8DT|I@ppHn1SP1BvP&%?u4K$HkiCpLq{TVEIy zHs97j5%xYAx($}EMtM~S5rvt+ry9`$4XBvVuj?RTY0&htgKn9zQhjXJxL@NdA;JO* z1XANbYM39#8cWN8x=s4ur_S+Jk-%qXxpa5FB@J#8e2`W1W_DG)Y*l7(Y&yoAb2%MS&Hz|EX zvvD*oI1}M(DUImf9^8dF7#bI`GW;WTK)I@uFUC>X@epVsjrdQ~t?pTQx%pqwtr?+P zqbz~iBr#=l0wq+3ZAPg)K&e=G;f_n89g8re>UWH5qD;X6Itgyppp&Y6_RM})2kxsu zPAC7HU@~E#1S;v_@i-durRP5Yu{}|42`c^c`{${RoGL!BLG$<&36<{2oBQMlr!;|9 zM)%pRbvcKT)rT`q2cfqo4-3@u8`VI0poVqT0K7#VN|Tx(BlaDtb=!&eq9|7mUIh&? ztf(Rm5cX}+QSKUl(HVa@R?K}Dnnr|l=`_1Eo{=|4QBB{JzaOQ;PoP8AV1MSgYIc{-6Q1UelsEI7uP+o2 zE)I=${jV2~*TB3NpZX6A692g#?}9P|hpC|ATlT5L1s*-#%@nVf$M2s^xiJUo;S?vx zLBI)56`#lZ{N+o<7L*^CzcX$kr+J6=sgTD6_aFYszpjo#yQ~xhz8_n;*Y-%44Dzs~HAJY`j?f)P z?e@S^pn*BR{5xxJ_D^fMH7Oc(bz3Gf;D)2*<_#)%_i+yY@xdSzYgz;psvGE!_%sPaoI%Nl6UcwxT;+u%g+_<4Cqh2;u-*;; zMakM$`&0`(K_EUTnHNm<1$yp8=68f&h^YMSxvZkz?s6|>&{ZC7C@35bk|8``h!oT5NJ-a6k1;-W#XT-3Yzy{))hki6$aN90L|w$zvjna2clNk3XD zSieWVt|hJofR5D_kx>-QVR;B0$2O zi>M@#M+Cu#C8kUe*S{GfmWLB53Y`I{vuxl6!f5ij=lRB%!Y(`fh%w)NKIKdhjE0~Ut??~9N_IL~2aU6j32`Rk zc>PqJZaNOuL1ldv8i$`IUK*kUstI*f z#K+5gP?lfhO#mnP*F)2*aMgiNn9&8;Rq1BS&AWhtB)o>c&_>sk9_ks2r&e}>;UYrz z)w=|j1cr|hhoXH+A3~B+9nlDF^NCx$2%N6!qOxC<6eprS`lac4TcX}P?4h1e{h59$ ze1C4<3FirGt=x{#m2S9t7B2N2305UW7SC+1%So3b)UYlg9@>v>;TOvvqm3A!>x50f zMS%O{&Om*vl&}U@!02pFE2M!$!g+-Xv9GRxJt6dj7*@IH0}XskFG1FMeQ!!z!SHw`t*eb;%u&L)102Z5^vZl?A&xk-Vi{vX7o~+` z4|Eg-bcsHS- zqGBMZC@3jNsFbv*=m80lMnXUlP+AXtj$H_H5b01rkd*GQNChR8Zak#4^j))gp7(t} z_g}cz9}xE5vu9?_%$l{E$AdB6qrAzxrp}HQNZAM{+%)?R5k5^RXPZD- zKQuL^bWtbIx3>I9l4Ed3qjm$Gs5!tPC}gH(IU0d6sTd)LSKNnWg+V>lqt>`+wxjA` zPIDbRA)TFCkNOj%2$#sQ@opQ;_`e2=DnF6pFohL;aCy^a>~T?Qr%%r47`PD8mqrBI zLH|psz>CP09_}86$+wai*ViGtB)nI#e{z}RitD3g)E0j@-8qUA`N}zM(wuNFYG(Kt?a|7tFQ$(p3-U(TFl3mlbA~1LT{+ zi_0h1{&dqS!p4H1O4LVoHjlqz){KYdVGc^`kL<@j-owRhj{iNdq+54mh+a`Y{3B|R zIwPt(Lt;WcA#yd@NEvI=4N5o~h)m;dHq?c~wsbHNHr64@_Tq2&GQHOGK@bgqBidkFq-6462${_Pc>-BV|Q3Kx@M?VPZkAjg`&qBcqTgt&KDvlx91QKkXJe zw00U`L-5!E{IMJT3u~Gv^oTt~s)JJt7L9O2IZ|?>fLVo+?15piL&KsS$fILIaY6Ez zLiZ2%4RkOxh1KR?rQeMPK{N}0J{@eU*B?Q-VrS7oG2>EpvEM;RE^#X%Z>)@7GRPch%>Mq_tO$RjK1G||1X+IXVIe~)j`dPI$bbIl z?^8At!+PlWO1aDt?vs0v-OGR8bBRM81;fq$qE)%MUK#e819fa_2lSA6S;l+_uI`R~ z4yL^Cc1kj&bc*f1ZH#Dw_r_l{!xaz$h*Ubh&h7D zqbB!(EU{0|;D@Gz#Ek(^cjst6e9@J>%`l>$-Nul7O!5j<6tp@*7s3JCyqk6msFZ^zXoAjHN zNWAewVcYYr-Y>I8M{s42IpOB+`&PNRf?;5W94bQ-SQ)9QXif*8Mm4vsNpTmv+26vjru z45Y!8bLC+ud%yKWTieU&d`#3weF%-P4`6FC#RPDrKup}~LukbQDUC!VYay1DgIrhg zl{2X7?D(9WP_@sdt13L}GmZP~!xp+Ga| z%Y$uM8X@5jpQ2?Amu*I~Wrreqd)zHB#tnz+xZw|!js;=#C!l)JtS0|J!^{}rA`y?D z@G#y9wsNUuLiX)HPYyi^GHME{%17fJAgH6Y8~Gw;^!QuipMdiHf(sU{X-*eVF8DfN zlS4h#tQz!`o9YFB{zW&M&_0k!0W!Vuv-Ryb+G80ZHVsAxF2)^7s2zcChhR6<9jZ<6 z$3sBm^~)@gYuiQe`GNa`uVIrYC0zmU@%&{`=Wt|JaXp6O%=MoYy4^kC zX=)qU0fFV6$fQL_tPm!gUt*n`S|&aIcJd}}mB*0bDzmHL!8YnE=EKvq;BLXt^K-&g z`pyo&F$Yz~Czosl#nKZ|oSS@65pFPM!P>K3LT4f`f=Rqx5W!;JjT)lZ*S6*mR)@xi z_8vUQ3?YJ^t3G0I{)M*-`AUjrV>Tl{9;V!cm3s`FRRPs!XuD(MK`$sXoRk6+Vs9=i zO@uChD-dwYGj@l_2mGbH0}c8MzyKT0j~;~DA6NJYhltnWEjxo+7P}N_bCDMgL+*dx zaH1kn47aDucB92aYrUaj?exvn5L_D9acKlpEf!3?y9SZW9$G~VyNj=KsgcOG6u>6K zXz%8;DERX-9dG<`T&O5EWb8ETx*pxdl<|e2nVfAn`4`1-L)JV01nOhv-=C9XjmHrm ze_bD1PBb^@H$%X#Etqn@Hqr#y*oxqoLF+ylCUwbXeS_ffV5I!TNGl{1IORnGONPJ& zpxb}H+Hn|fdmi5Q$%Z9U7@fUn>2R)JdY=}fN0k3Ky>>+c;xrHg!C!)ci;A4fb#!4b zs}?}&PG8&tt}-E$hFKel_~Ou#u7c4D#YnmG@~scMk)UbsLz6^@$2}4aWafz=&GXg* zwHwILcw{G-HLlE*z!{O!wVq$u=*$l_l^JR&OK2@gQ=PZJej5V*X!$UE;uXwh>WEYu z<~|h#^JY(!3<}MW$%mi{pHR>PQIaL2uXY@_m%4Qq9jw6w#$)l z-?;4<^RQ-ZGaa-7>rsRsY%FyXve8|o?_1y?p*GYouF66cX=0RR1MT@sUGZEpWqkDq z!J+X3E;h%M2(QI1@yn}vjHv}L;1%Teobb~pZ(_Mi8PsRTaiwchKeZynv(bSBnS2=C zOh88uyM)&S8C7eej#}VmE6rKB!*+2kZ~4eorxMUEj!RHUj}p3iY=3Sxb#B%Ojw%w~ zaKAeluZH$v1nL6SF=$)rdbznk=l+j@q2?n8`z%06S3X9ygZfOL`_*As>=9|IkM7T)%k(y>r|Z&IFVn z8+-lG4s=3I(L{n^dggVYc(I)%4r5LdarfKi;@n3%rdi$!i1H&6m=tfWyzsI@XWC-i#tr7|r{V9Nh$g8>XZ$(z-V=|h@-e?v-M@AhYGr-E= z801`G>T+S>tUXbFBuBFhw4{1NMGy+PFq@-x>An26P_WRl-(`Vyq8@4E{Ts%Ox2QX+ zf=v36Oi2CIn>P!3Rf*KFQun7rD_qw{gI{*aS{2}ZM@EEHY)Ymn0>v3m5{Xpm*qGh^ z9h2h^;`#Ub;r+*tg>TwWzw>!7dqgZrme$XEFJ)z%v|D_Z==$|{$FpnbF-_2N80VYh zZ1+D@WA~w4-FK@ErA?$1Z-Q4yxXi=H`KuJ`P<N zrKeTbCgl-`Mdl&n(lbE~B6J~Ik*#J+1YL~viaVsA-&ieaqb2v%ht&>G2F=@cWO_8t zEFY9xte20=ygd9!@Am4~bi?#f0ayWLj&9W0?W&aFp|8j6w&RczMi{TFDLT?4f&sIE zvnvYAUUuDS)a0~e`@pKfm$i%43kwV38Tp@9J98fmPG=f)C1&!aG}n4(-@319H}2Na~>I zHfxwn)>Vyo2*hR$DWoWb^<_N80Z)$d4~aObjq{K>3aO$c1LC=K*5<$6Y>2{x$iUk9mtp4ow;r^x@v1<8}aui|gIIl?P$@~C@b5UN=fbqsvh zp#Or=JnQyL(J=SzRD>vvfO`2%OG^YgdSKi0AIKZ+=YA)^#JPIP&_OU_qIg;Rd0KZJ zD-81g0MC?h5kVXe)d?3ble~nG;is(rpT1($i3tu_8bu^9=?Npx#B$%Bx;^j` z5ZJ%nQGIok8q|38Io}8mAce4s1ph}{LO-&b~f z7+R(~`_rU!#0NZ=0Bmyd(e1sRz*7OMCsU7-k-igQA&(tV`!cK7@@Et#wB>=Eno+ro zdtgU1iv}n3VLB z;CKYEL! zZ~zf5`-e^)_Ucv3#G5W$`lF)c_c`n!gJTyByE5}v! zHb95c!vTrMO@KzwBIW$qjT%&WBC@_pfRKS>oyU%o5e=%4bwFUe%bWU}bQtH>A{&FX znG#9mv61Ez-$Qan{YI$28DW{Z2@?ils;rU6k>?;PFjc7P z9dzmspdw$UgZBgJ-n_ajBdYcaUFf#W|J()^RDufY^IOo--yEyU=Kr7oVtD_H_1@bx z1p8|@i?Q3P2Vq!M z4Ippi#|tw?wLd~GC84(5dC_GsCBt+V^kA8Z>mrqpXEt_MAetvT<{n4LAsQg z(feqe%Ns3<`$i%gJ!J5Zy%1~bdH3@a_mdkSz^ZK${=L-edaA5n%o;{ElfI$ncKx4J zf{5%9ZeTmDg5`O*o1@Ds{jHB6f_~9MM&wlo;wc9}ONJjJA}jke~)BKszCXs`{HY}77!F+G+z0_af1=o!}Mb)<{=SDO6myQI6>2W z47mF4Za2O4a4_OS=`;=YA?Uw}A)Zk%V-EKg1=!3i?5{;9eDwAIO$t$Loz8=B-Qj4! zu&z!1*vQk@zCepHKKf5q$f5UUwc$4@w+D+})>qlUdgjND)%|`2NXLRuEz}~;xPYc= zwA#)>-_d09Xkg$Ce&(X zAd=eI$W54HAhO)Y;Q6DM^WHb9Y&Y0-Ny8?i4KiOe5#n;;=T!kU9)v8r(;N_b)Yd$s z{`3}B;TyM>5L%e|SaYWkK~5Ho@C`j(@=|(y8vAf~Q_+9YhI&52&}wqZujM%3YI<;a zbTfn*Ki%F5We))%08lZ@3tQXsnxwj9RKprq>aAmqeB)Q_i11yUyFncQSurJx=y2{7 z$_}bjlkDiPX=MTNd_4j#U<@FplI=@|P{>O0I(zHcWMjt$HOU1#4m59$HwUxy!pJhl zOW5zyOm*00kZ+DgC|%HRO799?LOlas1U8EVpRfvRI|s=X_gmJU zR)D4bVyb4X*O?E`IU9N3(MR$d*+&_MhE343`ME z9av965cz&1BF|09DwjNVEARplR@=oF01~EF2wMPs#P0A^3U!LXZp}2)?8Dt_xqe6i z<*o(BrXD{Cp7f7brmG?fK~}3Y*n^?>5%>Dmkbulrii=Ar{X|xczzLoX3C!$1UUA3- zPm{5%4rTeDL`HL%R_$E2dPaTFw}&tReaKHKu?R2?U}^H*CS&Q-U^`?du>v7)#d^ME zt>m@tn;4pXTz1tzwK`@8coG%7?t=b{HtQ!oUmm~n2Q+NHi~Yn{Ie!Ci82@s82QOv{ z$c)z;8NaGWec8Gh$^Jtn@MXd6a{hqvvEq*PWKX-Ej}KCshi?laDosE}mx=0o+C_Lz ze-RWbdGixc`P^G zcOpbFaAFggsuov;>{#LSK@$@$o~hTNnACo*X}a{WFdhz;JOS!UHJT_sbIeac9t4>d zZ<%p?(uv^iwz|A9UkHh{QD&RT6HB_ytNIk_>N-dpBJa{R2yG;h%fr?bPYv#?g{tv_tb&eEqR$1nL@V_`(92y(-GIeb7W*` zRigt>^;HxT6B~V%U79)N@_tzxG?&yij=U+cm2p+Q#MFnm$&jErV7BlsV~X3B1nfpd z#!%N8z>X(pKQi73wuA|SyH=bNX=+&;J=c7iu>0$zqjouf7uZ0@+#u+wYS zm^G1q2h@t4Q5M#HlUg6ZUB%HZq^XF?06&4c!IGe@?16;fG1x>NL0n6I_3f{MwUL(J zcLOS5K46E36u;as=p!_*Ccb`sCBE%k-VSVc^krdUCig9$lb4!RA@P?S>wK;8cS)K_ zOr5ZtUX@hWA{}N{d|$de{K$cvOVN<0u{(NUdPw1@wOXy97K1%KBu(w+kwtKC>n|BR z(iS#>s8bn;n-~}=W5w^uY}m{MS@{`r*9gE9oMZX7f(fI{yjr9h&VQ2}hAmt5m>&s_ z${ik0ZoJK~awSNyr>8;V4kVfl9H5v~ey*7ga)l$zZ!#8dbiHS7R=P!oYyrFjqpR4D z=o*1*j$*>r&Z9{NIW!`DYea3MG@3Z3wm}NLl$T1J_iELHf|coKw`zU%K{0IO0CA(B zR~R6_g&}YK6>uK&TAP~wkOCGF$bsb74@gv<>Dd1D$8efyQcYb7l6bdh@t15wPeNGQ zNMzg%nQ*j-Cz;Q7?QA$L5L}+%eK`WaYyIsV%e=*zJ(dtsf_Mbv)u)<`mBOr?$@kj5 z4GINSS(ezxc8*i5RrvD9_95tjExbn@u(L+Q*=N)(2EFIQ;JvKmRSU7|i z_z#bVOcbs&K(u`S{=O^7`!E5hieW$*h3eRfCn?q_P_QyS@#=Tzv$xf3C6jq$Rb20j{9oVhIpTkBgmDLH^i-$pocl5%2s6w+1v(p;FjpZb-q>{zd zEp8DU;5nlw4E8SQD%hYha~EBK$K`NG%ls|E3b1(#M)d3GV|VLLCTD1i}qn zZ>N;ZrEZyREG^PO$s8S`q_@>tn-CLgIJtTF*;SsZ)(H{BP&yVPx)%}O+yN|F(|v8n z$!i3jOJPAt38W|)+HSV?HSJ84|7*9mqKwUYF3ZMP!h<3lW}NiQ>iMPPwOd!g<%?f^ z$HT;uTDuC>!4rB~7@p1lqr$ybcdTf^*Y8fneH8j^0=7{~2&jFgAfnKcu~YjbWajOSfuQl|4F zi{9lQgSF9MsGj*=FgsFuBQ{KBF;u5z$|!ehH~(2VjQ_|}TP>Y*IMf$+yYbE@NmV>O z@l19TPv}W&pSOM@06bm+8$|SJsBM~_Mxwf|p#-ruTm+_|1k8hiH6X!ys;ZK6J~6at zBv)X3pmuw=>-|I6AD@c^fmDj+g)dckPoMRISFe3}w;thmYQ47Sd-?AoN1h4>`n==! zUz;uKV&yRx*t`6zUR!Bb(o-FS>K|Sh_Axd$PhE!3ScEWEEGKtjkC0(wPi^#j=tU#+ zLcuncbp9VgAvz+NR%#>PKFqb8-LPEs*{bWwI+1||9=X!+A=WuA*1rm?zv|!6SB$}F zD`(7sTlPAzwhgGx&O=`p&Jk%J_(5!MJg?7}G8`H@#G@AS-afnBMIP9kTo2TZU2h?^ z&-a$)j+}Lj+ez2uI}|kisnkG3c%OdrcGJ-SM?bD92c0>%Qq`#FRSFTTcO@Y1oM&)^VmccQRk^ zxJ_-(UlX*xDWUr&r+NztLBOW_UDxdn1QG~_GI?~IZ(sp^o%gc!TC+a9i6wR#KT()RVKT&Vi;OwGX4h8JOUR>uaP-gBPF|L8s{{oDA%e(u z?`8(zv^1-t@T{Dq?xHjrdigKwSL2*>%(URJ!E)s$z#SBiAzCr30gs#|kn@%LXB{7k zTU|$TDJG02g(QUsQDOt{MNQJcnU?U#6320^i4PeDU=$AHeWlk2YOw=H!+5x6K`bxU z3BwqjhiW@URZS_e#TFugvpd(B&=CKwM}>(1VJc1peaj(J=H7KKNb>#F)V^#?@VnMK z&}w=seZ4D+OTeqnYa&eWTW2>*&bHgCiRjag5ANXl!k62g3QTE4aI`T+MOcy7t;UQj z&VVm^0F??@SLOJY0K+gn7pj@z#bUa~muPy&@Iu%aJZh zyudrnLJ*k&N$R?ltXGfS3>-?rCCTLdkildJ;)^?d`n0L~;ueBUQh_2c5X(SB2yz?Z z0(8&?%FBS&H?Lp6u3P7e3mvQrnT}3gc^w#E+$S91gZ3wgYHqa1BDP<}Eg~sWZ8$^6 zj&28f$R8{&E{^hVtTz(7I3~LpJxQQ#`2w_!AK{Ca^|)e=X-bmx@q{K+*0|F7ono5)G^;a?jN) zt7WBuqQiv6j&3Wb?qFuDJ9weQe&zDG^4`pNKArfvYUg!voK7g7x(pu>;kKCjo8SJt zWWop<0@wg4;pxF|ZC1|tcl79VO~0~Ls#0zU&qo3u^cc{O#+(96LV`Hn z&XO_b0n!7aY2d!aN4uN8-~3Cfc#n#K8zs97E&R>IjZ%ODlR5-s=TSNJi@@?;SRwL? zX&$Ko?DR*rv4f@hA+90GStamofCj`i>60xheRo%;`|NOmw!my&V7cA_S0#%!?{$JZ zDdnZ4uIRbyX`t?gxW*8^okr030&Ldd`Z_e0XNG{HXbybLnKH=T51%-tQoVJp0cLlbdu)Bj#mp}V} zn>iZ7KpD3!h{Sn@c6o=x=us4;i>R^$+BS>s{J81$K}w4*o?2e^GCY7dzv1IGA^|}$Hz%gi}XWkEYjgYvXAAM8Vr2lrwE#dw2&A04ZdVj!fQ(!49hd*HA%Tq z^c7c$hlTcwT&nq;G!T|&IaW9LEo@#Kcx45ES5~5AqV3hISKH4UMm!Hp08;KEIG%?1 zHpmDFqLqK?KjBWX13Gu`xSAvA9zIQfwdQrj6?n&Vfs8hrQhOBGN((xO|B})E7L5$$ z17?%nD$2|S-iS!(utV7ax`#V~5_lBU`?eX?mdao`xi10PRyXe9ojFq-nZA{-ru3M{ zVw^3E?oJTv;XPkNGSx~*DV84KWWLQw+=8<$a_($R6#o8Us)760!G+aq6eAb}vfex3 z0KbLtyJ%2+n$l%>D_y!q9al)?yUhl-pMvsu#cirPk&u=~Y6LmKI#kvM-D!Vy>>Q7E z-QNeu`$98~e7Di)ay$^WZ(IJ@>IdZFYt8WZF2pqqkCz%!WppEvRDnDjpFjNkUhg&` zLUQ=*6;%d8yKBSHn&%MZG+%Ack9uFP;k8Sz=Cf^+saP{U3;^}|p^tdCOPQvD_UwN%;#aB@@%a zw^9C~PiUNK&|sw7B9%y3G_F;B=T<+9c5@5piz5Hkofc5eeW2(i_sV!ke3W+!d&b~Chniu5nfI;R+7^H25)0Jqz~T1O(H6FnZ z4#OxeW>9DLU%h#W*F@gojDYSaSfQ|uzj(%yg;0mK zF4;*#B`AX3f5;(Nzh*ztd2c3hM8=ooJtUj!HRoM*EwhG)UiSVqyDYm!&pcoj*0>!o zx-!*Xw=nD1(bRD%TG81pa;V7kgiN7Fx#c@@jk{RDBviZWx13Y4rvqRPu!Mcja#yT2xx2iOId!Q;$>T-;YBLkLb$Qxk&OgU!0I{4cT zk@%7y9=iqAHdH_fazpBFTfN&ITNCpLRjn(k`&TEwDkScq!E{6ds>IHkr`zwf4jW-@ zlw3UUI>#?@RrLmpq z9)bKm>y=zeeewIpk4W2rlgA9Goht9dUZ}@!`EWA2^*sY-qa+<6%eND$$Xx-+#Y^25ejSxn3N{;vSoB+_ZQBZ9!yppegZ$I7 zE9Mj2R);>IIn*5+X6qj4d&@q+xX5?0DdyMcH##~x6NhaHy6rUN(Bq<_-CM^xR}LC@ z6b8nmkB^1kD3z5$)?~PLz*vxNvd0pVUha1EjQZ+D+dMi<+;(ksg~VxL7u28i^+0{= zq>trFH)4tZQ@!_GML~V&%@R$Dr@_*PZ()*aY7R^Z8|bmdEH8#_%Z6tbtpvrnr|Yac z(wF-G#;upiSWv$ZW1FUx+H-X85mSF}=bE=_U7KrZvhQN#&|K2ms%c=K=eg$8=Rv$p zGPlzXvvih$?M2p0-DzD9V$|`ub-tWEV8S#PcJQ@rQ0_iPE_hL8*?i6--Gj$?_oUPC zfkM5>URQj!{Z(M;GnQ_3P7$!vqHKGm8fV);o$>Ex*R*BC6848&cq_E?*-Das&dHoN zkrstb!5-4MIEtbl^ylw0D!f$kq*BF0Ag(?wzJW@GV$WQ+V8hDN5s?SKT}$)NHl0>G z6C>AhJ#j2!y>~@#TZJ30*NIQ5oynN91^J7^q>Xf7z=nPMq^`NT{;jk8%E$B0y63&S z`l$b8&4-I1NyX+THaEUWe3P`(mo`%fQHfh^pZ8LKrKtyPG~(joYS~}05E|R8?`AmS zJEoN^hEEX1$ARW!`;0vTReszigr=9QwXLkIbb&x-$smwdRhGh!m1DYI9%GfsNk&t| zy2k;R$RV+=yi*kgxmA)Et6I<13{16wM0UZ1G-1Xx=RwWv3s6%tm8iEruT?SgX*k?n zv9}a_NV3CmIg_hh>g;Omn` zWo146`wG^$sajk~zVA2^EEW$vDdmIen!d5APM`zR26f`VpR<5uAph#@6e2Fti z_FP3_y}-Fp?sw*Kb=oR%8gUI&C5Js_Ys!#$_7-+DRi6>>U+3*##=csJvi|n@F}oiL zK_t{SbgBJ5V^w-B%tVNgQNKNTs8}*kMQ&&KhCd$x!Y(K(CG~r5X4b3Bf90hmCDl}W zsQ;c#giIwb+PL_hIf^xS`twRlR6F-lZqx=X{MDE6s34oxZpGv%zYMl=Q zX(v^H@H8mCQCvRRay7r3ABvkigndH#$FH>B`jOximnhSBMfSi7tFgg~rbrA#&Hb~K zw6?W5xNa|%mS9tT;9|>X@3S>0aK%&f*eOe=FCt7l>avJXwh4chu-kG_twZ%#Fm{98jwwL zocaBujbY=)zfniJxjCVC2D|~(C(1FTBNm-PRcDIArTI8CYuYtf_oHZxu4w2@@8`VI zshVHZR49SjrbmKmNEG$>in|S%$w>%YKlOo?XU9IYjj%#_$seiN(oZAapNSez)~uGC zu`aR**gPp-^GG>)f)rg^y7nlEk0u}G5Q zODlZylY?qm_S&hh?H9ghf?ATCpC@&I&ebGe0uMQSXs`arl(QVbrCdyksy!uXD>Wre5phaAX*qTs)k!bNCwwqW+y4S@ru@!lUXA{pH{$?CvD+`nYw(wunq6!9TQ8TG7i{~9Skx$%%M_ymxZD4t>+$Cb+ zZCbyN@KkrOQDWL6yY>JD_Dvp`J@J9J-h^70Xf~vz>7A@E^89;0d8N%MfBB1xv|Ne% z2J#75^4jTe=JWWod8tM0c7pqJ?tJfyDVYiv+rGv)6<>DP1SZ{)fqa5mu1RvptClL~ z-kL`wo47hXl{nIc_=X2ff|&LeT1=D9u6S10s>1g`OI5I+iVA^}UJ|+|QhTJm&!%$v z`~krFWKV7N3bpng>-AkNH;|V-YO5dvM-vemp1r$Phgq=1tsvH8T?!6nm(DCcj?E=F zaUao*yjTM-2$A7#sbL6${uCXKY>WOixJA5e*)Z@;O3foL!kUS{oyYWBH3r=@OcTad z@p)^meb+B_Zl_HDPA>TJFYJL?4rhd%83Mn`@d=eW!amr|bE2B3uKvdKS)g(fDReD9 zMTyL=?^+71Ha5z+;dXm=AC^sq%_Q%`hf}$^x$^=9_;%k2PrhNR*!!#Fj!3{p28_aB zgzA5JCT4SuYP0rKZAI2#=_Y}AP|kg(e|y)v!V9VrdzfSt(u=!u+YxY zUE{T#01vDnN^OO_cpBE`Wl%OxKbzZ2)~iu+gzVXW>uWsEk8cuNyT8v_qvofI18for zyPD5ry^z(LR%5+>$Su{gPnguN+G-J3C>U2S3AWJR;XrJ76HsgT%X!Qtt8wp?G_bHc zUcbkBQeL4#w=vJ*%(pEX{2?UWaz3zdG)IzmT}ddI*{FPIBIl&0DP3+GiS_HhjTOIu~_>EibG_HocX>Z!}jig4(O6-CG8ZYnxeZ*=vmI7K40NTzId ziw(cek&A4XPG7k&+3UEsX5ffdcg<#Rje6pv zhYt^$&wFDcFvYrg+A^&)tUz$pQnf}YGkdsZQsSr#?nF=Bw ziMy)#++X_5Cwzae{@`b5H;cEy5!=|ctdg}3^K{oP7!F=t0MTvXgxu{fAVCTJieEQk z)s$5T1+ELnRHRbX9eC7=GHAOCbxn59=v;H=R- z3*Gt{-F-RrG<{3x-*$#aSa%$}SIYW$$OR*@eH}NRQzh%KS-3pNwqdM2ThwmO#P2Hz zzHpIPDp>Lv?df3MpDw?6r1h z(<%osKFFsmJK{VPK~xqg`tz~oa!W^^3$ZM%GFX*ce1p|<&o3$gPlI?_gz5yeLM~+; zFZ`RC`~Gh^j9g;ivBbRR0_TjheY3#c)jipo;rfwd!uIN zy8QnBDYxB8^`sOa9ATD~S>GB-$(3%)wKugtFtoFxRWwfA^0&8-+nFEi7*I7Yl z)r)58KctCJQfcDCPf{#efw-KaPLfWHlELJ4xaYO9;yo7 zN@=!r%>1C^v4wu`NY(5!r{bc^X`C%0m9tY$%Wp2H@x!l^o1K#R3^bHWd}d#jyduqA zy>WH2(DNVxBO6|2H#nJ5?l0~iH!=QnV!W5dt*=@#)l>3N9ih^>585f4dxbjxxxQ|4 zEqh1T^m|GM#kaEcQ*&^~lme`ee26~)EHHO#LFj}#dDP7tdc3w*MTn}ciPCQ_828XdV78ytH-_GkUXCZU5y z3oM&fPlBhc>91|La5e7k!ob7^!N9M*lW)>*m+w1ammKlF+^1%~!mDDhWTfP{RPEo< z(b3OMdZxxqtA{Y;_TvBUmu(r>rp139h8-XMBQta2{{uxb{rWFTo**N(g literal 0 HcmV?d00001 diff --git a/src/serena/resources/dashboard/serena-icon-16.png b/src/serena/resources/dashboard/serena-icon-16.png new file mode 100644 index 0000000000000000000000000000000000000000..ffb6a5d02e44baa82ccfd4cb901381c031e22cc3 GIT binary patch literal 3208 zcmV;340rR1P)f6Xi@@54ZTQ_E-Enz5K6$1 z03tR-RB%L5k){YTDBysjLy@r}iiH7DvFijGMAUI`6dRUFWUU$Bym{}eS9UO(Z2>7`&z9wUXbV-Il z#&6`Y8GKGQ04S2&F6MJnWNa;Ck|;8QE#r9r;7G||@X{|>%+C|c55>;RS}qbKr-&IQ zTvLXPlM{>K&(BTgi^a?^4mXV>;xX8n8Ce|RasXz}{8imI52H3ZN4bf ze_i~WlJ|C&UW9+{8AKoW!}eExnGFE2re(F+`iE_46#!l90Z_aBhs|Iw0E)7{bq;-T z9=d#9QpDmcXDh4R++0fmpKB>E=%LdZt9g z$j;($`3&Zthxi`{{&gM}5&R^+h%b~yM9Zd3AWW9ETgVfL1(`yIK=_}U_z%PWq}jQa ziQ4!P(3V&Nr6C$XejWfQDiI(Fdt@un?|lo#M+5oIi_w{wo%_#%{(V=tO#a9gB!7-$ zM?^BX5>d|Vn*3S!?g~$*UQipUP zL&zMmg;!4Do9IA%up=Rh?=qPj=x&RGBx1dpI68aT- z2O}^EromdU5o`ssU{5#*j)WJ%$?!5bA1;Eoz?EiTr=n?cd`V|I)p<|3O zju?MT93~aB0<#&j8`F+Cg&D?-VWzQItUA^l>xvDRIYI4MQ`g1<+DyrL=EogS06Xii({|v`U^zjmmKqDIK93(F5q| z^fLNk`gQs{RV`IdRle#b)i%{Ds;|}NsClUI)k@Ub)kf6bsWa4l)YH_rsduU0(?DsM zX@qO!YV6TCtMPOWZH~(v?wpc2hv(eZgf-1HBQ#fN?$aF5oYvCT^3%%Fs?s{6^;Da# z?V+8jy+iwi_M{F~$4y6|vqR^k&SQoO!;_KDsATjprgSxR{dFa}^}2()GkV5)QF?`X z?Rxk03HmJkB>f%wz4}uIItC#I1qQ7Kw+-=zEW;GTU55RJuZ@h2VvIHzbs0S}Rx=JT z&Npr~zH34@aW`3J(qMAU6l2OVO*7qXdf5y%vo}jIt1%lghs_<#1?IcWhb_<+P8LFo z28$a^64R5J!)#@aTGB0pEekEXET35!SjAgyv+B3{Xl-wuZrx~o$A)4PXj5p@WAm%6 znJw40#`fA=@?77!tLJvleQsxN$G6*KchjC~A7a13zSsVPgQJ7Uq0M2^(ZDg$vDWbh zi^d9LZDyT!LOXdmt#&%*^w!zIS?qk+`4<X~g?%562@eae34a)26HyS+zks@6 z$%2*zuOhu7%OdYYnM6sVdZQJi6QY}=U&naIl*dS8tzuWkUW(I*6U24LW8oFzvR(TOpMEs5_rp_~TJ^wNN(wM(bC zZ0;`Z6P^ce2XB(^$}i_nB)KM)Cp}7bP2Qe7nc|*Ok@8f)7E}wKr~0SXrM^xJP1~RL zDLp2=Jp-4Km~m7{5vB?IGPN`FGKaIwvx>8%%bb_(Ts9>N5;bK**^9Ef#WdN^)PTf9 zvR*Qp{o-l7TcBI8wqSIn=gRt3(5j`Y zdRObOE?Pal#&6AmwS={4Ykw%TE-Wv6xh`g1Pmxy9nxe7we(PI{6^cd0H#WFzsN0Cz zDA+i-Y3`<~O&?2mB^OJrODjs>Z{}{k_?699m0x|@lC)*8%%N=0R?Jr6*6Z8cw;d=~ zF3&F?+a9vLa|dHb$&Qyhm+ZVyVOLSNi?B>BD~E ze(8aT1AWbo&CM;EEoH56tE6@EV8X%6-*|u1-NtOIZ>P7H9s-9XhaP{M`0e$>L5F*f zu#U8SXZT%h2eqT56Y5;vIn|ZYCGC#u9zGg)w718lr{jCe@An_mJyvsE<#^c%!il02 zpHAkVoIaIx>gnm^(__6$dheWxJ#(!uyl?Pq(Ao3ne9xWf_v}A;-u3*k3(gmgUSwVD zy5w-FbHIL};|Kd6ItCpEJBJ*Hx-UCj?irppeBz4xmD5+fub#UWaP88_{E^}7QP*$Y zNVp-r$-DXJR{E{yw{vdK+*xxMeYfPE(!GlNn)e%iH2tw%>L5Kn>ODH}V8MesW8ASP zKV|>)e!S=*`C-L`&P4Mg+egPHeJ3wJUif(YN!F8@r^P=j|6Kdbc>FRj6+1Ql zT=e|YubW?}zu5oM?q%Q#m@1m5H?t z$mN7;1XEAmeDzy*{J!7gH(%?z7-LJO-(5PXzW(1oxB(!|St>D`fJ|=&G;r$HD~8DK zeGH$!|7PG|WoGV9H17whbph(}0~rD~AX6nK7n|l^fBrILOgjSB3<3(`yf7(msGf-| zPZz|fvavF?p=$<#RY&fDoDXKt$+ctfGna?U0X^3S)Vqy^m4z`9$mhapz~Osf+D1zP zMKcs|f%LKfL)!$K<}W~JpT7MX%nq_rz@~%&q?ZL4RmQN;Mh0i^zG3+L4-&;z8sb4YV> zUoDNR0y=U6NYt@v3&ZutAHXWa1vnWzjb*Xh&cILw(hCm6>35b)1}0BeAk+Kq^G^)_ zfL>r_W@4C?W6QwG!h}u#R*;^0hDDIP07}!Kv<=Ek!PZJWl7gJfKBjUET5^Ku8ovYm uU4@aEV1@vx30?mqw-L(D^$d&QCISG!=*v*nqpZ&W0000f6Xi@@54ZTQ_E-Enz5K6$1 z03tR-RB%L5k){YTDBysjLy@r}iiH7DvFijGMAUI`6dRUFWUU$Bym{}eS9UO(Z2>7`&z9wUXbV-Il z#&6`Y8GKGQ04S2&F6MJnWNa;Ck|;8QE#r9r;7G||@X{|>%+C|c55>;RS}qbKr-&IQ zTvLXPlM{>K&(BTgi^a?^4mXV>;xX8n8Ce|RasXz}{8imI52H3ZN4bf ze_i~WlJ|C&UW9+{8AKoW!}eExnGFE2re(F+`iE_46#!l90Z_aBhs|Iw0E)7{bq;-T z9=d#9QpDmcXDh4R++0fmpKB>E=%LdZt9g z$j;($`3&Zthxi`{{&gM}5&R^+h%b~yM9Zd3AWW9ETgVfL1(`yIK=_}U_z%PWq}jQa ziQ4!P(3V&Nr6C$XejWfQDiI(Fdt@un?|lo#M+5oIi_w{wo%_#%{(V=tO#a9gB!7-$ zM?^BX5>d|Vn*3S!?g~$*UQipUP zL&zMmg;!4Do9IA%up=Rh?=qPj=x&RGBx1dpI68aT- z2O}^EromdU5o`ssU{5#*j)WJ%$?!5bA1;Eoz?EiTr=n?cd`V|I)p<|3O zju?MT93~aB0<#&j8`F+Cg&D?-VWzQItUA^l>xvDRIYI4MQ`g1<+DyrL=EogS06Xii({|v`U^zjmmKqDIK93(F5q| z^fLNk`gQs{RV`IdRle#b)i%{Ds;|}NsClUI)k@Ub)kf6bsWa4l)YH_rsduU0(?DsM zX@qO!YV6TCtMPOWZH~(v?wpc2hv(eZgf-1HBQ#fN?$aF5oYvCT^3%%Fs?s{6^;Da# z?V+8jy+iwi_M{F~$4y6|vqR^k&SQoO!;_KDsATjprgSxR{dFa}^}2()GkV5)QF?`X z?Rxk03HmJkB>f%wz4}uIItC#I1qQ7Kw+-=zEW;GTU55RJuZ@h2VvIHzbs0S}Rx=JT z&Npr~zH34@aW`3J(qMAU6l2OVO*7qXdf5y%vo}jIt1%lghs_<#1?IcWhb_<+P8LFo z28$a^64R5J!)#@aTGB0pEekEXET35!SjAgyv+B3{Xl-wuZrx~o$A)4PXj5p@WAm%6 znJw40#`fA=@?77!tLJvleQsxN$G6*KchjC~A7a13zSsVPgQJ7Uq0M2^(ZDg$vDWbh zi^d9LZDyT!LOXdmt#&%*^w!zIS?qk+`4<X~g?%562@eae34a)26HyS+zks@6 z$%2*zuOhu7%OdYYnM6sVdZQJi6QY}=U&naIl*dS8tzuWkUW(I*6U24LW8oFzvR(TOpMEs5_rp_~TJ^wNN(wM(bC zZ0;`Z6P^ce2XB(^$}i_nB)KM)Cp}7bP2Qe7nc|*Ok@8f)7E}wKr~0SXrM^xJP1~RL zDLp2=Jp-4Km~m7{5vB?IGPN`FGKaIwvx>8%%bb_(Ts9>N5;bK**^9Ef#WdN^)PTf9 zvR*Qp{o-l7TcBI8wqSIn=gRt3(5j`Y zdRObOE?Pal#&6AmwS={4Ykw%TE-Wv6xh`g1Pmxy9nxe7we(PI{6^cd0H#WFzsN0Cz zDA+i-Y3`<~O&?2mB^OJrODjs>Z{}{k_?699m0x|@lC)*8%%N=0R?Jr6*6Z8cw;d=~ zF3&F?+a9vLa|dHb$&Qyhm+ZVyVOLSNi?B>BD~E ze(8aT1AWbo&CM;EEoH56tE6@EV8X%6-*|u1-NtOIZ>P7H9s-9XhaP{M`0e$>L5F*f zu#U8SXZT%h2eqT56Y5;vIn|ZYCGC#u9zGg)w718lr{jCe@An_mJyvsE<#^c%!il02 zpHAkVoIaIx>gnm^(__6$dheWxJ#(!uyl?Pq(Ao3ne9xWf_v}A;-u3*k3(gmgUSwVD zy5w-FbHIL};|Kd6ItCpEJBJ*Hx-UCj?irppeBz4xmD5+fub#UWaP88_{E^}7QP*$Y zNVp-r$-DXJR{E{yw{vdK+*xxMeYfPE(!GlNn)e%iH2tw%>L5Kn>ODH}V8MesW8ASP zKV|>)e!S=*`C-L`&P4Mg+egPHeJ3wJUif(YN!F8@r^P=j|6Kdbc>FRj6+1Ql zT=e|YubW?}zu5oM?q%v7$;Qyc3*|yEsPW(G91i2Uf>h)u7AU1XzHeI^+OOsN4o?C--+Rt^pZEN|=Q$GR z9JIi3usS)rP+p7eH!;RafO88lW(ihu?!vMQJvo@4x4L^K+Di{yoY3M|QftuvOHB^Q zx_wAqi=Rd$4^>S4hRW&qzXUC7LC+eo?Qnmytr@(5pUhv`U(YIl?w_k<<2=(3EJeQu zKhv~~W(x1de8v}jBfCB0{x3hV*Bu!i95__i*yhz7fcsqad?atdV!2k*K!C&Xzn;LQ z&K~&t{~@52%}7l)JU?B%4}GmepIfu*FZQVh(EnRa8I$BTELyda_YICh!-*Tv`845d zmxCnVM&DItoBd(J0BGc*l0&x5{2!R+Cwd4cU z#1kK=q$HGB4m=4JTKn96@kU`=x-fHg{5T$oasDSR;x4V7?sO|pKv}-{1|%SNtK|OI zcO6qPEvjlZqy*IAP>PPF`LmjV5#p9ey}Fxi7jPCD)Oxf(?uFMwV^Ow(oVj4l61vAi z;QRt{2an+xV?P=IY*D_A9@-QKg zZYSwXPqQ;kpM8sYKE*UwhJyLR!pVbwM}?negm8!b%j|V04Iv4=1GG!G5jy8|)M#0= zXK1Lph~eYXfHTsBGg3L@^PKZ%Sl5apzMg2rv3L(i$&0kpx@4yS0000)lfp6~N}e%oK)-yipL?>+b2bI$vH&iTAi;k%Q03gS$cI=40#$AKHULi{3*%afD`qAov{+ z?`3k6i>{Ox-vY1b3^0l1s+!EqBwDu%@g&7XI#r3ze4^z}j5)|0G z8}sw~Z`!9P-8TI9C&CIEL4cqU4+u=Pyft4MCU9>RUIiGGaKeqwM~{A~f}9_=0q4tvAW zM4wyMFx}h%wkk5aT*U}??yL+~F zww(7k@*+*jPg@VNS4>@5Nh!OZFhqZ<*mEksS+i};*_SlGsi0`0S?>O}xlP{WMF^%EpWj{VA97le zbP8EDvc2z_A-Y=t$h@W2`Bq3ex2TMN#6)sF#Q5v(^wB~LBLyMR!q!K|R!D-2;)9S`cCyQp%GHt{L^&}rjFPe^ zQuX5*p=zLUI$Blcaf-d@>&9fuY69*gBC1sK^dS7ZKj6(Y>v$?B@|2iGC(1UGZ6#wbG0rQM%;lN5ym|O`=BT z)X8zzHM~^Wip5Q|P1ee}hb4zaJt%i+46aEO(GSVGgLlMnTyPc#JV;xv@XJunP|(oq zce#sn@$nzKtPzS*Gr8mgaDAx-pp?y@8;0k2HQ-!l9a2Ry%CM4va;6w3U5#9oT2)x( z4dgmv*Y2qx4EU}$Q*$xqBuKoGnHeNd`mr>Jw6Qv#^NHDi^=xb6;C#4J$GMwiDrp5G zIxINqz*r}BQUt6hdPu!lGgv2BOYrTz|r%pHp*U5=`hrj&d- zJK-A(wF6ptQnczH3!9Ctvm~=Z-wM5zXVVxd3N>LV*BbUIKN;q*;g~?x7uDO;ThzND zwcV)=@)xx99%T<14#m4~2e=?6T#u**yzOlRaZY zi>aD$lMvguQbPHiLeo;LCF*+iBafQ57LsO4Wj|9udO?pwK1zI7+ToB9k};AgvVCg% zabt9d8w})ifGaT6r4UX27byQ!g^X=#qs6!(|8h$6S`AI z-U(96+CCgA=7awB388VPC=(Hncs5hc{J!sv=!f!G-{a~^{bIMgXB}sagxQ64g?EJ+ z^-=m(xeIxLdA~eLeR`pl&=+2YvO#jw?o;Sr*t{<2V13NHbz9>r;gU@@IJ@wMhWa_X zCTl|*w5^t%g&jStobKi|so7+i$;pY(;$8GgknCMq(tH-#m*bq1!jrNShM+N($H3(a?VE2)0=FA(E<-}jUMbzk_xPWMDl-p1Dj#YN}Y z`l?f1zI;aPTq<!6ls#Yc{(u0Zz;~^R#|CIC z*;cG}f-~D<+9cWw4!$34Mzn?_rrA2e!g;EPZTsyeW~h$aj`t3fevEAyZJ#OwEPwDA zvu#QbNkimns4Ajr5u+W*5ti)oMr>ZcXY%#kUc=sr*K0}n$w7(W^n!&}Ux^kl ziz181`GxZ*=hFp2f^kBP!n1dZGmW;8!AWfoS+ldMvZBmmhNFiwr1Klv(XeozNDX*a zhj6=@D%*+vE=@q3U0gB89)|^o4@?7QczT0WUFQ3}FGIBgcrcHQNizxU|genNA?`e^8I(-?Xzo2Qp+Q2w|I7;WH7Bjs$0s?}BWnEuNc9(tau0Tiwe`J4nkI+#HNDlQ0Yjnh0!+@Yy-Z z`0PvQcTVc-q7uLU=2m-v1nW&|p<=hL&f$4<)kT~-q3 z`|?c2K6Z$x8F8o7^*%h!`t>$q&LY@X!_OKPTTW?9+SGH+$O6U=eQ^7i3Ubiw1B z)saO^O>LKj(8J1cBH&| z(i6TwwVIHe9?J7VIpk(&^X<0U%}e{S+edQbV%)@MCXKI-jXwZ@1kMIY@MD!g03gGl z&8>0P2KsOpA1@FB>Enz71$+4twTbUem0(|lizf=l>x^Q5WK-&7;k@guoC}o!*JsIObq5% zR>UG*;ij6`{?Z`sl=$6oIA1sz9267;3W9-rux?-}1qB5#1PX>iC5al6{vqBtM6jf{ zzrbGxG*SL8ShOz=?c>dRW)R`*6M$3V4+uab;jRc*8D}I?M$#DpLrO|XArX=YBuq*Y z34K)f++lRK{QWflZJ*y5&Tcjzm@)h{LfhaA^5kb zKn0`!GZo(ce^msrHo#{mL?$3-Oq{LZng|@q9PNcN!lH@Qfxsw3Ay8RK2uu5O)PZZ9r4)4r=Klup-}L^M z$bV;rSkT1W{ayZNz3_Jnp}dKG6GUtfEUUW@vAOtgTGqJVzR#-nj7Q*%KR5&ihsg2% zi$7kv9RdJQEa_^hnY(L@-1E|BwPKB*zFJiqp7^7&uU_=JOdRDGQ+lrk6Wu4noEfF5 zNmqV#T7N0g^&C<0TGrOTCoKK3q^?oulQ}!f=MvW93W`Qkk4J2k?3$JCm7Ue$V*K5t zFZ_STe9KT;1r4m`_KM8BXUoSDy@3q@gq$dGy$M{P&W4~vD6ZT=#` zU&de745UVgU<>>h_r^*vRoqT0k2#A*R97NB?@LiqFqx9z=YdDT`7{>+W3oGy4^GMr z!^Q#&uQaV^*+n5c8#gu@BSVyGtW>-B-9(OkI1l z2sPkoQpI+)IdT0aZupxosak|?Yq-}azwSlDl?+8@ginBg<;JhqK0mlfN_4t?TR*!> z66)&6rH5=~-Njm#nY{wgFxC>5vX}lGJ(_ty_A@JVHd-O{Pq_ z95~O>ovQwviXCXmDtJAVIDi_V<`w!X`sD1ScUhSd3L6j#HOK~hOAkSy2Auyy0z}uz zZAD4RixSbMKyj|s&TF4Tv=%LXyLq(DQtpZMwc!&HV9vJ7;X^!0{&*dk z^HJu*3ug%>-rEf}ALhWPpIM|2ab|QFJ9A-1Ks}e3wJ@*{;4X3F5;%5z;WI?DLnY*a z9<4e+rD7Z3W1Za}#-xt%{N|GyGp4F}kTn80hiE)){t8W#`8reI{Ui>yRfU4*j4*ko zsF7sxNw*4(rnU-Gt?x7X+swp_OxT;Ii@5chSTfB|d|2t-QX$*ua?H)dA1-a#k9PlP zYWEa)3~^cYffC^CEqn;MQTYNG%~r(3Gy7DgTE=qNy?C>0Vi@?kx1w;NB( zhB(jD%e4}n2-6^!RAMRPU&MO|86~}l*B-$tCq}g_pds-IehTIuS9rL&xg4l;suxUT zQks#&j+n*z?#zqIQ}=~{i=5mFq=o6v^R9@1pU-xw{&68!E3e<4He1z@5>zIgM{$z4eYyP4n>-88b>k?fayL_yrjo;c!zw?o_pfQ@I;? zsqV9^XVe;gpPX&q5vvKBtlFUFizud!JXjuQ+fnAc_CZ4=?S{LCRY9iQR#D*obww4s zmj+xJZT-l`h?Vxei2;XpG?hG5xK=jD=m9#nI_eXa9is z2j=C0q7;M^iw}OfFmid~zAyb@uUDCwoOuIt3oQxx%XXH{pL%HPZ7e*eksydYRKjw) z0l=N734_eFe#6b7cbrp2CAaJb$4HrLrG`v0Z;$~_;&69?JGVymG(FT=pQt#Hd~VR; z{uG_51CnLC+^~hu!BYaA$VQ8|bGGmaH)pLh3hn2Mvw2xVm09HSNUWrcjaRm!>|Gio zv92_mZDr-;gcyOu6bP?)H@>0A9a+*tNR5MKDCs?yjEQ_x<@j(pX1z|av4GR};bgkf f@z%{q?`@v;TXwMhW&hK&_m{4gp=PzZQ}llT5X~l! literal 0 HcmV?d00001 diff --git a/src/serena/resources/dashboard/serena-icon-64.png b/src/serena/resources/dashboard/serena-icon-64.png new file mode 100644 index 0000000000000000000000000000000000000000..28dc7d042e5957bd9106335a0d4790f1abefe524 GIT binary patch literal 5286 zcmbtWXH=8j(tQF*uTlg>q*qA@MT&%8l@<(LkU#=N0>ltnXaS@O0i`Gg1qA6JO`23G z3L>H+O$F)Fdr?3>eDAyNy8pi)bI#0J`|R0g=Es?M3o}C&#`BB-0I(Pv=~`t9C8RxJ&grjjVw(8AeIdP5>M^;^iqTt0E7ksz~VgsP@e+; zzJ6cz=l}pW(9FR z_iPo6AN6x@I^4m^$7Zqu38HmODrJEjl(^Yh)d z8}DpVHG!`ud*AkYpA(FiPgS9c$kj|ue>+>V2$t$CHgUQF+;jhPneUF*;xl1~$Mz2P z=O6QBhQW(Ynhuo3)>egE7!STF#0~GquD&?3Tmqc?6K>ZmvpMvK0cv$rf&nLNlzAaQ zR6o?SZL>SbV>&4Q9KEGiVv>S_LQ(^N#{Gtzw&ifOBbLxjn8>Do+r!wkzIZfaDh=Vz zcUviADIEqKSa#kTGFxAAius_JmPB9Fd?W)=9WAZ!JR@;c8q=%hIQ zL~h00KFjmIg)JJdhJ%?&`L4)*+9dtIx;@j@z;d~U^nQ6+o0<|iY{p>TxhZVT^ zgZl$Xhx`j?c}?6Ie(~M&oc3fGBmtrz?zhz3IUquWkQ}vjJC6#LawXM4gflzqFcnW6 zT`!4EUK>2h%&5gNdS);3ng#WewulEvLW@p?1x$MPL#=^fjYQ{6lNhz&Oxq0FW~V_4 z$?HYT3RxL3$&>m8X;EyA+8P#uT&()Kci7p^>NF1W6wymZOXu1bas1{{JL5>I(4iiL zd2l_B42z!Vq&JByu9PohxzNcx%CHv`HKl~(&!Syu|FvP(KvfZy+_AcG=0GikP9P$? zvxZ(Pj-Cas%gjML9eE(cVRZSm;T6GXBUQ`TT0uu62%d>gyWoC+By&RKvpi;aouoN} zJ|X&=u9p^fo;2JLzFH(OBs(N|n|7DM)IcVeW$=mzWJj97ug2*}0vjsld>wo^7%({V zQ<;}JCgx*@Em9pi^@yfl%>+6R6ma_rD3JI%`(3JRgUUd|tjn=zmouYOD`6|p71b3% zfBr)r!>;1#e%x9EJwMySSeZ9UQv=hqcQ_k@nz@{qe4_R{Yc|IY&W6BRnZl_x$=MP{ zoCJFRyUsdPNQK@99{gC@dkB+P(g^{9DVjUTyl5nw)V5sD#&RIyY*rI5o zwY*{V&8_@dmR^ihb6Q>6J+2$KqY&vsQpLz(J4`}tacwrCnovbZ-LYCU<||9AOQbM6 zh~Jv8?AOnPGU|NHX|S+OlT9P`iS?;)>wd{4!a0ldhkS~ThxqLH#?aNd)ppfZ)$S-m z4|-F|y#DoP>4Rp2F&^s=B_Vh0U)xo?8oztcA?Axuz?1R(jXI6ijaTvObBkNvTk%`3 z=C00OlFH%Q;ilq}=Vq0@Y5B-HUwT}+nv03sTynxDGJPkVDt%b$qV>{^$5z(xJqt6d zi5DU8Ap7Wo>7oZYmIZig^tJS7w_o&G$=-k!{!Rp64|pc=QRXLXhfhgN$y_PdzTCcT zyt#=?j3YiJmi`uDiKNvPvCcEeb8t1dVfEQY!KTJ;yOqc3UM}~Gm*%B5o@Ne)S51N` z{;_-cXsiW!CcI_kvgmuQWE2%ik zRdTfq5v6=E-#;OBALOPYV~{mdMI7(nzLES;)cG^Is^HGuP45|}8FO(SabxjaaW)gQ ziOr+=O#jS3w+noFpNIEuqYHUhZ{g=5|qZr$B22mnB{;g8CR$%oLAaL&yS0bUl}t4kJt^^IkkoK zn|&u^Y&?}+%tFlkYX0+WgMzv98SZZ}aL4E;H2= zbImf-OH=kg$JfPYc&4ov_eLGR!v8B4jB zZFT}vo1>a#TCxv*9&Us-g&-%nTZ2O`RSenpI*d)x9W@{A9l*9mHqEzBGyu*Ymn^t9 z6bBXMlq;3v&T?@NaqWnYsy2uoXvcZS$>m=~$Sca%LJ8o0r7}ey9u142K=NYOR60>G0!HPwrvyau}x=Uw5J!UlN)1o_E5Z_8r$@ zsJm;kONpzSEx0VSYyoTN&ljBF`bKKbq-(r8qsOdgta~+{k`NFFNzR^c!pSs3IMq3C zpPfH@d^TAWEE+BLQheq?UaI*fDloqJDOY-0Sy}`lYAAB(z9OZz1)~t+6Q-+{)+*j| zLyP;^WS7A&+95iRZ;#K4&qqO5!Q9Q*eY5&^^=?f7%6ztAA*6-%CHZB=RU6p=-?g8_ z!rhk_a#)}jnv`#2`zKXedtE<&e1G?SJSF_CLa)sG;1)6Lb(>cFpMs;RasdYllPKfC zna;1+`Y%{RuLbY*No1?*3E4b)<+Z22xc;2}r3Aa~kyiQRlIhauCskJuuV22-y7qcy zdiDJHYUQCvF#%(?I4Up+TSf0wzpfq$&f9a6y=CoGlU!-u;FJHl@R@Jh{2#|dO+=5) zL@~bNcDZ%Q1RNGX3U%Jdnd!(lKlk|Ahqk|A5L-twwF@zGu%! z5(^`XKFg)#4RRtsi~9Iyk1wO6`9p|L%*srZLbATVt6xdW%1>ltWM7uo4nLdu(Xeq) zRo{Sq*Xn9obhY!-pXDk`G9<(OD!z?R_LwaaEv_)TH5}#7@7S%ydo_3 zaQf9P{_sVLoszP_|2YTU7(Wi5#W*`pf>WL-@x}e=L-Y|*vS8Wo47!*vxM#)sp6sL>! zz!(wn=o>^cge%e0RTU+nslmdi9;kN8;Dsh21p~dX-o9#qFyViQ)lU7t#}Hu+bv(*V z%~H?cU&Lty6ZRkwaB2`pKtKREKmqK7cZWb#RaGH!@(_7>*;9n9Z;&?u87S-REAlUa z9@^IxkHHZzKHh?V36U;7egv4XpC1OL=7w}razUY#WL=O7C|M{Jg_K326ri#w1qGC{ z5>!Rr4eBENZ+evLf3xHK@YsKHqg)|qEZPh0P4I<4!O*{&QLbvPcr=pWga42I8|XX# zKmAyYhMtcX#v9`)cnyj1R)_q3#OdLGoKyPKYBDe|s6+lY^FN^fdHKKS(_)@_|1JIB zKK!?RXz$aW1)Mg=p`85*0MM-(>uDnbo#rT>CX^ArHzy0D&eTHr#`Hg+^p)W6Vzr`Y zTl9>%K9$?qc&JIM+RLT+TUuw_wl6R}{v@=A+^>}S>;ZpO>0Fa8W#dt8&4j&tZ3y-L zEd5xY9)qnj;{bYA2iv|rMu$ZSwEY-4fW0W#-Dmjop<{S^0k!~pxF9D)gPIQg-fD8W zK20kw1h7klUH~rKtwip*YtgguZUYb=@SqaTk1S0z=9(-ymc)i`%_v{42C2w;xqzj%g z-i>olWBUy7GYfw7#g1&PeEij;3rnhhzQq6-f>48xp3)0#3`~AkA$qf4{4TO#O6S2j zi_V`u`tv0B)*B#1HZ)+n@mD#IL_x&u{>4BJt-_VrU|OH)Qor@-=hWeyy=>n~dP`G4 zIlg7js~^+y=%#{VSN&7#pW`(bR_cMviWL&!>f0RtLr-7R9gkp)n0Q$Ecj^#pn%H`Z zhec8mZ9jP{-Vj*HgSPy7-&;ERHIP*yVzlWw<6&KfxWGpj9Rzhe%aHpt`YGU z`TWMM+>1+SAK6W>quQ3(vwj?vlt1i~e@j2lrQM|`snP;dHBam6(A=N;qI<5WSuoy> z>=k`Q{iVB~O!p*= zJhi;y_`(-MnGq+wS3I_oy#I?usvjN!t9`n+K z8B+m((Q_-{>g#A#>KN8^+oc9G~>T;q@~w)~?fkWPqMr2!^Jc5X_$_NSD~0xG_{uG0D`&eMTg z$W_j)=k2^Qi=ISqx@JmB^Y=JaO~B;0I}YAy@MeasT>Ca&IDNNgOG{_VpVh6eki)Kd zQklG{wd1(*0(+#GF50oBLS0>S`JRH!%ktl?h%{3%DY+gmex0|gSY+1vg+faj&rfA< zjc*yCRq2s4zI1617$g~qvEFdi$Y+sX5D$!bVznKAbWSmte*JnOQIQ0_aE>HouJz#u z%c=UbwuEb+*h%kG#x4Oj`I?QztORPG!;Pi0KmyWzTGvnN#UD94WSXEca_yZUk6`8Q z2zIKLwV$-J=z>V-li_1~_ScL6uhwrR@*fy1rzq zB?dLfM+Z@RJoT}ok|#A<3%cFc|roCeII zr|di@&x9?tEV|5jQoh*B+FJ2@^rDcSMAA}sTg*vRa4Vm*VP#l()k&z3-a^xoYk-@C zPU4dsmYr#rwujeBxXxn%L9$4#*gW-kq+ei@?_Rbm!m|5^gw`f>f}8^CU^H2cC7f?u zp!5Vkj43Wl=%BqxZ}nuL{0z;;UbDE`##%Sh z8&S-pL$Bz!1Gh_ebYM)(qIwn8NHs+}Ywh=2Fm1fi?N0T{0r$;=fE`c@G{@y@6e1&5 zIE>xNUEN3Z+s4Or3TWr9=k=+bIgX?0qPpaA0hp0E~rNb^s@|Miu}0p&>39 z&v-+DF~;XxXZ${$(#4RNz~yvV$za8YeU@?df*h}x@r1}KFN95A;3SlpY^b?p>AH)T zX<|zg>A<$PCbEYczrY#r1I1z6iI0dvw!t$0h&2}AWt!)TSgLndwf{~ZhtKr9%Pkc@ zS~KH|$cVTEg11_THI{&Y6sJY?QpsmAOT^8YJ{MG((X;A5^mCNO^nn@#YgNuEUQ zuif+A7`X(C>S>{%B{XSsL^EbNmO0slk3J1W>^Yr&CzaxlQ_?&Ps=E^txs4^?+LN<( zxE=+X2tc@Jz^2jLCpa%~MYy?J^fJPR(nhaSJnSn~4pJtS#DtBAY>FlaW|MFlRW|Nl k4)mO!I>nvrE^nWlspk3wAq9Vj6MkGNGFQ8aTiYo%5eT{-!?VF(W`L@6M&Y79-`|kYa-ut`vch8xE zC|3AfTNhgZ0Om%lrn8ZCh2@?z3wgTlNJJqEO2G~f1?sOa8buB>1tH830BGE6_l9GQ zoNc756BGb|_psb3RTV7@0A>i`=y+v3^9wRhBEoa{5-x;SiKGY`0D@^MDTkKGM39n$QHg|N z1zAPGPCF(edyAQXr3TCSX=FBi)q4bTqF~dNN-3E@$j;8jXZzwMasdGh3=AX?eF#22 zI0S)HY!oXwDx6s1@!kO)Qt;%kR0&JO7>h#=SCXluU^6peJ~@q(=Evpp{cv24FCPbj zd=8Go_XTl$UtfNJA4u{^1G(7u;rYA|(WRMk;dE?1j{pfF5hPYB2p}G`8>Dx zMtb)_{+3zzFhYVD`x{%>oqGZG?r(;{xYpEk(sUMlRl1v0Kzt>L@kXj4? z)}jb{NOXMoL;_P79pgmJu3ol}{CTK--OfXE_Q&U9XiHBM(>=ir5O2=vZ)6X;k1fRy zbm>yJYI6%+7wp@4;Jkw;dQToYiYr`Q>FKn@x}XJ46jUD`Rhl-91mPc=oA0=o zJ8Rkv`l%tslZ_ISCi{t-2Uba2QnhoF+dSL$L1MOg-rl`?2||(R>wdJ^(E;aGp0x&B zzC_4F@t`lPjP@`r7l_9eLb;W06H($Cy#yHaM@)6_cvlQ$P zkFITT?6q+?=ZOsi^78Z9HPIKq>LIj|L2p>lD<@R1r%zV1YbuV!E`DWyV8Cenc9v9o zA<+wXt$*-~QGN1;ui$z!(fk@<0bRP}=QAi23hY+Wxok<%ZDAI5>$)r}fRwzf_|Idq zLsjgW1=`!CSnI9%`9aj;p0tti7o=F5L+6sksI57g)-FTR^HNeCrxm5`NZICHx>116 zBLJ=GEl*KS6S6J%lB8qi^jI6}glM?_aTGc)13?!R7D7OBlkA?MZDvt*d&~|P9H6i|a(;caDD5qv4ZK0Szd6m6c66T2WogCr@pvzidY;~4qQ5*lGdz6N zszk<)tf!{KR#L+l^@QD-#_*Flu_Ku$9V>rf4{&F!DSYEXVzF3RtqiTjb z@8Q{l8^h6g4f_w$nq?0P3JMt5&?{V7<8mu) zoDN-aZ?n~a;Odc^M>;<#yD{Q?zBU?P5|`ew@Zzq~ptXbd+UvND1J%5hNMCG&s9hsw zyO#BzXbnL^Ei}=8x3jYoJ4FP|L9E%tbo1B8c?aeUpYCZ2+Y~cV1wMo}sge`?K#8f1 zS^pKTM1PS^=b-Dc<=*-Ez~&0xhGEiBihkwywoA*sqDMhIT(R&QQ1keX_Q!LI-xd-M3u9ex%blK!^n^z1v+KMbEf)1%QcRi!-e_Lg}TkFx7 zdJF5HTN~Fv4W%!y@wxr8PR~%(C1yoaQ`4S&_v&uGbAG-~r*rozzgi=A zbj~z<;Sl$;(p*>QS!o>YpCr1tyFc;9cJqDo1zqzUKy&e+)tFXuVJwNYVgPNdF7!n6 zZVp`5ySce}eP7lYS^M`#j~;33dY?~+38-=Gt15IENTt0l8$R6mc~a6~rO|a;z!VdT zabAJFIC&~5p|MqVPqzr0$SfW(A9-NgJ`5L@Ys3u?7CzHd-i}i^F*INC7R zn6jJa#OTc+rxgOTdmg}Y4y|z!KpSS!2MKDh4E#y|rsowYzDuMK>B`ubOKf#_<_rjyc!c|KI=nJ5T@Q=|4aH+0(nHPoDnc(;q+m z<SNk7bZ-4&u@1NiP>!*M9^aoGh zyn6lM>hn*Y{_)e_fBJh*zw`7zJpJV9e}4MC>+S#f^e>-&{~G;Q*WCa9`uD$l`qxkY z^y!~J{g2n%Pp&cFeEQb)&;RlCz3XpJKe@*I*-IP{`&goFP?t<-2R`Q{PWw_|Gszq|DRv<|NE=`pI!g{>DA{?E^2>vt^TdgXZ+UR z%-Blb`kb=wJh%VRHSZr?s~kSQc~SVuMcZFq{QvZNYyKZUw|M&FOKLxP`d^=Z_u}A_ ztLHcWX5{!yvOm98Ap#BuCzGFF6rd?P`SG>VA766&v+Mu=`nmOImlf#uv#ZB!?H^vW|Mk`O zpRfP_aucpdyZ-lIuND87r~h#Ee|P-pXFkMaNE znadwv{mA3z&-VXE&%J*3JmxQcL9gF>_J;p>&GyHaUjFGd_CG(%``gzV{`dW-A3c3~ z$()D%==z)VfB3A6?>>EY$(b)r_blkg*LZaP{OSLCp64yr^qp&F|9tB<_Vt;0_MiQN zdHH@8|J%5T@muEX- zm9qw`{MGf(ndg&BDlq2XTz>hJ%lH1(^}m1fxwO3d z8UClsTIu?aFD=%qH{ZPZ&X2E=-+9IpI(gZTxzJLBKnYG=0fc5|U z*=PRpdV0xWBYybI;YR=X=^tG3gF-*J`1{=tjQP$bF><+&B1IDY_NC$1SNhKN?)_0P z?EBYx-}&IE@4hmM)_(8{M!iPgPp&aPy2gC>gDbt1?mxKt{{Hp9pIoEfw~)C1>^Yt(e!&)2JoW~ z@vH~#dtIsfg`zkR@E`NyLC@cMTyTz`q- zzjMXfPcB6KqvzQ1&#x%(U!Jk?53h(LkKx0Acxn5N-@o&DHt^GHl&tEfw;6u#Z{kc( ze{toCKe=Z3_NDzrpl@CoA>hF36DNKc8H2$ZQgKM1Dasb$AW~;0fC9;Ln>iM&4)P@r~!1 z&?4LT?Pu<5`@u6!c;PGGc&3Br;(}bharH$n{(tm5^1IKyP!m5e&3d()=?BkiYei3U z$78ep;PYAGJbK94D*f$A3(rX_pI(1`^O=LBB5ox6NefP!`^W@tesb|!WY%wMn>D#N z?`KyZD@aCu_U6HIuB3u8c%kaV={R&9swU{i0;n(hT2w)=cZl*+)ZG zN0Kme^BbGhWi{GSw^?xd!_U{P2dbmtho8@LI7DYuXQ@Vi|5+k&BmP*yOy7H!s%QF# z{@yJAiKg1j!c(-FeO#I4v(MX)J;k{Q)ErTl+_R(nO#2V7(OY#ZBq6QV`s|`?{Nhc! zwerg775BB9F&~)LlLC2)IOd6(`ElH$^k`f@khjgV+gHjW<@&UoO)grk7jEe??W?s8rNLpLpxjb+gJZ@Tz%G) z1;O3uJCe_`&6s4ee6mT>G>Og-d9$oFy%`+{&Sv;M^Ed-vulQKHKck!(C z$ccVLbgjmH@B07sjf2yA$6HiF`Bp=LIluj^U6k0*<-H_F{#!ThLuUcSu4#GP@g>| zH`qn@(U4^I4}FI5Lm()XrNwuvFQSr_T_NqTk6C8x53^ctG>)&uHF_xuO`G+%((n=u zWlo|t&pJ9`+vr&C=y~4zx+kXX#UAT?dd+7ISvZc0f<^itTr2+9D>MF3{=A&N?EP)| zv<#ZQ%W+ul_ntWtGs>v*EfNebs+*t%_uiIW(HB`jc5iUJ%&8SZC{mf`ljL|z`f-0W zFslP>WXvX(D=dPy~Yq9;!c35z@^s&5%_9#?BtFUp(OT>2W9->>!E)hZja zHmNP98sqOQBRpgi^l~k5N_OP_986H`UUGo&u%KBN&XU}rP5u0Rd=wdJA0eQ-tLNfEfoUPmrE#FQ5WXUV7 z)%<#+jAZLs1tOcZ62JAHm7LZnK`9L``m&1bw)yH6R?-%nZAY={367!HkfW%8A{mXD zLtqjPX>oqvRsQfAAC}F|k{8QN=XlJ%)4w&_lY|awF8W$O%VI-O(hR573{i&r+W9Kp zi;LRY7qnX5udQCIjbyV)0mb#swpUdOjl#U-`@L(;TFXd>XLMxc>>)gXQ(0&jglDVZ z+8ld6{+bVD>v-_y(OOn<2q$DOt-z=C&Qjx_=Cr>phxzW6>AWq6VFP##aZ!@Ds|m8$ ztRd?}ebLK^te~u|EbLg9o-)zppZF6c-Xbd+NQX_) z;Lo1vU*5<JNfYGXp55A;*XXkwFP@_{DVvukoEne*s5Sdbe%c3+#%fo3 ztDfj>#$sohSUa4<@htgKYeqfVGAsCUw#*x{oNNIf<=ppH&tDx|PLkq6yT9~yykiKQ zEuVeuv_+>hSvHnuYGMB*4n=3x**<$~k2Fipdi<5YoJf$pEoRJO!;`$0&8?!4SDD-D z_cypY`#>w@9a#{1isa?wAEo=_RiCmXbd^YhZAe{Y|J zXUpC}j1S?m6<*qMHg~5#H0E$yxNY9m@moWGBios+LdGvzS5&o{KE(>Ot8i&WPd*oqRXfvUmDvy>k2~#e z%eLM7dP}w~V_p;_m9lMgqW)*k(m!~gHPC4C=3O6W4RDX<%2m>#wnuAt-y8INjCdb| z5+r`?GTCP%vm!W;hEok1cIa^=(AYG1Y_75M(EFVFEU&&(P#==a_a1Y%+84`tD&XjK?lin8@`D3d^hw;Uqc5wjwU3)XMtB;bR03NHg4`Ihw7!``4w_O<7WxrLo@A9eUTqb3wM?Pri1S~l( zuQ~1XjTU;!l9qu&JZ)A-70q{!myMJiY(GmL`DdurE?3X)!lW59W}&M9h&VeXvxJ&}cG2-TB^`&#de5+b#v3$9=(PpCg zRitTRcM@<^hQY_I%nPeOZI@gmq%R}4q8Ul>=-LeaYDaZ%mseTS+<6jx-_Ni&>+HCE z@^KDYTZQZ;CEE>Vweu!Y4s{P@;Rji!v%F;${v(@%Ibjr9n(K85hg_X(WmT=IPMy#A zoAupmHpzt??{uJIHT9uz{z*e;M;uMPNgttD(&67)v!z9pdK(EFtm~Ptp|WS5NKbh$ z&9jr8Nt0cusCRyT*YMFzG%ZtGrH@v2x@U&$>{yvm>Emc)_)nI`(8OI2wY@Dn`}ED( znJhSj%O}zWj^$=_QOpcQ$)%m1F=A5`7C{f@p!MtscgsPf&%;Ciq*SCg_TKFQXM8+387SLF533044$^JkHG92R=E8Wh&uefWB|YV_*$K-#SQkRRHMjfF z4zS31pVhjZZoRex{J88+L}7`;e>zIKZ~)iUC9;6+*2$y2m|pDmUdvc~puPOktoh5% z1#2%07-qM|W-@x{beDf)x@GoQu_$(Lr^61GW9C^n8A6#cE#h*ujIs?B>=Y~H%gZ3e zBPFyRw^o>RhJWok^chdtCQU!eMmEcG&{Fgboz^}H&yMdpyN_WRMOUl7#g6&O`*zF| z%)8wPu0K@C5Ob2MH5WUhq@362V=ZUa>^S?r)6mMxQPUH=zPC>gNyNKfZY3kDkE%t1 zm!6nZ$ZE8eL*RB&NhUO&MD#fFI5Ix6IFjgX8R4XG+8!m)Da0b={H;vN*!g#e+Mb); z4=t?PZ6`Lmio+@jyuf<%N8^uA<GTr7e>YFR{Q>SUj1j++q|oMST&m7R2k2ju+*#}>5+`SVY|Lb z#oP8>Q70GaEz9>*b>Xx{*-m&woU*rNMOlv3Nu@Yb&WX=vH5;C{7OX_EddF}AR$43j zXA4#;s*||5q^CuJET^~HJawz*v^9$zi2aE6M?aV!uRf-KcWOv| zXMV<&4`mG`&R!x<@UY+QF4;y%udOaDf_JUw|^T=QEEZ>!C#Pb9zM zjqH_Ys$+cI-idw>pTq4%z4*+gj@Qr552uIwG#EC2HF8dJg1~RF066-KEZ`szxu^fx z-lP6sTE&@lcsLo~&(4fGvSewm$ByL|tY8?w41vw(0W$7!Uj?sRsQobi?`=20aZ^^A z-t@ii6~g~}O=flhi_`nQTRA+`r(7XB8|Ku;V`aw2XYqXe6;&Zs(g-okRF&F$yE>7f zxfUK&sSg1}UsZ+!aH>Zw+;Ac@cz4OMup)eiTJvR3(et1c?5X3fjUzS*^n z;_2(oyI#X+>z-4MIFH`uo)jPTaCjeq@O->t7s z#rkBq-+0FBYU*Wb2XqET}j*u+a^wjyz+Kjy8 zL3+-1j^yJ0SkAd4-g!n=pH-T1UtgLnY0&g`6M0LBP#w7O@krB)8T8ngc6+#|&2)0LZrbNzL;A<+ z!)|cv)Ba>KDWZ};IksmD3ySlooPXMfc8faipktCWSKob@V|5A=48P-ay^pVWI=R1I zP(Dmudx&2xc)6yYt5$oK!Jvd^dsEf$P7BIAyIcm*i0tfC4$OvTv%%LhTYt5;ZjRv# zOKTs#lkcoU`$3JekNb%{?y|H%u;KKk=C$h!S!nl|o>?LK5Gzq;KA)Z#H^I^+PAmRD^z#;UaDPg!7oEtd%O zmY)}K`euMP@=zAblFP}=7Os%AImwiLgdSQ$Z+46(@->H%;ke#ny;jeQVD%V<)-MNX z^^@VC7C(BF+wHDTTBE;xWr~98p?Vj0x8vxRu-=`w^YukXGqWM1wr7I`$*XL`yS_s? zsA|1tTP~|o0nxHX2-}>Yw-p~TH|q8CwE94ZK^va#^?_=v_twVgJItnq6Xn}0Wc{m4 z8DFh253&ONn5&pxe`B@Z`)6UVwTe!o;-2Hs`LZ{o*sC=0aoD_suXQa$ZHJLugp2KU#Jp1|K^g%nrLD5+7lc z6==T|$^!CL^2y4>@ntBefXdJ;yF58s?OEr1wLM6lj-p@B_wIB{`=L+PR4t|mgUZl2 zY{Nt6uh~gh>Dkz#VzDR<<+VfU*S&FQL<+|n%f{x7;Viw;Z2OI~J6LUjO|<)IE)_(m zGySou?Al6Uqj%l$trlIZnJ?+L2-OKeZ*Xk=ufKOsp2ZjVN(ZYJ4GZ|>&R0%^YRr&^ zMBAgX$JvC|qGvW%Y@joqo9$+g(CWnGyI*H5?OyZDW-%j+Ts@$QLs7AI=%J7PY!9_WS-D76et%APkz(BcZYY=fapO3_kAkw{Q7%$wpb=c zK4B1xhGWYw_MFGDp?n})FaK_To^6@4__at5%gX9jEveSl>5P@%{=GYIdu#n%y(A2P zr6Nlem0xs!g&)CMnL6|^3yL`OD-$Lx^I1?9J9@>yS;W5FFduTo_WYt?BBAp-ldE+Wg6bjOVrS(mZL0mF?7~-HP6xHVb|~d~2mySo(73R;|4#v>WiK$YbPPtCFQX z{(z^)s-YY!dB^qqufD55C2uptJ+HWo__J|kzZ#0cF~j1-0Qc=q|6N;ysqk3e!zcMzpWn8?z-O$32&Oi$X{jsHfm77 z-qH$73D30ml{eY9C&PO+lA{k+@LK(L@$O!~)n?viJLsJyo3kvnIcMGN$FdLJAG)%z zLq&eDGwRt-6~kiDv8u*bkvhm-*44XTXZ^M_EYuEZhpw#TNbeU{aF^>3BS??*%S*m^ z{kAz+>S}&;mDaR|VXcOS&)eT?Is07PCAc?M1UZA6D!<^R#+u zPYuO+)lS3U5L*b5tZ+ufJwD@|B{kD_V=(L9HhSaSsKd9B%jfak9_jF%1Z9Z3DcPNg zs8|-W+|Rf?Eqc?J^^ITKTT$w*zmo#JMLeA3S6`nrl^b=AQKp}N&Ohkl>ysw_++K*6 zveQn)PL^^im7YQ-{=##MQhb~JeSOjt=CmI#FD1uc{Yg_XF};>+v}4&0i1xbASB&ZY z=j!~T27N|3Y{$h=&#XHEIPIRgVHe+eov)kDtqB$;|M@DPG(nDX+j8Excfv#CG=mbv!*z=%C9Ul`mCj*UQ`T0 zp`O2eWfC3BlRK^OcGY9go!yvYw^B}P<6#hdwPty$b`;|`F?wRYai8q#|A9BW`wwKXWj z9^me|s}U0G6F#H6lb|E7Jf&ZqzPF;JYIJeR%KKgs$9oZ~Ywx-mIEKH|<=Z3vc^P z`YlQE0*{Z?B>JMG`!1C|Jhtl5d2qf#SJn8_U?+y@_-^Z))w*}z7!9pl&fs|w?%qDT zd3RCIdb)hMl@>`Pk zHQR?K>#*FIir_y4-1}ZL}1* zX?HQRN^mlrhKnd!2w9STU;IRW9vW`R=0%E7KkMy_g=`cWc_%iXjpdeip4fI-1#?Z)Cz`f(0AEAx#5*%@xK>Tt!p`e+X!m(7f_ z-eh+N3zpp;$rx|Np(4@3ueZjimO)enpZ- z(gzgm zD1?4-E>tL99^Q>9OH8^+BzcfCc?{8#gsj4-v#V>2x9zf8ar@45zccPzn`}pKoX7WD z?e49=3;`p>D}FY+bFU$9?Jj5B9^Lo%iBG$sR`yZu6Gq}OoZ4?thH%43e|73ky5uFIxr!tAVyoQ;-G;Sf*de^5C>x=9RS; zdFi8DNX6iEbL5U2efou066ikvys^77{IW_@+@G4r?p<1+2Bzu!No)70L&EIXbGbDR zX7!%)>SzwXPphZ)00)1IPTHZlaqZjdTjsqLtV+&N*ts_P*z7$zm?bHVS5&j8b{Hqe zpC7Cur4J3{1)FNjl6ltdlA(DOIJ)V)%F}X!M$ZHA9Ujr2l^2on zx%`zCd0Vs@_1od%Ad9PakwLb6+U+6m#BSQzShsIG(cG%#4axn)UA|?^(P!C89(~Vp zdQLyfXwE!j^x8bT-G$5Tok*--B(g5qZ})%qI5MN7W9iLtB3d`J^Q4m}88fe$Hzk+m zoB#Ru5UR>aS_oPDk`jGZWe4pdvZ^Z2Ar9;GuD@1oppEUO@O#ckXrM~B+4I!s8|KR? zCz;tTi-`ua&8ot-#kuAx!{45IkyOuaWuP?6w+cG@dMZykk|Uja{p!3)EBUiIP*fGX zG2vvg=MkYNO&`AcovM5yzEDyWKS6;Dazeq*7kMx)G!i!?bMm)~1G9Agy_+uGS%D9EmB z3stioSZ77+Xt7VJS9Ke0jnt3FJhq@&Mxa&oj^}!A=VHvNjGoV_#&Sb_@0oNqhu`FF z)b57faqqM?0}6&3kb-@9!&bDLvDi?1*-OvWi+VnlPSlck?fW$X^c>4MRAepLUzU(3 z(kHYq4}BlBLJ^G4lFi#0(JGhOcsvggjagh}<-^=8vFd5#_h#Ok?voHY#6Gi4w%ebg z=XhmvmC5016y{g#c^MTuh(mEjJ4-DpvX8rWkjN_2Vl*DnmtPjkqfI}2mL+-Wx4>GJ zpLCBs3o28jo!z}7!$#>D-{u_ERTIz_mRCXQzDrn?G+@Fqz3Rx(T2(6iFNXIVs!opi zceRhkh1wy{zDLUf(3YLxBI}_OTr9UjuX*%k<7E=)bFMu*+8F;r-Te zu{G|y^-_-{aXf6AKx?;my$kiVZO6gRWn|vwMV{!uzn8n@k@q%Q-yEbUefcc!usC@40rE4`xMJ~qsxL|IJG%LM@d6+1b*g1%;qg~H zOejmEsq&Iy8j6-J(8W37>Wt-N7PG^7p_%h&8LI*ZaUdvc@LTFK|^)5kUH_ME?vQa+Ha%M`rH%J4%XJGTrcsv%k7mvNWz zt-t)`zf#CQ(i+Lh7LtQ6Zt(T8ibrbch{uPeG;(C%ZJF9;N+&0#-p^53fLTPRch}5O zvGXIdz8;;Hr$nPHW3!NTE7RJcA)9O;GmCv0n}$G-oHw#+<+9e$G5%lrL4A_JXgRTT zJ5X!P_B^kBd?bzKPQ^*@mIa<)iOAlvz9K4p;Jn>JT$bZxle0d$55;->s&`pBO|#|2 z_GUbp#XS;q4})E=wUOq??u!puduRKju;^n=xTSv!85i+3caPm{<(w zpNqAtP{^gq!n{FYJGLTNyT10xFl!RQXtV}m$obU6bn(+Q@BXZ%hRu(NM&*JX; za;}}W+rQ@V_&M22dp`d^s%ih+Ig*oSDLYw8GW?yEc2fFi$L22@^}RWn9G;RpuIVDl zl-HFbo;yeRQR{5RPz4ovr@3p(&i?lMM&?KPep!;mp#^U6rtdTz z_GK-rbT;Z>_ak<_cHao@6%T**j0ZFhSEu!}GfQ?=oyy;;{o;`#kXd< z%jeDec;l!l(I{KpzjcAp`lBp4-)|l5O@6c7YU|35@|w`5ogfL6 z+idiqWLh!<302t)WzUHYnigBEJdTWx?z?pKF7HcEcl6JzkBs3SX|@YaN2pD5MxNQy z4La``r8DSB{q!f-^PfKb(bGS=-v8k>?ybLU<|HH(tr8Du)4V-scM}g%EIT=x zZoj9;*~5nv%S`fSzFCfvoF8+#aRzak4y#UD6x$`_P?DT$Rlo42}Mm7-~g*?-&|ihkq38BDgxf?dDsMeFOl1-V=k)|=}Z>SMg(E2Q( znzZ+w$g|+$cJ^#m5vNFRg)^6xsn~QpDG_Ax39_!bcKD|>TAD9*6zLie>BMrm$Vb1EH=IdN&)E(qpeLln({o#DQlSj{Mf0Rk@?s+Di z%;HvI+1MkM);#p?#EaApPU62R!JP-KFseAVy{Nq^UCMyTwl!xRWldJqzRCkyvw-X> z^v{ZlXT7E8>1+0(E$J?X$oge4_5yjPm8{tw)VuTFs@Yk#dE42IlI=}KXIYT7+C{L1 zbPaFfpH>zn%6Crw)CzoF-qG&ow0Sy~WS(V=R~EbsVVo5a^_;GE;(TOn&i6;R zF7%m>TDNhiZQL@6Y{A%x&?+Di+_*G}uPtf5l-5p#8FhxUB&jPmwG_8 z8G1oh{pvMTGDZ##+f-%Ynrds&DctBvKQX*l0*H$-D) zn^jAzz@rSECnl49RklX2jybC1(wKc^!~%BfVt3g?hWnc`mXmd5Em@PV-WN$0T^c;_j5<-6?q$j1E?zJr!oq&3$*3D1h}I=hc4Hhqj0lU30% zoujr0Y2G^$b>HdU>iO)gmdj>;Q4(U2*|C;0>xq@G*>O3gH6HEw~1-tEY3;5Wk_|7x7S%HsjqxC;fv^-sWS=J&f6T9vZ3wlScX@L1V-w zy2NR+y2r+3Tn!*ks8+h#L>AV6ajy8d3Wt%kA3f6u4U15DBkhUz$(#k<+dX%loQCuN zdxfVQZ}Z-NOX+@#@#t-lIP2V(SM+$^Satco=iSG|$E<}!7mxL6h2qiqn|!;UwVM%U z&cb%T*Gxm2kZAjk)r046_sUCJ44FLB%24!FU24q|Sm1ilcoLsSFQ3Ip=zZdG$W2Do z!;Yumuloo`$5va^?x|J72X8xfg)*y(nl~TIo6rV}I@6Gg=L4t>A&ytTg7l}&$g)4Q z`M#vKvlqa{V;f4+Hwi%of2UD=6?=F*+9zFW7JFEF*;|;j*pr;nQGQbODm*)J#Tt?%uU~E>#cVfQ%ZtntGUq3&#pT23Slp`?hrV`DX^(VQ(Zi*2Lxg7B zxq4c>uL@aNa!5f(C(GSEuptLMp?f%<{G)F&<u(2*%GGepwJ%HW2uOFR;1eh?2G=|~yG>?R>|3HCSF7CH z|Mq)T%cENJ+^gEm_Zgyu%LMpv}pfQ(%PM= z&`nkAMrPT1cdj+WKQbZp*OyInMjCtFR}&wdDyL&uhcW_vt@(LPEZzP&P(l zoh?|oI@F^SwIzSvPyVeit&%^^p^}mCg*}-sO`>`C46NN9%QP@VjT=6Dr=OmTE}v}d z@y;}0ojh|m#_~>U@`q^2e;85}Z2xE^n#)6mkgw0N`%QVx-4cdP%|s(7d(dy58Bg8q z&MTIo;4pte3u!Kbpk@2w_vP%(TX!07{VhjGMpJ2&ULbaO<%Y`L{;~?bQrtJHIEACH z>v6OHq5xl^-=WgtJ4uyCghxdScJ%&kqO5OcT1B+sm(@-V_*loN-JJBf+~Vtc9Bs^7 zi*hGZ`MMsL#j=`{{|}8`x8v;W?RMPR@B4Nf%39;mj%$Bu$1o`j{c6ll+*e9UVGWj3FYMj+Z&X_$`kA-%F@*p9(N4I z-y!{SukGQscWTZ~I5vOLM2zknk5#SDA>LyD#{w~?K?A|g+;IIxJ)d2kE3yN z^0v($Em_$kHL{&^Vs5sxuWFt2{K#8#^yV(7D4&&kQ&wH>RW)kWrX;?)%<;`}#EM$= z?5rMV=swi=(f`nrm9AP;zM9U)$@_dIrSNgHviJC?|Iy8dq@BjPeHtR=`!qh@pi&6M z0?(XL0MYO3d+v*wab^D!AMab9-tBphG^}8ycwsGTi4KzNF0e6uP3`t!+R-*k!T~v! zd82o+yu3LZ+fFQ>TAVP(y4%m)^IBON%&-UYte-RLvkou!&Kts)p?xttsJ4-pUMjs!1q@ie2U5$o~JG1ZCHOOuJ7+)@bmyMOo z9#{LuW36}jzP8gPolvoO_c*RQh^A%e~P57$K}D z9^B6fNg_J)bQVnKjn4m0KG)r+tR0o*&BYNsRD(BDH7sw;#mb5}Y>yUOwF(d((FFm? zS(Pe&_VuqGoz)rn_?sbQZ`3*KvLXy>CbJr~nqaN(u2v{+Hk!#p8gnwUU;K+9xJv3G zZ8P9kro;nL)vrHv;;<7c)Wjp-F=9Jf^vr6qvdKj+F_fK=WV;sQt+Kl_^mTqs^6sJN zBmvS$Q?q5dD)yw6(7}3(NoL)t{b~19q>6LB+AWddc+HC-cPG*N1_BST;K*;hEUz&# zx~$agXlu+Sw6bvj&byMb=PW#H+`RX@WIKM40Eancw5zU-};gN zX`KbNTgZk&J=WA$1!->IN8!118S5o8lC?JLM}3k{Qmq~*_iQVq#lKuJG~J4HFgr_X zEHj;F`A7d&4vnHB-9=@Q+H-h_;_Y5es~OOj3~ILu2^1H#9gC(vUOw)7qPb`ZabEl0 zKp!znzfM)dhMjicx`=1aEXdl$w&a;yNGP6gZ9aUPdCz%F+8~FthzoBXt)>WOrh^Ec zzWi^7(9GNZThr`StiV^Y^cQZ}*t<*SEFp_L-+8fSs6$FgAg!&EQ?`~&NH$$fyTvmj z^K$b)wwZV5`sr;r+&V+9{C*Z_wlMT)Gy7?Meo|lh-n^llaffP8BGgOn8-1*HrzPbd z(RSvm->54G(O`*IvzL4OqfF|TR9d&Vq1Qpf&3UBqa{YFUMhzvbnXegj z=RxcJJFhshd#B5<=67DC5^A%F_=J0TY2e+#Okyn7)BQUyGeWeyw;5;IovIzY zGjox69+uVB=B?c9_|j04mWsdTzn^MUr}Ta?!1GY>*Y&N}^1VCyE+@{yhQEvP+2QvZth{b_3~6-@D$50O8P(A-hPQI6Nmz^M~d=9)0YoHuzxQb2Ju(^H?i{RoO)d zL(gzCFYIPvm~}D)@;yFS-0OZqG=+?!mmQbBaRXUA)k+ozkI?{K_gf2W|BOlk%lx3g zW?~K5CjOgUTipM;zx5gqZ~f9s&Hk-9xZMTL*$6bf-z#C8^c7N6uP9d|=k9-2tEfJm z+(?zyoD+pd`-&_do2-km+iTKFnTpy2iaOCSC)#KNz7?-%X)SpWt5~GJ@3g8NWdo~r zpbb}6SydwIMVqHplu*2V{fYF(os50)U*EEz{8O*0AH&tMR@N%UJz9UBz~;3_!|}y( zwRu48hl-GXwJ~(8zvs1+!SXrJI~O6x$u&z+g3af<0OK(;I$Qn_V=2;GY7B^9imRZ#Hw0d{*k_MLd*=3MRJ7yFv z11dXFNn*vKxxFnK8xLE|xzj4vTdvrydbh_;dz6)_vQ->~+UQSbTXjCfA|c&SHtRuc z{P32Y%AhlDSjb&Iclx85bj+0%M6p?|6fSz|7S!5|yyF3?yU(1(Cb=_LmUHSpIG?_= zN;B@eK(i$ca@l?&enW)nxXoIWr0K;Boc9~$=)G5yc21HnjDqe_Satgu&#)%_M(f(< zNm-v(9E28CSFIm99;?mbLr^@0vidDotA=JoZDxL~Kg@phX@@eI6j9Aij_umQg5rF0 z<+tp&)6Besj!Dv7)pE_TQ(O`ZzvFbhkFR(-*}q#44z(SsPVl5!K0{ z9nhT?l<$r>+S4Z5QF&?yW_!Gw0NZIB+ImO&$!z$-(%Ol)&sk2Qy&5=5YCR|6MH&=@ z7iA0(rPZ`1n=_iVXf-P?-lu^74+X|uh!g#G5r>=XKl-!O^co&$kN0rPUM~snq;KtS z+xr;%wu}OYJHLS2kcW=Z)3@WKu-(TjAV2ydnf7vwb{pwg8#}c2yGzm`WL~q|XnMBO zTyzd;wa(f{>E>cRbXyItrxT-(Pj9DXT$mMId7|edfuiNd-!?rFl}7VrtHE{F6O}L9 z$4OG*bu=I@g|Iv0)hh2P%MT;+p5#VW*;ukN?_#LlJJm8zdzMD?STWz&6M^%hMXydd z7G;Z4WQ!N$tj(`NUA(gVcBH*8N^h-bND`il7bp&o%@8lF^06Id_nhvY?M`!X##8l8 zFX$sbt7>1ygi;l=QN4UG-|vKa^pr~sy>_;D@H%O%9%RL0^TxN%y*&i$n?w1Bp7dEJ zPByzW5#kh$lQr3}7ZR!d7$xzG$M$=haY&kOsuGg&kUUA!>Pz{Qk(a%+2N*W9^W;3N zMH}BKTS80TPK!eZC;$PhJ`I=UW+Chm@5PSj&p*+A&uP?7vt4=g@&q_b7U%05k*R(s zJ1Yt<-twC4(&*MPyf9O8LD z{IFtj)AzC36Xm;m+9;Nu%(I8JR@Lz9@TkVMm`U#J;Ahv&vxxLYpGkR?u^f`sUYs-9 zO!Y0wdB02+-?KjJqODwE)e@ZPp9Ysfn2!$3KN`-s$M!Pv;#Ho+x^@c-1)Ftt97f;g z-aaM#h{Mo3tFAq(&HKvkL)=4IevG3uwu)^sIFdP-la3}wl73CTAy;>3vZ>ZAi^rqC zVHw;ZJ054&?TFJ5I?TV*`>>8*L3|n_%`h>VdXqjvvFJm$)+}k2uJ&g}Zhz*v)1&*& zX*;f+J8f4N9vYL*?z(MVT8SE3Oe45TAMJ1M`Z)IWD1lihe=l2z-$x6t?X7v)TYrI! zzh{fZT{OO5>%r+k&{O#+Q(9i97mbIF=!GDmXc_J@e^LtFNG=(%`Wv4VY3!p9NK6$lJ|HS4E~~T8>hFf7;U8SNGQnLb)L*sqrWr(oFn{Sv=>SG4qZy z%G+?;Q#oR9^A~N&m1k-rAy)D3c^z+Q__NRBM;gsn!shKU&AR&vjWJh#9qMK8Y$44; z+2tPP1I0zp;Yrl9fW_@DfejW|=zPEcW9~-KX=_+GZ-W{RVUe!Ub z<(%diCVPtRWc2I(%>q(RKd8@o7CCXS+CnttIY>`TE0Q+-pVRYO;}5IVD(P1;&Z*;`kMt} zk&$JL?7J#TvnAoJR?n=bU5OZ*4>?Fbb+N zJid{~hRyGuwZj@Fh47O% zOWvICgcQx0cdc$!U1#40)A4F(X)%9{qAE(QSKeAAJ5?(bmPc8IUEQlz{E_ZO&!Sqn z-saNY8tAC?zGJ)Z`KnRmoAqqHvV+&hA)W3k`fA|DV>8ksedgKQ_yqv7#`-y~G_?M+c*7_MhXlaCgu_~!16wPulR8I@Ve z`of{A7kRrG?`H(72o;CS@2S>u&t$ZGwOu&M^Zk=uz8psr?P<=+Wl7n-_vpk;==eVU z<Xem^w=UV(C;I!-&m#=och)mB z)v~)#p4&U@EMS#tJg+7j!W98&c=gJ&vmgt&$r6X5Sx0E}^?${dHRplpD{Cg@+O>ww z`ES^_UBm9`(nOU}@2lAvDYs}R`0M&21*K8KXR?yAPS0UvF^64Ly9l*;JlhcoSVw1lMF^ZPyU@4&a!=dIkj-{xtBWL2t6NR$yK6Nkmga*lT`lGpf0Kf`d`$k` z3^=y_-~LSs8u^Y9tKOrBWW3qlKN;w?uP@26eW>xc-?5{4L(zHPORYYxeY?Z# zD!Qy!+zxeDMLzAG+DXK@wcFE?qWudghLa*_``zsJoTDE3jTZ==wWNn6)ER;`R@Jh8 z`wUNcQd+ZS6^}Ch&AjSL$bIZ3F7i!&x?G}smQ=byaBnZe7kN6HL>bF&PuF=>aix5~ znj~9vxPO_#dtgC3dh>kv%akm5nb*Db-_c+Z;c)rP+u$gL_5h_r5){g&hL&c@`H>rQ4+d}?j06Yy&@td7hM@7R!cMi1{W zOE>Chp}k5rou*G~J34ss?&3(EOABBB`uS>{vi8txXJkjV;Rwvko5}y=r=I8MS@3q1 z)9_*n8#n82Ii-QJ`Fn-IdU;qgv6{tR7QqVhBsQjchAz5Zv@SC4)cF1;+$+74!{(i&gp z7*1Gc&&31?Q>)&~tvs*&Q6}%BL(%L=t)EnkT0U)DQprcFe695@-*7%+ zhiGvmV@~_!Sx1r`jz--J-#IZew_WbDNJUjhn>+Lt9f9>trwvQm)e1=w+JEu`|;wmp~mg*8oJd~OT z)^}VR*Pe^4&xun0Rc!cT-){2H58Yoz@n2p4UG2ZTR@A5evi85YY>b7`R`%-K5fs{C z@60q0oV6uSeD2r8@VN@1_U^)#w^t1^l#dVa+PHLl3(>e|ypbh0@CLr&U1eec~GQGxw26w^`Wy?rdyrvu1m zaad&&HF`&(^=m&;<#Q)i(Q!Mw+4*9Bc7UdC^JEQh2+dV6(6d{Syef-;Uyb8^`k<$A z%|$+oD&C86)!6HqT=8&L)s|&90wuMPO_FAvMuf(Y{PRNtF2Qea_Pp{ES}Edc6+$A)j7lc zCU!bUQ`uVFwSk|4gRx1X0 zi}&n%`SGeBS%+5ht$sYY83&Gegt1G+7*5`{S=V)iy2sV{0^}A;haMnsE`pllv$ZiTm zLz2}$^cltvf%4Jxi}F^deKt%+WenN#EVK0|<7{{|j<03=;cRDXX|w)T8eXFsphS}pqUFPD$$aTn(oVYV3w0q zfA?z_cl#1?P{~5J13UJGOCupG8R33eR4as5xSti2j~4Z_k92$2%H;CvzA-Yajbr}E zhDigr^th9wq_C`0uP|a~_s!H_)gO#rmUAqKHdt1Nj;fl;Rl5Mq^C!k1!M2va8e#qNthS_pB!DN^)+ zRn>QUpZ&q5`%RHr*;p7=q?->dYcbayDS$n7VnN# zWr2{>%+At&_Duis$?ZaRrvVCOc`P@kf==eOK$fQfiJC;2p0 z^|n1x`JQJyRskPp*-h5c*9}F2cJ<{WtK4J@*&l76ie>z;7pvT5O{>QSS7XTT%Q*8f z*+rR(nc0oGjNY64KD*s2#RHPs?_w;oLd(n1XO~`e%q&S6g}m?7ezNv#_$5E>2}om- z(Yx<5nlUUYDz3fV#LiV;Y7HH5i43wJR`BI)nGDRkU4Pa{XvnQN27oBjqDn?a+&JXT0CnZ$gZF9C~7g%gumXBZO$Ta_wCsc;> z$;RB_{Jb=)ythxncT|QLX$=RDwrqu$ww%q~=_kkPEIr+EIocm>xpO(I&B8qw1@5?9 z5f$9UsX=$;sYDPGgogM4cLh zF-hgl*O`@6jFf*tQqtPEmr>5!@`u)^jaI5Yxaf4+MsZeA&bDa7VmhtNs$lV?GQS)j zX(j6Dixdy9dUpzslkQ{k?J|^|KIPTX9R10!=oD_7clG_&(BH@^kE@pPi!vEe6?OU) zD^RmrrRWKf;&JDu_6TzcnB@7CeIqRJzB>5o?*-vM+`bSHX7zMfE$nn}%v{XKW~V(nlKAtNq(S zj%M?w=o$_dN&5P{OucMp*eGsCLG-j{)!L-J7=iz))lpLHYVU{N%{acos}K!U>M&>w zCGh)FKHd0iM{3unHyS!6OU}!2PCI?0g`TpcWuwqfo7GW8^PTl&BV`KP+aCW6wc7FO z*?k$av_r^3WqO|a_7^YhCG=}|nhnud{!x7@t2DZG(0KPGbmVvUR`ZJ`?=G3FomHGU zveSH9eQYr=--X-gnLVPSy{q@_T$?$m>HCN`%LJCO8g+0x-8Pr+n*Z#Xwrr*fpo%n2 zefamr%ATT(-4xeX2{GDCG`~tU`S0F>ab+6^TZ%*4hmhIaB7G~Gkrs}w&ET(gSNHbL zUdmKLebDtZy=G~tEaSmF`m-6e4$*RMZtajc+=b`SQERWCf!iZ!OdK}pt%t=dV zX|C5L9CEq8l~+v~VH`Q5f%SC;Q~k!QyUBUzKt+``v!tW^lWm;caWwVD=x&cqI@>kx zHkICdif4IVJ>j&cCo8i1B2Tv*(2%^#kwQfy^YgogQD5I^TGqCzAFb@v&kWgF^TLlu zIm}XNKdUx|ChmHu?QNObr`J3md!Lf8hw?+X^n!bN8od;)Lsa&)`xv+#wMEl|Kj^Ms zwyJ0Im|;>%VnuypPkt?PS;dZ=v)_{+bUXR9#)G>m?=G5oZypow9n@QX#rEm`Ua|Yo z&JZQ!Fz?M~Sm6+k_2Z>oz`fP;`*~hSV@~fj`ok5wJoQdPYYfNGHatK@?I>b>NkmnM z?3dA1CA!x*R85_RWIJOq;m1)p?zEh@FjOFiW9)7pRX37Ubs!>fUjpPJaG1Bu!q~;px7%@9;+=+8E-&@`XvfP}AMN=4 zeH=^S%O|hNV^DeWn*8u(UK7$xM$2oW^vETtH0x>gbi6y6(0CF#`OqU)c>Col9PyRD z=5O#d$u8FmQQ-?Z<%{JAdUZRBjjj^0N&_6h?d}X1e|+kGDzr$!S7^Yf?$w)ja@ERD zN8i3dKsPC9_groRwcF>#R~CR@9sbZ*0v|Ke_Xu^(|l54Oc@D7Jejyl6>aiPIN$(B*IpETNWI}_cpVf zTtHkcliTkO(1E$S$yc6$dlYq_bn*MFC8Bz-4l>L@leMyco?xZ+#_e&MakN)k;7zA; zCVSZai{0R?HcugdD0F5Dy&+hw-5FZ0H{SM@4nC*vsyg?cX1(uq%g{cwvbo9y8nY{KX?Z1i*x1?XDVSpW8<^)F&*UyV(K*{G zb?z!eW!J^5s5H`^1C{v`E3R@kU9d*>TVvdZe~sFDug5qw3=&+_l_&F3wT_S5Q@zZja~jVnuJU2AKMbtl`8NcAhQ3%6$563ok9?%sOnGjPGY{Mjfl7i|qf{UvI@A zPvzsMZPxql@=%m~oZng70qv)YL zK8-9wccUfU3`1&zlj?HQS7@50nwO3sp>@y6NBMdkhId*aht743Z0{&So|;z`ld_@m zGIH-^w^$(`*v(23^S%=dbCL_KkV;VuM{CJnSjghPeb_YA9fNo(o~8kj%_^Olq|I`m z{JNN_Z!z7|BE504mHd6JdfZRE^eU5Et`t?ISmts^sGiFk>wmAEmBD3Q>Z!OpRP6cC zy#8ov&$kGD;_SW{BF7WwS9?mA?J&(Und+UkqL_40Q|+)k!r0ZRvYGIB9<%-|VmQsZ zv}X_7Nn3eute_H}|Ld9NSx(gQZG56JpD$C(2lf7C{&y2e(ww`A)>=@j-MQ$Gp3@#> z<*%!$m(8O;os}sWnGadzUW?Cq=p}yeRu!Z8!z$!iT9161wJLu!>us*p)U9S#{|+NP zbsn@fBky>Sp0gdg-OY*3H4A|)=<58V)~wQu`=ZlqNrR@h%ZT5`@5TtLh)0?>W?5?A zI9Y$ErF!f(OTI9RtDa-kWy_vnjWzd`$J*veS)W!M_={CS798$eWLBHSlTc4)&zp?( ztZLK&UbfuLHWn_5Wjly_C$KFqV2g8LR2T0_CY8jy72lJtuL0nGMZhzbC2|0^g?^6&52f1;V$3{P@ z*lvGi|Ir_&r`PZx57L|T^0g!^s@&V(nq7SOS0_pD^&OGAeHar}EMXGgFOQsBa1rV^ zr9z&~u#YdcE3pUs)$K|k81E^AptCBQBNkM~7NGxHH^baF7%IB4o!UsRkn^ETV^TKjtFW_-UX+nm~H zK}^5nvVO%jPs=CM7j1aD*A1$}-dh`|@9>)zPQ-8DP|R8F%J^!JQDp`CF<0@u{@pjM z&-!O!+0U6pt4K8}?)eT$g!!vFv4$hN^j0=NT2)z>uaX#>xW~70p6yn8W4>xFp6=(S z?R?l?QM;Md=*So4`|jH6WzJfQ%CxmR56SKQcJx{0-LnqNhJxM6HA+M{c2UNtbzE6@ zF`EyqZlr8l)Rn<*>yqk+7H%jmH`w0Vx&*CuKZ7 zM!bA7nS>kJF^!&knxsos>=G6AFXry9aS?ucrOjr7p{oOgew%4Hg{u3T@g_s&iuS8rWRt&m-+tW4dv#RhGhLbzkG_$NVdvG7DnEk9M#Y0d_3#zeY;*g(tj0-?6Prs z(*^_%c{pk2#t+S)Dg)9l(c~H6b_idbJXKyj`zuj`l93zMr^OYpc)l(l9UicHhhB&OwG}bcC8B+dV5IpJ>PPxVG}1 zL$ITTv{x=(JS{TQUh&IJAvaxm;?w5IQsaN^Z_Q>t{JzmMi+Iu7&7MAuJMef#NJ2C-JN$Rwr=ds2#JZ>EkS|1T@(WBX{{#Y^Js;eAX zWjGO~W_Y9;{o)i|%}d<-$>zn?_|LA%Y~CwcZoc_ne38v;El^>-|B!7vZCotN3@3~Ir?qkPRfcHIEFwyFABB&L-;-x~bH2TN z(Ojy+XsCZtRQuz7m#`qaAcN*v1s~PRqnoEZm6d&&`z|NjT^4=Ueevj*ao;7b=YJvl z>Qc#jm897+j>{5^`l9z;KK_^Q#FRae9_rm7_jM%JcG!d;28# z?sMCe1+r~>qF=_HlVi>NCToOJ#f`f@Uh;*?V$o2kdK2y5Z=IMej>^R$&2aFJ;lqfe z&Sy6kqXMhh%5$$TJ^rycbmh=UuAtaYS>4a0e!NR zkWG7ElhaXoy4^-~lSi+7t-foXEJVbim%hO;Z?i3r{@d%jB8eGx@@91wcBse#)wAyQ zQwCNvJXSToDhdos%fEW}>#Xl~-i6v>?r@fs9O>Qn5}`6XTZMP|e{Cc{n`G0>wVN$O z_4JndZga5I)d8(tRbFfPXse-N8PWUgeBi54-zC-A##?tpAO8zuzPMxOi}%G1QvdjS zlI)g*zj#gi&NG{twLRuE^p`)B_vKBpqesNA&xd1{{IoS5V^;0scSq9Pli+;sepB-xWu6w7?f;E4er<2X ztB?O(7Kkniwuh+_DQc7R*C$fT{`1s%3axy7A_aZQcKH~K{rW_TPggA{sw6)3EaQc7N#J_Z%?2xn8TnRWXV3Nexyc)zRM#B{YOosy@c7zU?{BFwS`!4z>D{ zpxLTcXzNtW)1pn!tjkktY}Yz!NbB7-t9z2GypcM4;G^euN0_WnNR969?i?wG)BPs( zz18|5^NIEMbCTUmOBU^RqPbHD&-Y#QzDXgM>C$snm9}>RV9qQHb@7$dwxc|ESdw8f z>ZH@k-3F?4X9n6&Et8&(c7}NMDJSq8<~7e<8*9fw^X%rAwOLCu@tv$QdBw4|RSN3a z*>~%lo2FUP%Uxu3!PEM-eUtmRACEIU8z+m_ss{Y-H|h5*?aJ}EyS1z@d!Vg$@9C?$ z%DvU|ISEM9RaVmFW34G`xR1B)-AhJ8eX41DeylasJ_~wxQ43|D&HTx`-G|i5ldC^L z>b)^@o+1}s#dKq#Rhd%0XP#=~?ce2qkNYDwiK8+{^DGJ!AIiMQ7QL-WCU9&Bn0+M^ zJz$RBTP-TIl&5!EX{}*!x5E5Y-Q%>QZPCCC-D4Xjn0eoU?N(A63unVnXe9pfVV)4? zM78rN?NQe5*BJY+}q#sdzSY#tDhz7vcCJ*Fi{e} zc&xrv#aneezHq#|MY!7CyCg}kFXgigB^f3k&%8u5)_+yI(@xuHVcD>j%!{7YiIeFx zTx`ig$dV@eifAVUdWKuo%JhZX?M1S&Y!n)J>dQaW6=85IUS#27!Th6}4|ngt9X;MX ziPrPGdu!Z(6@Jk&i$LY6SKi~a)@ryi){r1+q$?2_H(5k_qtB$g7+3~IzKd-}!b^|Vd`PfsqJe5C~sa(BX(Q@^6l=H&FyD{a9Ng-5C9^_0ONh9gr?~QDy z*BEkY=aoLT7d>}FFHcwE`&dDLrzazK&#jE)Ab43qUmxuEF!Q%C zBMj`so}A6wE%0S`k6F}kn15`y2{pDCE&z;d^0G-jQhhWoI++;e-)<#s#--}zz~_vv#L zq4gka5n?;w>bWwza`$D}(1iR^l4imj8Y^0RFW-hl=qi)0@8ZU3rwN?1DskE9q6LXW z*L{y-opQ;0mTit`BCjfTA_wl!Nmk1i^`Kqtp3Bp%+Zk+HOvZ7AS3SG;lVz~7d+V}L zR*~PMzx6|hv=ddNU5so;mVG{A8e2e1e6?SQDswC=kGE4TYYud$Ykf~Pf~Lg~GC6jJ zf}$~5lI?6h-5j~&MxW|@e=dLK-n0el|+$XBukty#R;%D45M z)qBpXqd6o$t)5O~GILrpTRXSx2Cwyf#Q(6^j>p?h)XlCF+nX{<~r{762#lUepYZ%QuB zH~;f*5-V3bTG+RG#jW(f4%%5{RngvEU)Jf}ZvHkOZES~yzVmetsaFRdzh+MsM$uer z9o$%zdAKpF(qns@WLKVLX6y7@KmO8_Y^-ya|3ufF`wS1A>(dapJF8Y=c z-rL8qdG1k$r;%8ATE!(^T9-^oB4SHcM!QEZ+Rbat;+)%iTiqMEZmlEQd87 zv_cV#_9lzz?)Ivg`EGVSWHn}SH4pDimbhHgrP*k`uMb~{z{9IuR)GC3AD z3iGQX@?Assc9vRHWFNy!xEaIxG9z2?z3`%PlUmcs_#R(-8g#dN?fTbcB~@%d_DuJ!s}yBbo1YEM*R4@bil(fj zSamQZ`i3dVJ*wZjKmYjOaKg8I3(a&OzWGZe=zGohrAqReHdN;EOvX*`|e;!H0=7-{TfH8$Z}W<8)5(XXgIp+U{VP0v-mZ4Hh5#qLZL}v|I3H(QJjtq|Ql7%bqJ%AGCsq%i@-Hieq3t>HBxsID z+BlwR75toPbg124BtO6B`h3(r$qdP1KFCY5aI-~ynDOrF3u~-RYWWCSiVs;v`9_u4 za^?AwerRsJcCS{#y}IFcIr9Z8tsYezMx$tb>}ENBb~CF>GJ454jUmgTh4HwrYSXT5 zdx!KiA5ES~W4bbz_FA*M)jX{?Tx18UKAd(>WiQD@uf@*8>oQA}<2$=OXmI2=UKXc~ zB+*4V^0LOP!utHVd;qt4le3ZaEh=y3vYoqUkAK+*sZ{Mtiy=f(`S{z9Wzu}V`Wl^~ zwOW$s(@A2rk^1o-9A-1E+;@B-O8PIRXPJ$g3}A3Q!x(Rifb9>e{hZd>Q4u0b%JzzS z%~!8sA4FgmNBg83ehoWL=Gy8wVQVBD*0O}EBh!GjdXJmzqg)`J*Lw6HYSijE&9SB` zK0^XFgx1-0_7+ZtVsT$PE5&;`nzj@BhZEZBwKH>`5{|w0Jvgln=axTMqg)2}xVG}1^A}Re2eNfMc=Kp2JJk#)semzdvlO=gH z$~QKP)>c0GZFr8;_B^xQ^2ZaUhDFvroM+cxwcAMN_b0NvNrTUjGa0mlh|98_taElq zBH=m2Sw%0Kr)gHbD1Wf3=x~n--DcwRYi*=C@_-^mcHbRSG5S6+ZX4f?c$1M?F7TaGC96aLd_IitBY0_T&!lTq);3s zk%OZB0!Zuau2iFo`XsgX`;8(u9ve|vE1@QGZZ;qQWA!(+A`ggdrtF7E%MiPC$?n%`oo{~GR(Vb+<@Ur%EHz_}A9a)@tLKRfBTRUy!rDpW*(qJ~6Wt{QZ zs`*i7Cpmo&`&jX864zm0JoB(-PHOrlv!USfIeX z{p`__U6rUZxmEbt6m6_>OgHnUwv@=teZ*1YE|kj|?djk28uw9c{+u|c)$HMnn~*y-A@W_+7ov6d!P4EGA*H;gsOHnrygRxtl~f3PvYy=m4CdH z;b(8Y9rE$No~EL>Jq7=@>&VyaOG6Ew`j&i&2a`gWF{H?^dFjcQvahhF=xbbioqMb2 z^038TdMjQYuMVk>zYmL|;1RcGU;3~NR*1Hf_pN=Llt1bRxx>K^p(#r!uRCZO{V&n9 z-N9rOnl29*{mG;`k5rP$&@vA)GI=~=@ELUw+*7*fEv)f$&!4qD@_*<4H|4hik~coT z{?)o1d(&+gw zouUnfpiidq(qFc}@fH@m^cTqJ-Mj^=+}q5%FWi#XBVsPM+-~U1#{O1Idt7ySMBa&J zPgO6{!=c*Ky9;|>av#1G8xMv;c}QiwRY~*rYV1)LGKUxN=Ac!xwwLWWc|5j1%A|Km zrFH3KXxZ6E)M!0YY5hZQQpwH^VkVWBJZObhMYQctAv|5mig9}O5vJQ;jH3lQ|Ykc#*l-RAQ|4{AH%hi&G1>}cbe?|k z?>#PFlmnfdsufP|J8r6XdwVP?4WvVqw`bFOkGNS^@|p!r*E^Z^wtlaVZe5-?501~q zp|)|`>17M8IZm@0GtGlvtMJMG+#l|^m$hucDv-`73HYWm9^%rGA)%5m>3PU?@p+U25!w0H-8en`&ave z=1J=~-+eOktR~`l^N{3j4eGypTt{Q8j+C?C+uNo5GJf+h{;Xeanw0OD#)GZBozJlV zqly?InCG`fw~znrE|%2Z_?#DfosTaf(B8YxJ>#B{eq28Ox;32h5|*AVWalq!A+I~F zo?ga~;ZCP}S-#oMC^qiXwosrYRy)IbxM22bWNRxz%onc`3)2_e1X%xzM5+lOc zgU&S4Z5k-|oAO^dWr^`F7kb-QCLjMBUh1e$HO0cRAt=_memA1}j+Uf;@}GPnpQDSk zmg9S*y z9rzj$IyM-oY!D#J+`a2p&#a03clVj;Zjc#?5#mhN&YgS5%Ze2dJF}|oQM9UMbPYQJ zY*d9RGM9JA!3;E4_chp%z+3$H0!YgIBIA@uzaaMXVuNdK{vF_Qn=hj{1|K7l^ zZyckl5jsOFUh{Buiu$lKUSG_REDtokQ}msS@bOlHnw@t}>fT_*88J5m?91bQ2Omc~ z?^MNHB^Fvjlht-E+SeT<==+7O%qpE=zDh#gw6(*m^Ll3Z)G54*V%nW;d^aF=$g*D3 zo*SQq%a^Y{dp-jVot@eBTi$@O)e}RWt0K4V>SkL1+=We#LR)^FtPaRat`0hh@(`d(=>NmT^Cx2+_}aG7M#zUtK{AyR+7X@~kWt}v?}l?{7W%ce26 zGZg#PyNc_be;0!yx^H@xCFVO%^<|iK_io2%XF6q)VFTjffpqcE_yQt7e_Mmcnwu84 zS6|yaI~6i~TfX#l!}tE}7Ja1MPOH~^DSCSc1`*>PsNl~Ky6?(Z&Pw%StN_tv{rDx8 zs9()L%h5d7`;az!bE-Z-+;O*tI@A77|zFbYc)HR z7mD9yo!->K7&*-EcQ3N(WWz;$zpW~79%*BUiwf4Ye)zU$Uu=-g8`LztuE1`uVd^0 z=<413O#=IrLCG;5&;CP4Hih3b-l=GPoblL+f5>s;NtJi$m(A}BDON6$?QuS%@!q85 z%X~k%tktenCfQ=&VLchpo}}n|kJmHRaGR&>c)uCxPI5iRfBlzOtEk#D-u<~1=)&Gt z`;cTfDc4mWRTX_rtG%=az;GZ(L#rcuSjCPtWc%HVwmaq70Ig=&CCAy78ka|yvqJCf|Wd} zH-e{Z!IP|&h4wX3D2pqXh4}!ZKIIo-p01uGxA?O9dB8qfqie{!(d>`WF5lX#+_+53 z0(PV6-a&kQjpeZLuJ zbosw3WM_1uJN{&sdWWkd@ao3D^`!3m|AL>WsP-t+t1YVY{QNwRSvN(8>dy5G&-#C- zG1yde@kL)|l!?dDSpvqY+;Lmc$j5Q()-SKf)@1wqE56pX624q7R0mQe`5ceC^`asD z+4Zn^$WNozc-C@TJz2sV4Iw#}R{`xDvdbNEA$1~^kI#YWx7ljdgilf@}*u`DW=@2qB}jBf4-R(S2^#i zCc5-{e{qwa`wg7)*4O==1ayDy@4DZ+nO1t5^!O5aW`B-gQkRA4!uK0V4wmdYV)y$U zSYK=A9b-dMaWJcrcUiq!I*hG5D@K+vc*wr%@tzefe#9QDG0fZVWf$M8Ox$s{Yc(tP z<-X^`a(;5%TGjRY?bREn)y}Mn1uJ(lPRo58+Vj0>xu=s)hx94~9jYeN?!HCy#Qtsv zyG-J+33^0U+aG3DZ5lfygt`yQv34c?VHR_na_IO1mxz}0G{_?amZ534tS`HR2=T@GGCNoil{q79B$S_ zETXK`Z_~>_*c`ttf5r~v>~yx>_FZFqy1n^wcj|_VyX6Ji^adrp#@etM|6Aemf?4A4 z-~O+W)S*GlxBWGeJU}`a3O$`W@YEBntoiHz8VSv5*Kewq383ROSJ+Qxc)mrH5qiT^ z%)>ID_jHkXHJ)>=@eV1grOPPr(0gJl)dRX?EhsDZc%B#Aweul5k_$%Q<-T#)Zxfr* zn>9R<$)9c?N2?JSZU!pXt{8jACF4mPzu@Ou526LUm`gi{N*1tuvDvGbtJ+CB} z{o;mVa&Px<+*Q1vP3+wp!7#h0&+n}tPZ-4oXTRyP3I_s{%T7*7!tF3&6vF*)QPdk2 z*Ub!#Tle|R+1^&#y?1HxcRXc9QY_15pN&2{z2(<{6}z z>?Q1%%~;mj_9100s~Vv>AJCxt;V-MPyp{Jpxob>e=h!GIs%5OT zYA$5K5Q*2v+^7}Ecc%o+iGdc|hiLx86Vtvco=cV(=WD;KU=F01w>KM}-n$T1DJqPH zAad^O0P+`qEELDn+IsZ1@~(@RydgUBV&C1AYr~i|_ggmn7N1p@iE8VF*eqa%@o%kY zzW=YQL9VPp!>sBjdO_RUhqSI)>V8`cTJR5hWeYaE?>nmCvQAH0L)>KRymy|+Qq9K! z*4_L6JUB*fpK(|WFf4lV$#&-6uIrcVQvPxkzIINM?;4e>`0A;BI~{(DBOD;6k}duu zNAo6KESLTl^<__zbfVbo?9uhcUv1L8tkS+kuvy28d-kmI`TkkOQ{#)X%I7+;)$_N- z(#WX+6#QoXwiv6#-*A|o7;q=>W3?FE{oY<@F^F~b{B1ESUL3dLklow2V?MU5{#b;C z=I8H=LG5B`e922?V(UNOc~mF4zii-oeE9G68)K{do;GSj}@w^p4#H0^XL1`ru^ zsxN4js`l)SE8D+wlWg2**H$P#(#&)DKgNhPH~OtP?)(4Z7H;x?cEh3GLwFNaa!{=>33F?riaZ|NNb*$cGf+upQEkIZrA1Pv^D!*Mi~a5&zn=0TT~@O zb41t>L-(%trgECR{bFx5DWqBNThNd-FL!OdkW)=HsfLB{c^YJu&k^!!751EsugVCS zx6)Z6udqXR>nqCp&#}py{!i^Yd&My#BfCL@pZmMBWU52RZ{|MzRxaora6DBET_qX* z=ASX6b@N)%+55&Yj$Y_ImW1<`nXy*Zz``L9gXRN0w9(dD?qJ`c!%VyP9mC|tCioQ# zm}{??(^aEvam-8xJ!Iz)y}Q1r_@-0bytE8mhmrPd-|7%DiRZ7U?q-dz?R&p{oaV{f z+Oy)~p*Mnur12`OCT}t5x#!roa@P0%-5I@?Gc*>0*w-&n;82zKpFiTZ(~Ro3Y0he{ zJIBs~?8|?>kIMSRi0h)M9d#n>L}8`s#i;we`LHJ0W)uG_o7_mz0yWG2&pxb^rL0>v z&Hl8q%j@5K;C=e+%WHGj^%Bi=(PM&rJ7PvUPO|E7vlrR34t(oM`-Zp(*mowh zeb$?Oaol`f zKdma_JG!z>*P@z?J+H2?=JU->^@aPLx3kYv{$w_r9-$zK*}zUQq^rK0+L$Mwd_d~Hg$}VoLeUM!0t~G4lAnTrVDa8$sTkX~M$ZqPV)cpf4}%x*o?huF1d2y0Jx4=rh_{ zu^(L6KXxnvW29Ngyy|v$iwIYpu-%xIU(Y0VV$*D>n@!5C@X3yKa&bYKrnQROuN*0mivH(#-EZG}t?D#$C)%#nGGc09L{_aoWg+># zFF$X>7AfyM!D?i-8h&-J)%vN9JDZJPAk7M%vFEB2|D|fEc*@YuRSd3o0{tAHCRs)N z@qi2!lkHuwQ58^?Kg;co)v9x$aBsP+yKHLq#;sEyV~6!C&$pEqPs?My4Oza8_t@6+ z_T(oK#|3-26g5_9LJ1 z)lQ;SPc*4l`6XFzvfWFMbwX^ucYygKFPpW~B0H9)_k8<2$K$eob5SK?W^3aQz7m1Q zHs$4P%{K7DBE4lulJuoxozPYsr_xxjF@74SXGvcD+&OTV`;ILoyWRl6FYm; zRGzLL8Z%&l?LH4z?}m_Jq%4yB#V6UH)s2(Yu&bh8#ei|yqRNjpa#Zu;E-_7tRh=JPY8PC9=3uc9wcRJ#saCs2nt_Ad*fnUycMl%yzFGHoeA;PJht=P zBG9_)`C`iTWu9-Xey?Qp*^OJXZ*9UXIx`cMls*)-h(Asb<=bF*+Z@O+FL2nNW=KhT2+-jQ!R2{NgC0U zM_4zWsnbb<{L#9qpH~a4>%uE}A$>(uJIQU0eQSmG{eR_5w_;U(SnAXP zBKuOnO690lV%2h`y>D!JVqEHHxN6s!GJIRxDZ)-dKj%`2{gO-Xe1(nU-JQk2z3gY@ zpW{+;$J^D8cD>3mePyrs{FQ@`myf~QGy99j&N#@CT{p%;B(6$ymu3Itmv1wyUDTxW z`cC8f!l7rN!>+5eLq05IV^&!eQe{wv+(}$Gt5dBi+6j*5+U4NtW}@Z7(2VFP7R~~zJCKD{wFKx#SNr|DWf}YSk%=A-D$99%3%-KZ;jv0*dtbf{Zghg!SJYMy{-3>val`S9G(x8HeX-Se?076VC9 zz%UEcy+b*V;DHcNw`TZntYfcE9$xRLXxaBa&fh=aBN53Cm?g^(8QGMDS*&geKFZ+6 zm-IW;5F_rv3W!wD?knbQs5!^WfTMQNl!li<^f-g;inIMvru(-UuMMSak!Z=jVQuoi_Lvoc5&y9 z`OmL9aBtMt6yWsFXgYA z&I)jm6{-|?s<$3?Hukv{jqz2sV*PfQPuU+r_T)}NvIRfdd0&azyZ#}D8+#VXoi1em z)l7b>hV0q6(r6yA&+>NHaCf8GKZ`F~_`6J4-4kZ`zBmjcUtc2}bT;|g;f;;NbJ|@M z@ug1dh}?NvC!|<`Z>L5P-|MlA}>-**R`V)GL_psRx1C5S!*5#xZBgNiqY1@S$NH^)c_=MwUY^4 z5lXBnn%%d*ope1UO@8bo_F0`=&=co3iwznzqg>(1)fwj2Id@fFf$+Njo~j4(-1337 zUVop3Czrw6i7b5ktAC$`bnS%VZZXT^BuC-$U4dE)t7EuyD+p0-Z!Ioi1_ z+e-ZoFIx_oJ!f4usCqE=bHCL>D);57Vsc#;xmiChbM^XLEr*sWa{FaB*0r}4NuTwz zRr?HCF)lmb_Y33a{6K^5mqD@`d|J5(t}b7_!s_%-imSFet18RIizFVpLmhmPoqX#a z&9OHNlpT}I9_I4j&YGH4&CtE(gqDr;Jl~G(R=e6`)h?S98Aij8CwJmQ`bOC&jp%F_ zTHATo7q$7qe|hUF?+|9qGNqNQTP|_6FC@Zi?m&jH@})~I)zLqd9{rSXWX$W%$^&ch4b30&&a=Lz8}Rn>xm&6UF9~! z(z!3wj{YLA}B2dR;t=>Bg8kyBsgKT(zr6lGS1NPfH9M4}wRS8t%h4w{yoLr}1 z-F>-et$j&h=V1fl%a27+XBYSaBCAQZHoNZ3Kg?FwSi2po*7kM9#=kXde*a%TTK6q$cW;Rp6AX7cRkIGO!O0XuB7w;-+a$`zu? zoT}!b+mm_xs-fF;XTIUGs>cc!?I*RXxA!>yT>Eyqnygq5^eS2RSmR!TmY9u>ZwMwp90VdKjY}&tXO(#j1>NG!# zk;&EbH$mAhzOjoHr=i`d?k*SA%Zjf!pj~4*PttIa6c>#8Ufi8c3q z_F0-_Up{T_)xg9+gX+kTIbpC?i8wZ@+HmtR--d$ zn8p!htkz+1-G*`Vd$YLpTL;$bJYL7^e@^wFv)ZI#X{+RQS$o7j8~rl3{q*VYKKsCaZfzO>VNuSTfKub6#EwI@wp1(r}w9_BkSRY zvd|*Dej$6R)=6GIQ2{|U7OINbo!)HsZ6se7W#OwD2ahCeeFS;n%S`CIT|LFlJY1*0 z_m^cLIvftz;CM)s*{_VG@ZbBLe;F&ERR%ASF5RS&5d{Jz1TiJqF<+6`Xh43&oTf6 zVQBWCIbCt&u!YgZid}n(y{+ddmZH;kqCtq#3$R_fJw0VT`&Jo4SN2+-_EYaHSb)B? zmaWzH?(q~mCvo|us+Mf-C0Ab>y=rDgQdSd&y}iv7u~xlv)v)nL?=0lRm`=kiYyIgr zzPJ0sPk;3;_0RuS`zyDQ3CAvS#H5`uJvpG6UBY@;iQm~Gq!fu+j=z?*LO6fa-`{xv zP1BnP=oWgQwrX)bSoVMJfDQALXNq*^anO_Gou#NR$_^?mmL4BI@6?e%bWFvPr^YvMsp1@Ff@J>^f+u%Y~7{;s1;LpnBT{TzN#t&t95{g(l8;!HG z1*=;X^7t-!#VZ>FhT~OMut#Tnnf7Ik zY|)wi;-Ve@>=(L+tzxM=iwiuO-}`na4lVn?>MVv?j^{qN;^|dhU4402aB4#4zQ*G} zUa7B%#p9GJ272Ru=!32*ld!_-ueo;PP#b$!QG5-xU)G6No&DXXs<6L$Uk9)L{BPcW z&a2{@Z(=`o1+lO`8r|wUT=zL;%DcU0K?A+o)b9w*Leqm35H;$2FcXXI~i47xG>l_BsM@+2xh#u!uZ}h5zW|yh4=l z671pGm{3JgO~nr3G$f>5{JJ<@{8-)E(E24O!hF3@I%R*jfEV}D|EexrZ+xAZ_#_Wz z^KXwNTRCCu^o%<9!)LWySz=@C$vQ)8K1f&c@XJ_a=bR^}NG_gfYJ62TYmSL;e)!*a zWHMHJhDOp);1)Ds<*&dcbK|QZ>Uu8J4e#WBDg*mbc1% z)6jb)YgCKZ16138p3~%G`>vOX8y4kI4R7?RCgi7YW8FXcVBMepo#a>6<$-$3PAYy| z&HlEU{cSZH=gYXit!6P^?4;uUk6X?D@dvN|{BII|&Z{~0|0Ckx?gmAhP3EhDqIE1T%KTT?A9d&qdp>0~IARY|RytQRR;)x%z; z>0a~pc0|v-+^Aw|ebt;5{6q_9R&t>|*B_T9@AQfu^9EM4Kke56 z(yDrB-AcW!dF(&?%Hc-VJGu|uEY->2z69xfLN0KRCwW6Ix97CZ##aNn=Zg!{t-k2F zss-y*Wt7>U_sYWc;#I6w6Y4Sc;o(~)fAdN8LA@r)>JaWkqnZbkiQ>)(I{EB`^!3i2 zz+y6@rSndLB3!Vuq;t7xB0{7-GXrIuw<6hTxt`HgBl->f%}1 zy?zo)YT;M_!rW_>zLc~3Zl zprLv`%Uk8@dNcQCw*AW_b`eu;7SdZ0S-wWFRZ(rp3_3sKh>EF9THiQuISNB==(Q!dynFaz}vk5kc zEjHTk7vPZm)?CuQeRyRjnVu`#```LIW01C0LtCSeXw{wGHj3Lq{Bg}N7kZix>CRo2 z!HbX37Atj)^C8#jF=gE^rszBK*l2a2D1%G!wVu4lvqC3Q@%7FMvW!?AyQMqCb|Th^ zW03;s*@yP=&tki%v2PIwoxBh_vdnB}yq%q!@-eNA7CZTSCj;GKGb^*ObzUv(iQWrh zgRM_5em8$L84b$3Wh8yI`B|u)dyBMFUK+q$HjUNG`ET#5Cl_C;m{fN^z5hRd;R$zp zD@WgH*)?6v%z~}xgexms(L8l!|FZjhg0ZH9J#pd?HJtVb*Iy8?q zs-`eng-C zM{gI|l~$q4-g_@XHb1dcs1u9r&BG9t=2bmbn(TR*N6dm0HFWVV*Y1u?zC3;>#kAzD zGF-Y(0$7Lv%AB(1#b#B7)#V$qvPEyr$u>#TO)io}qe=XP3zXOM8=$H|i2;FwL?TyT^@^+3voCpji?e!a=gCPK2b9ai zeUS#SG*?@(PE6BWYvW+!bXg4zu+xZy8#N{aCqon`45kSya$2*3SCSxqN_57{0S6^c=&Dm0%RgXvt6ZU=jRjm9qPys(!C7l*H}n zU&Q%e@6XVXgivJ--m(gGmZ3yxwMDf^(Wu5U-j1ZTQkcU_^ilH}XT55ax}mscxXiCS zS5?n{)@k3Ny4c!JuUrfLc~8U+6Rc5$h@^7}DCgCtlV%vPo?jqP4BKz(A|E(-0)46OQk79B@ z?_^>%AYEkT_$dyfWwNzP=RIsh<6|6{n`PWtj#D{~E7-Q5(m#|fpH*wY2NZffgoMC+ zqz589EY5dXkLYniwVrIZXf0Y;3*Xwin)2*f))aTn>E1qKmNjrRX^Kgf78NH$ ziJ?-qtVdQD%KDU zdAtaxC4C{Yn!-9|nV3f$>})Ar=+$>FVLjYuS6D3OtA_1Wj_7ots>*JzL|D=N%<|T# zn!{kztgP62EE+!Yh*K~!rCsuoI|hsy)1KCy8iv-mAY_|IvTDxsDW8ZJI~Nb;i5WYr zLtz<~OILhgKj#v~Pw_#AJB@T_dWec@rqvg5N}U!dt-e^Yb{MEXRpsYHYuJMxY|Bzv zTSPa?-_>=Z?(KbsC@5#JBdLAY%c84Fx=Zis1XpAZue_HZSGmk@Yy}bSiVyOHzxcs; znY#Eb1LkG6BSqgIXY+EFwXD0lixy*X2#H+PUBv}_vF{`ffOv<>mx-?{nh+@P1 z_Q2!CLm4yQn^`<`_EBE3ZniEavo$_&zg~14M`PM9hWM)}^HdzRjE8MIHRw0ldJl*v zb*iLG3TspaHpW&(C%lj?ycO9jUsX{x1m$nPw6rcNuD9FJweyUu8V0SideOc`10NO1 z^R2zB7u@sI{cgRVs*HF#3&f*~#Sp=|&Nm>UGc&jfNn&JY3Cj|prCLe$@?7<05!hSt zoj-=#YR#cJAF(5;Sdiz*(YaoktxPZYxiCb)(q z>?O)WrFe7|m*k&1$*@rMAD=ePRy?bgDmM7!Isxm)%)?W8C3eXAaYy(TDc0*NyLGYc zIQHmFL`Elnl8>v$Ebd**!67uLH=(Qgb^O*dI}ch0t*hw0(-3Pfd`3d}@^ehP%sj@- z9_Ek{R(4kHr?3-_*sYTbQfB?Q&n$A=ZC@4_Rg*v4v&($U6Ku{d`NeflRT&P&W9*`< zw>Gmn$;4nabv&03tnS|Q3w3tPAK5mhbx&-OpcSE|n5_<{`?;NH2s&2tGPH2`qbx(AsJ+n5MV~cob_1>}#6lL{J-$>tI1?oGI zWbJgVcqH?BXQ=P(pR2sWZnL4?UsqU^SNXJVX9m{2QyVA?RpIjO-BVV>!m&f=cKDcu z_ZETl_Gg2g+{cR6ue!^QEWwWX0KdpKb|Yi_8`6!|?eS|C%-_v)m$l<*$SBul(c-E2 z2{XL~?|LyOT10*v%#Y=p&c@^MEYiDyGIQBLj@Q*rCU|gNS7?8$uR4bC-o7*|US2H0 z0~Uu_Iu*z9x|NDgK8Rgjxl5$RC3V2!O+1I{CtAu~Fj;pfKFa3sOx|MMn0#8-cjs^z zAs>wiNtQ0F2?H#(v zU%!@5mKBO?wum8%M!K^?d-r}Rdy(>@U3`?MtBRqUtaO{-*cTSC#ojZ(869IKJBOwu z-g_Bo%M&ErL9mXiDVfq!G>SPfdUU^`XCT3yAv zdK*Tn2gYf%qM`A6qMbjqPT4Tqt{x&&{Ws6hGVZa0d-V(|0h)&0W&2JK^oK>hI3VSn zsj_uANWWFeY+8gicYBGw;f77)QuruyRGGz|v2Ek=4=jvJ*>C<|#VgKOubs=;(|T-F z{Mnl|Szxtc7+FtT-d=8H-%gn4k$7-XkWY#-Rw7%Hu0pXwkoFcjzc{G zk*$Wi_QrwKtHG-wts-;BFIC1xbRC(s$_`my94CRLWX)rBhXI++GGJ&5+$WB|fr)@y=Jue%WOxI8Df7 zMYF3=tDjz z`8#zGAN3_=SG(_2rya^NX&iRq8a%-&dmAw3$2evzlUQ+mWma6KAmehF>)4$x?ZDdE zbnnf_@M+opi$>DIrJu6_+hGZqlF1;>Gw#}9Oj2#R8m6DZ{p!3jR@K9-Q(asZn*FCU z#-dx2#5C4T5_K}{FArvi7|*PJ?yC(rieVvx zbZl823rFh(Jdtk0sJWggTauyv%2P!!tEOAgkguv<{^UcZ?HTjod|xuOYSoo>tIs{R ze2g{28XjTkfwnq9-GAm+A5{oA=JViXu5_vns6_>p_0zqf3pa6(UGjKx^yW@{WFaV@eM#s} z*m9@JsLlYtlOulVd2>Be_9)+vO&1+mwiESLFJ+STh_NYMX8&>wD;J^ZIkqn&kLMO8 zIZki1faxwCrSI19xb)hbo_WxBl)fn{rz zG3{TyS}g`kazPB-17-F^FzRd9MOWH-BCP8{ z$}O=g=3GTnX2o1%{c_ss3)Uk|RStC7&C_fcR~|Qof7!vE)iFgQ#O8_R@fdjL5A+Xt z)gH43ZQ!CfB>8HTdaJD#c3X`N#iccS5);v)8k~jW3*T9YrmQ(WXH8zL)3XlT=FK>; znbx@a%nqG@EIwEFRzEp?poJBx`B(9*R*qfUho(E_EMgB)_T9NuR@})(5s%qvw5k)n z^9@Yrk7Wb42(PSPwXoAH&(SI?X5pfYhLeg`R)3xWkZ@hEDBNky(3K1jLfg*Tv(Dn1 z2Ny@>lto-R6eXS<=8IB0-zgu@!LXIeGVF?BA)eG?Z}oohG27C-vzB7Y{;bzHe%_e7fZ4qL|X>9-ECT!va zmVo}o=e6XY$HGqajQM=h{ELOo&{QPd@eC|ZcJb>DIib=xDP+B#pjEgXW44QxhIQAA z;l`~rzO|0g^^mRDZ(25IPNS<;C)MU*hE{nI%^#-(GO>_Iwa6N}nJ7s5b zkufG3%N|S27rnt}(ar9Ziaz6j);P>`F57-ABFf6cp6Uc8>nuC7!Yl=8SLMw^dqa>M z>-wNq{93g;^+otPud=|-v8+5)>bhuTES?NaA%jMBM%I=|VZB|GEjCjZK|E};zCGzU zw2se;-ncuzuS#P1*z53`K6NtW3kR$FDv4-OO@pym(Rlf4-2gAJ#O&QW$Rvg& zJFaKO1bkbC+6s%9BrS?PM+P&=u-edGtNYS*97M zs%ZDB!{~MxGjka79DmVndD;%)gPx1vn6Es6-$@u7q)T~+Z+CvxEUc1kS1E=7Jl5`q zzjSSHn#030^Da|vWmYw3^~SPVR;BUYp$|cxAY<5_hxn;H2s!1)Y)~Dy2yiF#u+_XY zOjk3pY#v#zkkL=dR#59y*Fh2che%iDHxbr5)?y@y%bfHpZe!H!6a(A;ESP5(QFh7d zA*cSBFZWGjY)<0E-)blFkYr~jLm3XFCyux^o0{>y%495Y|Jo($nD6w;pWj5+x+>N>vj{3^Px$>nNii>_lIR~J3)`}QuO z)t1v~EEA}G%xFIxQ0}M$s{SHJQb7s?>+A%x`OF%FFZ@?4`N@4TebrDo?9 zD4k8qiclk_iYju3TM=Mw9?M=y86wHs`zCQuuW|sd56v<5-dYrG)dbKJAI_(2TGeDd zdx~xTCLcVHO~@&JW1s$Nu+{Q1Ys-^RTGY2ncGW4tYu3(^&5LKtvut5C6$icG9mnv? zaM!+_O;jz2PEXZE$Q7)bH@lMz-N9$HTzyyFSyr~|>TSAL1Grk;=EHH}ygWX$bI}(n z;*^b&kDT-OYW`%*m-YPN*&1-QiUG2V$t<^OEfx@~;iq~n^cyXH_>DZpS{|YYTNG1S z%)Iaun(7J4dS%>J2px4K`Js-ox}r$nLt4cBuFFq+Kw6TQvB^`GuP+YM<-5fyTdcAv z%2pSO*8C*juSWnCu8^C%u5J=^Gt`&N5BLTJFN@^QG-kZ;m zwo?OA;LcUl>(EGHw0*XAKA>^qcHC+1^0Qr=AJ?qc(;<@0GrKd62=PN}oyBTFmKlo6 z$?=1%Vy!ISN>wX(rd_h#b|LdR{yfH-<1E7tZ?`Ay}>LLt`kp4b~dRj8vn)O#^C~zig=FJ&j-x%ZSo9ub2sD1ZZeHtz+?_EkERa|w)BdaXc|K{9IEKNX ze`H7uY^S@fBN_}0JRm{D(Vt1A-|CLkhts9GR+AqYwNvIu9 z(~3TOn?#OQJ3V`1yX-F$k=-7uz;-X@%D!yb39E>CMZx-CEVQ%7GTxXpYhmH+L(8+Q zU1Mvu8}p@SI2;~GVTOB<#lLx^F0{AY#B&Z9y`(Sd1bLi!?m87nz32n$%H zbCOn{)M(F(mYuYOtvRhS7{WJT!1NE!R7S-iG zwIzM>(O9LJ5*=mBEU+%c6Xh-Tw0pH|{g*h6-Nu&Xto$u=xLZeod)eQyGIuzw~ z_Io>E-_UiZlI+8B?j&`#ikV`47Gu$UOUd$aZ>vETgh0S~eUVKTn1qIGOHx|<$-bVu zGbP@`y=>7ScJRP^RFS!=C)?hP;4L|Lg|J9Iu z=4nxrJYkjX?CTyW+}l?j;c0b9(avM_IsM*BPR|#0kQ96975loPnxfJAiS=ZB8LKwV zhrE{;!n!!9|HxzHWG@&YTX9wwlO!zePRPpsSu8|#^1AxG_305iLLV9IL5K9i=j4h1 zLRnTRhO$enzUnH!S$`?-ff=|B;AXq$u*mB@El42e_)oEDnS$xZFMOZ!xkE@!hc*8@{&7P}P z!c}<(7u*R;R%d}~g?Xud1hdC2yiF2Phr5vACu{Jidy5iJmCJW3S4N6o(>%+jO}Jc# zHU!7;_6SRF@2f65Bs275y{ZqhWS zq(wQG{KaEd!Ul3+akowl2Kn|Z{k#)uSyO~}#}iP)9`SPw+xWgDNIyO^jtA_}NldKI zcGk^X$x*C_G}2#($-g^UDetlIuoPo0LziC{sqt4HOO|#qYbP+t9-~;7jp`8bfxXFu zHQzq;!4%}711{g0eRJ3MEYs1mhz~LC+MdZ017M?emA#4yXtbBvvPyQK(<(D}&JOJE zef!({bM{Tr`cK&c0-oA7pLdLw7t;1l!e2*W#oFuS#^YJoopkC{tCQ{RIm<7nw9*h4 zXWn}Wj6~mI$=xJ=>YevXoqym zqWRb^`5qRw{xTsOjcem*Hp+H+vQ^fdCsA_3+cG5|;bZIXT^>|G-*E_b=?Uu$x31YM zeX*xijBc#2Gtv&fy?w09=5SXWJ5zTh*LdEw>Y)l&)w$8~Ns)maHlA&(6E6Gl3X8B# z7-pT~I5e<*98%t>@ViGzo@FhUM6X- zs2aCVuB!GjcvX8=Z+un8__6(1hkdQe`g zsxZ>gy>()pNf$2RN+s*ryqYw_TL@@vzO!;!)|k7h9J`U)7!P41>o0E`$JtO7t`>(@ z!|z386)cIv<2nelv*CEUvkvR_{hq7)N-D1#TlYdP&rT{`MV5)jTz7I^M$LXjQJp)~ zwqNgZt+ew?_Umupn9;X!uGo5Po_&qN?Tx~_wOIA-V~5TSstZXI-_d6){d`xTr>$SHqgSTVM2cI+EPh>pZ)fdT#^MOr(b6d{7j^fpJzY zzlk*#HA;54AiL}AZSLyPw5Y2zzATd#yZ|388(ZU-&f`f{yJ}oBde3GI@;`2cdqgBvPXX%hp*OWnZb|tEN5AJEc3L%x>Qfp)s7L% zc|w-fXvWwl035Z}`&s})=MfCN=n z`4lpW#A2w`UZVnoIcF=PvpmnUfVH=icww1NbhA>NMAyD1ag}`f0yqtoyiuOUvVJ0E zc_R<#p^SOr>i;;>JyLY;6SBs7Jp*|~9{gIp`0nmb7GMOcbdRm74n?LpbQ^OW=kQ{Y zP~>4Dd+CGl4jW{5qqvzhHo9nlAiKwQA*C$aIz{3uTkr3~&nML!vAA8r_nz^-{PTa& z;Me~P2L0=9&%T^LXaMWHRcwghxA*JnkN7?d-nT_;N3L|{edv+ncuzjcN9@i%<;QAc zT3IjL!)%@KiI!a8?xdsGV&QKxR&8vhn*_8uDiq1IBfH)=CZj8#?JX` z=T2(L?5R_Tsm2M`>D;Hzrgb|1=C#>u66~);^fP-@)!-R;s|Ipb)(-PLOk1nb#Z8?u!1j5hp3^Yzi{uTcFs$zz}GwF!!(O) zalz^=SS0dAXso8~?M1OrjL;f4rMG&-3gyhSXm+tHs&_^a?$x>E>uU>pi%YDxZaHot zQcARJ*;2@66Nu(V9UHRm_6tc4#O~y z880i)#NIT3j;@R7u*Ht65MoPnLW!N~&B|n}(amT)`D4lUb3X78pJcliMf6@81c3MHG9qYAp-hTS}ue>-v6TNKDz z47@T7=4E$Z2+*4rY@K~N)5CAP+}EK{Z=c@6@p>zE_Ee8nh6|4nJd9(iYMMs#0B{BO!#7WMlQW-OV$t2rsUxV&l20fBRIaWdF`0j6(I>`@KoZDvgth z$FDl(V^S4UA;jD&;Fv~zN{+@iJ61FXf#b@(6>7Ym5GF&oQL9ZlI5CF(lI$d4x$U)Y z+gSp085c!aG7G8p$__f+Y@TkA5*|EXq@-DWq0xF~wh2?ifi*)^dy%%eol>mlgtNMd zDwZq;Uv-O1Vm zf}JO_YTS;S%&)^t@2X}f-cR>{TsH0H;%X?;jsUu9#R&fDYh%TRom2a+k9 zk!dT(SjkgGj8)r}PWx>?&(tT`O${XD7+ts4cwY77GK=wbEOsl`%{%d9HPv#CC(4*R zH<=}}W_vu}o_Io~#p`tp^~AHBPvi1w$cT?2IDHy_zWqs}##w%nny(TZlH(oL z>rQqp$3GG0d9sRYlCy$6_U3qUSScOaLmaQhN{6}vG5hRjW9HBh^R!O0%g)EmJVk>r z(px+dbUG(Pkql1ldGCIUs3PB;>p(ocdfFXN+o6>p(r7$i2Qd5^-@eINR6!IJuo{1r zS&d=?{`DjVo0JzZU!GVcQ?=fCuvox{RYO_JZdLm-U3Rjon2zzQSd&~_P0LV0`*N%G zukxM8?$kAQ=|pen!!Bt?T7FhRhn8|~QG1+auQ-(l-aekrXXNJ@=UvuhU$r7D!nU!Z zA8(WUxTt8RiSZ%c8rh*5297-$hCK5`cWhdf1?hIoTRRsdK^-Vty^g}4^_*ov!7wJj zX7M|9Wg|5hdt(c8)1a!NPR?$t8`)q{)5=!JzvWi5W7E#7Vv=-gt2=OydE}}ihmsOp-$%3ST2BUe6O^WxO)M zP_0%6k{_$!vQ%7{_Us-Grnl$!?y&r0W?7&U{7yWY563@p^85Nl7EjZ&jvBYf2<;l) zmkY`AtZB-HM)7iJx$iw=l}-bFo;y5(jx;8KP<_ zYq4T$y9*KL+bjz&O!qr>5;r zpJN=_tlP12=iq(Q4{P!NkO;xBMS|}2hNLJpW+s%xxHMZdkY_ofQ;&E|Mb7R;W;<7< zLk8q`F9gdJVX>;d6Ox!d%azY!m(F2yeWcy&LBx&y(T01?e1Ja|)!CExj9Y1wST1CSD?3rh}?xbKjV6j(b zYz9jh_aqyPnc6ce-uOCd&xo@JiWwG+rE)iL~0 zX70>%C;7`ovl?qJw)Y-txC&*ow%TlwWmj?Y2c{vH`TUoy;bUDuJx-N>5#n^xGgd1S z!u!6-xCoK6SpS)s*j%nKZtvwWF`G?vf-pzEs`w{!m3zzT%`m$@3}2;{tm!Jb+oSWi z(B?Go?R^i=VYcCeCh{~L(lT$vD9ODO@;$xUnXcE3?9Bt=g z#C|G%V!EA5vi?wOR_90d>ZkUiUm3$4x@3iLQQtA|r?00c_3*;m#02v=R7k%a%RuywfsP9j}ssHL_^D-P*BRKEY`x3pPyN@^!c%;rv41 z@CG+sb*>pdLl}vQkSxT$*`)KGBFR49IKI8Foeo(g6gla#q9_fq<3>or!`c;T;c(xP$e$79w%7CYv%4qfO+i&n{w@pcy8Ux=w*s0*5|v(@r+eM2lW>}P3K zr#Y`ok0O_k$9Qb6hRo)y1l_a__f>?;qNg9bwi-MA9Wy_ANNMl@Kmt?_m zW$jL0nk}|e3+9jM%%KP#IV%HYS05Ez0M zkF8ncS!3^9PBOADeqGC@R`TR!AmeWznD*r2glBj%e(=*yY|1$6otLZgN4ah>Qw=_p zSF`M#FXXQ7Tb9oHbq14=7kgvPS}f{k7GfRPD%QrKF*S*0clVqZ!?y?}Z`KS~p*7hS zw?!L-EDEujr~0nu>RU3ld!AdznT=Kv)~Bw*BK4|YyTfAMcD;8_G);&3n+Ii`_AArJ zlKIVeJI$)$KMB_xE^CC1F{__C!x>v=x!%yqkR%LoVW?hiStEU|28r!~U+%lARn|u9 z*l8EeV<=gf#QCZ0)AM}S+!?N@ZcS{Ny6R6gWeMA9cO6&e0(KKWujSu<8JZZFm(ah7MVV;R@pV1NG?ey`^| zB0-#y6qbvO5X%qC=seLk0jh{RC*xI7v3E82I&8Ajk$$k%37U$uaii_Wr=e^-!S3~N z)uB6A5F6$8x}(1N%}=W7tZn`HqJAa2#@{3=GLEY|S)ex$#Q^D(tX)?-&3E{?d^oOw zsbZYQTZc^5viUzmmDS_MDs?if-YBcVH5=JC9uxQWhlgUueh@(#U4%PvW|)+b$Z1yG zR_wKNQ4+)RJrCNWIlbxQo9Z=JdoRmUxUqGs_l?aft+bwD+_bte3l|+@qiUzDfP-kV zx}g)fIv*_Q9&eXD`suQi=c*RSZRXzY?u|~XS36Z7^h5|K^NQ@eC=aGDJwsf6Hj^Fr zbajTE!${eYh16C?i!rmStBRdw#2rZ>%cProbzf`}zSFzEW6(*uvxCEHJjyET#O+($ z#qO)1SO+`V%ky*|^X# z&uH$dTe6i^jEomokCO;?ds|s8Sgl?3iGHhhPC?SeE#LBLXWCWW*xh)%<)>`OkC-`0 zi{;RSW8CAxWgND&Q#yCTvks-6KV(>+6)z8lt9DzIh8yddJ$qRjv*ra6>8eVrbEN7+ zH4A;PNi*1Rbp_kzy|Z}LEUV4lYz&2O@6R<3wOPKiw)R-9>dxv|^E*3clWD<1A)rhm z8?xRk(NlE_`Jh^Z#oQB9tX`FDB`e1Fvn~D0_GIO`GJ$=n2Ff}yWurxU2oTXBsT0fi zT4sUG=Gb?U!Z+cRFN~A4>Qt}CCfSfIvGPtbvm*cC?C@mP`f&CgH`saBy&RG?LLDs@ z@%&n?frX8)|FYXo!OBb5ACdex27bci6XC8`!KP_mi0^tI$jf9Zr|@1Yq+{r>1|zST z!@d=JyGVL*0kdiOv<%739dA{s#AlewetXiq7$Wrfxzn-E8n819W#Q}{r-!gAkoBG+ zG0l=4W0SD@xmZlP`P9nUwK-VN6WOe)IStkUWQXL!MazFILU!_IxAri;Gtqc{ddxea zFI%pXu9t~%V3ka(47LXZFIrb;EprqD@e_IMarukf!&bEl93*+Z%x?Tdm-?7tryOn7 z{2Yp_ZB|9F^`Wdvbng(Xn$GQ+t%oyrtVNndaxqiYf>Yef+hZNO?X7ycUYyAio@nL# z>$H%#(KQA00P+5DNd?YD7rsx^3!KfV8dHjFD4$8j(W z7-whCvxZg5t<{M}-IoPOZ&iD+RE+Fy71<(_K2;3%wwmYCc^uu?YW<|NLmV0oi!(Dl zzpAF5W%Vw^WV5Q(a$5O*k^h>xlBGzK4~{QhJ>aL|y!HA`3|80C&vH-*FXqP|m=^|B zL1sga*~X2SZC?mC?mB*rO%|_tNwv7@YbTDZA0OJaGxJsqi*~YyxERt|7Rr0#evh{f zGq1)A<8tSQWznjtDj=v2qjpKRP(6mO8mhAjmt)y10ChvJIN<%FV0K|48elZ5PKP3q zH}(xuzK>_e(B$U{Qif=e$kHL97-5fjCae!(EVQ1bmGfHXB|Mwg_;z(eE2Q&N-gb;% ztb0`u4XYAkQU4Wz(>n`QN!h7LinpORuH@131+Nr6**EW4bM|0$yVm7~=`h%;G4G-U zdhAj4pEOlk^spx{g~ZsC>|5LQtiiu=tYX0`GV;A`4NY$&gDbY9bG=lY}AA|KMX+9A)1 zoMIqJNmoy_U1>EBlBO!Gcom~G2)Xow%#}=i6AM+Hh4r+pq*|g^)5!n)UXME%Qcm zNSGXB((qR{E=EO3UGzL=rz(ohlNWPrU5^$D@|iuYerP6L^6fUe;cjNhunjxNBRb<7XR;dc$ix@-JIp%4_v`U+7Z}rvPVgBB` zWZnE)=Utwl`MR2Xn_sL2ap4-;+rxhA_Es}>hqQYlA{bpH<^}w!WvBy|PEP zTjt6lN#%|e%W3DC7`4;RkW0f>ko4YMwoM(|6~2>CW!e!sP+ot38XF ztz0~Lmc5dg^dgMJyop)aHynoi)tfzG7Mpsa_+JgYIPa;>UKST+kDb|U#q8PXRHvsy zmHFgwqMx+(Vqp4rU;kZx>2C9IKAY$Byu7zZo=AgiU+2^gJOoYlqHA6k7tenUw#-v~w0z7f zWp$BmKKrbGwN6=8tR*vKl!wKkeObBpe4aH=Eo8Je3+%jpB%S)^g8$tMVLT9Tk6&Ob zWUVT^>PEh$?|7^JYAj(@C%vI+c3XyARk}BzF;S>3^OByCK zGKcxF8Fu3ISW5*#_s(sLlDJA<-}fYScSY-U*P@s%d^#qC@SUE;&#hTJ#t!C;8;a*K zgYhgf)}MW2wSL1YEQVyf77p^2c~!LWWmRmMTNH_dvetV0Dy6J1MnX564FBvGB3R~M z&lsx0*{PZp=c_~2*zy1BHo9S474jII&5P#M$gotNafc1yJU*h&Sftn%xqGW*pX|AJ z?N#=5+SPDzZ&4iL!ex0eN%Cx7tFDN9T$49OfQ;2G>-35)((Yd+*;*{oTMh9jW$fLTw0vp} zp06v(BYV43?5#FV^H%TlGR_h|i{rRF>#Mp}Cqj6dk-ZbEvS<8Xuh@EW75(E{I!qIs z9WMF}(e|w?TD9*iAZxNei}O=?rnBTcPvYMCZ`Jb1;%f|5Jm#Io$WxV24#7rvBQ#dO znZIf=*6gf-&o{pJEQ=8yTb?F+I1lxU)Nu(bh{1JS`7y*Ln;Dm z77Kawm5ZGlS#L~kyl65imt+Z}$xhq#MyG3gPFFiZa`jAXVC7DVR}YgfbkN0b)v?RE z*F3^TMME{SQ5Z7HzU=?A`d)*B^*4~*y`@jg9s%etWnsy+a`>x=O%snNqyIL%}wriu+KFuw2xj&|D zh0U!hXl{(z{rI1SI>j;i>``3rcPxujGuA~nyO?x+9JZ4WD~Brgb(`$iwdY)o?OK6L@)Ox2XC`-D9IYC2{>Z=Tf28*R@+}%AgB9zxxcIq zsWL^f$u(&tC%oNV+nGnl4ZWES2RI-+WVIBj_U?OlSyO&0&@T`&9dy4C7!hw7LqCQZ{=x;V;~y&3Q(wE_E)Ybb;@4`$Z&;XgRcGZ&NOf1wzQ4bwOQdU&U-Wn{wI~az>_O`B(RjJ6 zya=HwIVQjBc$u85-i%&Nw~8jIt?Nn$Ai^5OC5?)BvQ+yRYcK25&b`ji+{0Hf+fG1w zV$!d2<-5xXF@AM+@2fmTQ&uUTv-RaEC|*X&j(OuUdkmjFL`CvG^RT(@*VkszPSwn< zj%YL%G1@nn<0DVA+kWrq819&arnGORd+W1PB=)Pb%~El*m9w+wpSdp!;)-e^dRFHw zwqjPRRZ;Amr%Y0W$UoNS1vY`!&L7;5ZR7mawqXDkT3OtZiyRPJHCa}8GGE;1BaEe zxRv3qY+JPFF*slK&*E4s?PJ)gkuXyiPQPYBp1t%*tEH<5LZ0ju8q5p+@BZVin4V5Cc=nt2&kU?uW0SK~l}UWi31S^l zI_#9d{5t&@3qIhVcI*d*i#f5?s`0NrjfUmSY*v=d9;}e$lQcva@8mo*(yepT)`&}4 z+uFBE^?>Ia`zF;@timeAM^{#Lg}Fq#z2vvIU-*ztcG6?@ zZGBRaw5mUuS7-3Dk?dmS&c%2-f5D^iEbB_0Mb9F6a`x;nZDuLSi7n)+g_$w2H2T34D2l(JFG$^58J+dtd)mDV((H!>HJ#MhYtJgH)%+| zlk0TfnR}Y#2Xy#t@;E{PDu#;5-E#bl7`cPppq|ZB>3quCd{)MsjGe zO5A1cY|awxgh$5`JBd%WtW1JqKC700=(TTWVTNXWTn5D}b`!s0m9=-4)_yy6HD|1C zeY_?{$+ue5ISxd*WB$6WaaX#zLyGq9EN}Lx?^(4;Ci2ve(PbGWwAH~qebF1$aT|Tb z<&T{I{n^X;ADqBN_-3){i_j__$cA}H!vfw#d7{WJR+dl00=i(p&98fZp^8Esv}*bg@EcOD6wwRxuD~Wu#DA&ma*pe8+q%hTw=iOq ze}9CDUwK5ADRF zXsJerBp57rLJVKTTGg~!YMFQYmoKaRsufmol?!$b7t6&`D-U*pyMyth6q`_SHw$(am_S|}~>ZbHs z4LHwODQU7p3_9&&oATl^W}P*j!t29D+R$MC2FSRERPL5VlQp!CQ&`ump>fq%eyIvf z`+#d(`vf z;D@Tq&{zcSONiGq?4qq_8-zqZfPePM8iQz5grr zu9{|_-t&{zbMZqOmjhNST(uoHhz-apOU4oW!+T@6&N6p;{iSdJds+#S}lVaLv9nMBs?K#@@ zdX_wYxV8_Z7N??}&E$@q4^)j-bJ%xP5qtNJz?$P)*Zrl5?zgXN5&8BDFI5xMAHx@s z*t`7esu|UKv1x0>j-)oHNX?G<&-M7%I-P`f@)W1-Z5zAOQ`I__&FW>~D&Ejn)QBbX z?b>+RMrL{XgQnpzKj)cyAFytPtc`j;hS^D5Z%O#>I*3{5#~AVtUl@|)v>!W7=e|&+ zXZt>V#OCFstRHg8xN{)aw2R+ot9gH;x+Ck3^}<_T%j5If6Nyh8hH1#{{hhUrhasT| zi9wpJN+C&cgJTwx?67X!bNFDnb_{$m8Nx5ak5Oq<rAMhNRWy9y&4nrrpu z(v}6AR|XbIy{k7brpwA~+B=!cF?4SCutN@aJJnbPLr!bT>hp5BLO0xR)XA0Es9lqD zr^aO235ctm7(e$tblhI8)%|ejTD{N;`^`wBkk=@-V|Sx{ zgT-!PG#@#el8211!aXkV+*9M;^u+69bF)c)~h8Q0l`S_s}r!Q_Dv_7tDl=!uef{q;^9R;-f)H_UEjb!ZYj>(gSL`ob|& zwiX4VH-4}WnTzttWlnZwuc`?V!s(s^TtKlI!lVEwa@%A(uF36uJt0}52 zi!S5z`LT@pdC%e0h~(upzGXvR>suEXAdP73Y-n9}%qiE<5PwzS+KEAO@O~NdYVCaO+b{dZ{=92HsPRPp4SBRHlT9Dk2-#&EGpxpb*2FJ; zTgEp(hKv95Qfd9Fv$MRHCB-YOlvS>aqZvem8=N&n@BEji%CcD{&a0lX!{rpZ{lP~p z)xGXiYQ7dv@rFH&s`l-jq%jCU8BWL36e)_CB?F1S{3s#c8qJ!@b`?;k4M=YT1llX&bAN-7b^y$9UoMe8F1T zGG96&u!HeswyOJ>sr(h!k*65GcUXQl8oyOR(4^BM&n+hN7KXKd7Nui|E9!WNT%OCX z*`j`-(JaqHyvd8OGlnuQ+q}J7&gz~a3lF(xr6058E5lkF?vqnjuR5Xa*S}z>Ylj)L zR@brd?sOVbE?cH53x)o;H9XKzzPA6yNkl*PlTlm`!NYSby*-K(KFcdMo)~iO2_pMa4vuate zXU%K%D(ILrwq4YAK9YZz%|);NH2=~k&)bja`A$l zk}BL*S&A#ji|g}I)?GEa(+YdWa%1{rBj@VHVtCbf)^D|JF4|a&EZc=&s#TlQ>2fnj zl}6z{UJl1ae^`i%#VBdYu~yg#q_HxA`|akwe@XcJ|Eji%_w}6!E`SCasDg__6@hG% zjmKbVR&G>tvTByJ!hIXSFR%5=@+pf|d%G$>jB{lOqpSF{Qg&~}a@{;twzCTRkd;K` z#^mnI)t!)^PefckX(bvMtN&b2z$3+U`L1X;8U~9-@;*zzJUs(GQ5C73;9;SUVlYy1;IPLj3G-Vs}uG=fVumtua?JP~FIH4|7oMyRB z!>m)?-d7*hb;Usb>T8NJU^y55JU7b^_eoK`$&b}+es}!roSC6Z<^Gw}Od1hG@ zvMxIK!B2RRC#*9|SBoV5=GeEWgxze8k5pIqTW&YbOMTI81TL)~e(Ge$9MvQwpcSvM zB`;c|nr?raEZ??D)o&8poAmS^U%9U0X?)o*EbJVTy~SjDg;Ex1ghtS4oHS5v=48(& zbvo`tU2?B~7U!y|Jh7el%s7j%?2lGffBeEa{4N%p>f0ArTt3+eR&V=diZm&TX^1Zl zMOIj?jM>@3%6p$SUwA%_N|)8k)vj@KUC!$J;=4Df*+1{~eB-f}jFC^w+gFAx)*DBj zwSV3sam>K?t3ARu?opqKnEh>zu_T{W740lGZp?47*5Wf(ul~Wh`J5%2w_1HQGrX9U z^+{Nrj)9;-1roC4wvb(ZU%uccv*Y-uX6(>@u6k3~efHedk8#{<#e!M*zpl(rWQH>M z${(zlFRB!ij@65zaATeE#?D{xL%(D4WNmlz`R12{WE}IuOgI^Olcl+`<-qaS4L z?w*y!7Yny?nB-Y^q5b!Ms2+O#Hf1?7N%)T3eP6@-_OBL?!NNgxciu~qYKG2?8ZX1s zy4oG;>git6&mQz%O+t=wVJH~0g_Wv{qH&dXOtmu#`V6UZ0~zGdAB(&{dEsChA09svcAu5RfT1?I=kepAE++xoVdT6;aXG`Ny}-Z7R6O3%cHzg1~j`aET3gT zo?Q1(G@g}UsN5h=XO|eA#fpFN6Pwr>gXo)o%yzG+!@=c;&JnU*-q*{!>WwcQWPrXy zDeD-6L$TvJBqb>vl}l71l{3>j-JiEHUinFWnqLjUOaX#3|1tikMxY^z* zmA#98#bdzs@+YKExOSld(~X?Fpkd{bIiHk{{Q~p-+B5+PyhMpPoLgBedFmrKK;?t zUp)Qg)1N&3+0)OT{=w64J$?7-x1YXq{rel&o1Z`Z>}vnR>+R2<{@wH2fA#b)pML-8 zTUW0iTz&r0(?5LryH9`T>35#~ho_%B{m)PTls_=+n-bR-RJf{yypGGYn8*tw=N35anbe{7ym!K-kSf%&n=$*=#tt`p8nUT-@Q2a z#?|v%e?4;iCfT1~tB~efDF5CiZ~u%kvTv3jU$gxAMa9pqb*Gy#nM;HHO^7D%VG-W40zE=99OKyL9{r_J*xBl$10{wn=^_Z>w{fqX$y4wEp z_5WXPmOr^x`swqUug~*m7o~r7t^B9g|NiT>;{WpWAFlrIuD|evj(>YSzq|f1{y#i( z`Qxh}dHnp@{{Qf~*Uz5E{P{2F^;^&0@E@<){^-)nKfcEP=Vy6;=UT)6zW?;2r%x}5 z^RORXe}C`N!4IEx@xANcjh^mV(2uY2==}N9zki*PN&A53hGWy}11LvxL0Mjz78h$eX@#;R3t(w-+aC(ROT!1^kz1J7Sfy z2CMw#_0O5-8<(tL%)h?;@+X(?{mbir|N2?h-uUOYpJ)AxOP=3&`oBMa{O_nzsQz5n>xzJGpk_LBN~ zB+d7G|NU1zhOhkm*~3Wq^%;NoV*S5&NgHPU;F8AgeqhXZFZq(n|_x)?V?|yL9O9@$vj(_kAM!iPgPoBqp_xk@2(f3liR{Fi`e?NJa$Lpg$ zdyl7|T-Nlv7j-y$eaxrtjrqN6#P426^!*P??+5RV`TdJ3-Uq>6Uk9Q+YU%ecn*Pa^ zFZ}4D?)5RW|Jo=rfMOpS^~o!vWE8)5jrsluN4>_?4=$cQy+(c5`ren{?_d1<|IQU@ zzj5K=A3jHpe|913e|bj6Ke*zJEQU}1!KJ-BdjHPnxxi1aQF5xE-e&kG7giKydiwJ# zQ~b#_!*?!i?#;KZ{Bi62)zRs!8KO9oD(9f{rlG}+CRP8KfT_H^y2#u z|9Y11KChrs zXYOnJ!81*GVf){Frh_Nqf?U3N^+hlKfAl=^d(XX46F)H9dbOMB2hVG3MNf0bW3&F? z^I73GddS%-{q0E$D^Dw*UVnY-nS-Pv4kY_Y3+|fx$OI047 zN2kcIuV=p0tm#!hIorLhnZ9?)iLQ)9S#c_B`TjLRJK5DQN@gw1@X19fJF>2feKcfs zBnbmIzp+_eR-+wtn+2yo{CwSdpgJ0U`1w4CLv%)UmTL6(pCuAE;)xZ^^vSbSJ<~rl z_h#8oG}UGno}$g{-bB$Btyv)fh5A!Yk_{{4l%`>aVzD~mN2 zZJv3rne{N6_U8W1_3v+7wCk@GUCE>Tm0ym-A6;{`%g{SZvVQHJk}97Y=i$Sr*DUiG zdy^0%J3!MX&+^S0(1Ye5TyuQ$nVwHSr=C~EquCnA`_PP@#x)oD(9Tx<&ei{$SD*D{ zL2x(vj^wj!GbWiVpKOveO`?I_*w_0(n zjY3Ph&~NLaVP0y?e2&yYCI|}386WDet2PSFE}pd> zInj@ZuGP3tuK!=(I5@3$yhSCHZ#5K{^E=PlMTy;9-b)fFr4Ll!qlMLrwoz9sBjH(a zF~D0iX5Y9m+mD_(Z)UV*ts&TvyD{0vWNvKe5*B~&nP>DDtJ8l`0QZYqSwwuy@6tn7 zt}Pkmcko4Qk7GN9dA7FDhisw{e}r;=cug_lWJ{YSeXQzwbDER_1RN$ zgI#nV4M|r2&}SGw1cFjoT70+qA}U$g<)!qnD!4v{`>E4KLA9 z<|JzKtfLdQjgIAxp69)EHh2y9wSfu~Kwc>xhGUE^B&&%n{-rtr_ z%b@AI9Eat8^30K#QAV9_kzjaH)dVfL_qObczQ_WydxPU;POT6^k;*imB*$aYkNcyE z$s(`LTAGQxRNsuS+L6}b=<&^X zvt--wL3^4D7t(1+d$_WK(aUU()r2F%F4q@(%jC1Y*>6Y z{+;VNJfOue0+J3*%0|YiR_m3~n8|+Zo++W8l-qSQT0b_vIhT*17nOHZ)>gikoydjq z5}LZBdKw~~bcx51wS3{zORDh`J$Y(KSmZ%beandRxLUJ%QQo}f($~2Bey#7WR@s=f zNo_IJ7=LFO;USx#htn@xBCXadJ2eXTvJ5_muDs1Up?b8m_ruw-npT|E%;rKBy+Y5S zZr&4SvIRDqUgP-DLs7Zi$~0w!_Vp*D3{KGv`S-gu&t@+|- zpUdd9hoxoB@W^WHnx~Lf*3W7_dzK@P*d$v+7keQO$W!JNl9>nQY~^-n`EL3rOI~TM z=GPl#BwNoa5ZSDi_^tP>msMo9%~!9mlD6P%JBrmwa16bM97P2b$!N?R z0+Vn^i}U-g@`u;>uxxggyjW&B$7A-L{;k=bBy>n~(bxJ}78{C^W;m^8h%(&Q&R6kX zT-4USpw;qzZS`7hB%4hND6V(5y{b}Z6y_!0Pp&mVSCdgv5 zhO86yMK2?=g0i-zoM9X3US zKYONsc_Ry9xpX&s-F}{3ojk|vd_(VLBH=+EL!-muJT}YntonUshXZ&&R&O^pj*C8K zZa*D*Y`1&bd6`|pu;8qNk}R~iZI>BBR|&ATY_Y6lcol+JJ!A|8=Jj_BCf#G-c*|qS zjjrbGk(Z+Qx;iZ07b&AH6tjJ~7Sor0l3G(>MU<#~v4ZyzT~RJ73X@&@ln z7zJcgrF+>)c9XS;9^SsQ@r?289O^| zdZM=(i=Am=?Qjmqv*bsu8TDw(tl-PpGH=LovITsUbKhG%e|2m*Ns0^Y{?gm=jv;Wi zeD<}|7M;>$*;t;bh5eH_6rELP`|Pbf(kwaa@mKzGB0=`Hm@$hDPx4wew~9huWp1nA z-{9)(1Fe*IWI^aDl9!W*EHB4bi@V$AY)^zIgGpy7jNkWpLS;CgY|s|Y&r6g3y?qj% zEqezsK7`9wcxlVo+@1c=n9uce$K`0Z%CY6L-}h~qyzjV79^vdGE}N6=&#Y;Z7QQ?# zLq$4+7PDq+<>gk&7qihK*73!=jU|mRyyxYyQF=Ha&7^YY>&!|j^dSFA%4IFay^L~4 z7OhDet@N1Bp0?>St0-q%v|%x#xjBr0#goeXvY8gEteU<^aXC`9+`ekwNn7%UtT3(S z)zKXNn-`Vgws}{_Zw>v8Y-hF#8NX;c=Dt;({oG@3ewIA)&rqvfuAbe6Ni$~5LRSG0adt{(75$1h z*$|E8AJw7m-hnqW-aQE&`Q5#hoW+lKmrT}9`_XES>@?q22V0yo-#z+gDX2KnziLr4 zCpEM`;?1IedNFIrhMJd%^U;g z;~ccM3fW6awj0c9=S`#>>K@9%53)>WdCMyNM>Yp@!YH&f*Xt4vxjNa(s#;T>I-l`3 z>$}%%k_$QB=|IJ5>Oji!@sp=ON%J=HWD^i*E3&3WzRg3p7LIr zXD2(8Cc98k@BI9(;iH>qTBf#2AFb?k&kWhwu`;95$I-^{pDc}`iMt+Zds}w)>6^1N zS#SuKPoxVR%gyMbm>G(aOFKPd#HJ`Lf*#C4>)8?RmV-#2hll=2sYq|^$)jZ}Nx|Li z$I4t5Pp`?&_;_$KP_~aARuR@6q}#r0_Ij_(h4E&e*Wf-%ddg$76P9f2fin<|I{XE_OyqIj_;jTF$K5arS+up_P}TrYCrPZ=W8Lhg_6y_YZ^g=`O>f-Y_9=YlY;|^V%O}(z~S6y4l{5 z3O+XWNaf}FNyVs>!;>p6jEdK-_WkR;`cJR7c~|+cYBas6GM+VIsaZqPBN=_ec72nI zx9z*4PA<}0mhY+R!fA`Lo$!b_WpB%hvK*_EN^z!~6Q9j$Hau@FScziwj^PBXv{v@d z7OYfMZ7-AL^h0|!1kyPbG8x16S?r;8+bx@q)lOQC$}*rQ>KbYZf~Y`w{JrelR~?eN6xE)R6kl z{ERCf%0A#nIaB){@7a{ru$M0Ht&Iz(^s>%wZF<+Onkox-ZziMlU+k}ct7Q@4v-Z`} zM3vo4u-4{J9_bBz#gwg^2jhHSbC{>z#VL}^7rW&Y&YXL)^P|<_>ffr1`_cUF+2+iZ z@7P}~x<(n#z|-m+W+^f!*`nG!vA*Nkxb8Bub-VCP7n8KFl_#63iQRg#Y)N%|n{5}`2~s_sdTE&-!~_yLpz)dEVDOBzpY6^P1UiaW=}L zUhny{_MN>|7mO>j-|IE^%#wsiR)2b&6j)ahx59SG@zNV3RJOB5vP<(MW5v~KXl8z1 zMphoBx36rLL2o^BwEu+cbie$k+@$leo>%pz!ETwEbLR)G1*xiDiTF4=?NL^qx*B9K5+YOwZhSn_^kN1*_H7b+clxQvZlvT3 zv$$$CRyEhW3)UR{<_Fs5Nm-v!IPe#%ge=zIIR|agzPH^T?rAffT&1KE1pj7uNRaL zQ`a8iR|{UQspqQIo@Fp7;o06)HN4Y;^3E=oK{O&eJCy^oq1kNk^~}~^?X8<*_`=fK zhwtP&>(G8sqwM2;BF}qCFRL$O*hzU2F&W@vxr;fgZ_~j06cl+6a??SG6a6Z6St;JK z|L8B5O|RjB_9RJq`C1a*SAlDPTSnnGwcfV#QEz8caSDU1o|N~?AYY~3N4~UO*{}(I z((U8^lNXSE^yg`ib^FgWZQK{zeaKXPb-NF!#be5J$N?%Xui9>mRcXzivcUXWE)nW2 zKQH3+%>ZxYp)8gqmy?+-Tp?+5k}3NLJ+y}2>=;etYYrpBalOTQt)3Ub>M;tfUk=jh zC&NK4e)K4}+g+cuMt}Rt6b02o^)Bvi$I&fey*qE`>x+(NW-P26Kz zc}^C&ym%hb%=dH9b~|jZNZripZ{&-jeIsr4vSqDhzO>!Gz4h{p@fc4$Ow$ z-K8ZxG2z$+AFL>tCeZz2GAGbV-BF1UI+Gx7sR? zot{qz_c!CsE_=BH4*DuEzSyPAg~qDyn_)=gyoyeT(6CT{wCqL~#bla+_#%TQ1Om7!U7d2+Pcv(EWydyqUGMZcc!-RYM0L!YdvT1*iJm7#Ii zhKJ5yvy-sWv#~|RVo@5(YlqUWd*jfE6plBRjm;awS$d<{_8Vt+u-XEfX!p}xDu_^L z`eRkuwUxp~@4Dk#ExK4UU(#<8suP0V;Mn?KfA5|=i!bn%4puE17VycPubc?gm>~^` zwnt@;vk9$5&upyNKxaHR+sz)K)rrY>zs_3Pz2=$CVn!CZdO#J2qGIjPLm&Ow@Nlv` zZxt_|Uk-(%cBh`QF!JamsDAv(JfUYOP%apr{HA^H4)3M`(Sru=`&8cf_4n>~=jPMO+eBolV!?fsM~AN_USUnw@ik1!4r9I8EuPBet_c-Y=w zG+jDwKQIz<(Au#8Qr&r`n=`V z3ZH0Hxh2`Qr?%R1%g}K?z1GIjS9zf|vxsE5tfyS(_(a)qadFiFbJ=0?%K8^OwLjik z2@9;X`I843&uinQdD0Fm+o?^v6}>%e7W{tr)=IOm^ySX2T6-e!nmrzs&mWq9>oZ*KduXIW}<&br%=WgomhbY)?O ziu_<_)U%%|hQ*>|RgJGAb&$KPt9QT7`fX=es2$P{U0KPI-Y>4;F4rGMkRIummwfU1 zZF8{H)%@rxt!WLzS`7`$gWhlFF<*uHEve2n-d4Y5DQ}AwpI#O!A}zKTE$Hy$Z#lvr zaur$U1G8a>lxKv=d}BQ6Yh01>-Z~q5%wNhQ^2M{)SiHWE*XP5LsyeX#kCCeOadMAT zr?ys;dXm-Aci#pu&YGjiH;$xh>n>}3PZSFd3!JlGwkPUy-5zQ`{te|^$aKC@iA z%$JUMYj-H_J4P1z-|nPoKJd#pX?pALiKs2BpyDfQQ(-F4`s{g60aIq5Y>&;5Q@2sb zsV`s3<5WblUAdR1?){tb)TydoFDFde(DL2Yhaas<=)YEHO>u6OUs-1KSxZH|s2GAm zJ%9VkBs!KScUs}?s>hx?yD`UZrJUBr!yx!-&GJ<3DAKc?oR)J!B2vdYEHkdVMNvKa zYcoeD;p#f(S>4+6{q|I`K&$)z*)5G#d+D^x+r^lkM@ehcw_F)Dr1kDO*1)7{Yfy+i zz}<6KBP7-*d`5RCK}TMBO20aNZ$(Mf=;D->_q|B`#7D9?_}3kyXuj{5_a%vWrc2LR z)oNNO&StVK)Wuh;ZTEO?q$I=DvL@Lwu3C2%kZc#(ljHi|PmAulAWsw-d)LO=rw{%! zn>{Cqs^{Y;&x>Pi?LoGS?Ay?u7` z?xLRcb`vl!@{VaMJH68_|jdVW=?MJBjmrYH;kM0g)P%xcjEa+Hdi%t-+h^n z{Enve^jG_@dwchnyJ=PQrtN(JgM!W4jm4q#;~aEW<{JmHGu&j=;fi_n(H=rBn;B)j z$?gsoEW16DG2V*HSBxJ{>-AE;e_MV_vKlpRL?*WHxhyL^t=>N0ZGX-iN$rdMiX@Gs z4?cD*th{;lm5s8-qldc|rh~hM*Y?)T?5)2fA}^B@u%-|VB1P3AJ;W&kT`nSfIS9dm z_!(q{Jt4$0&yUH9^flMAn&u_bdsdV;@Zm3)6@_%xc+84wA1BL-=;vck*YeNJw9~av z2>s$*s8GB-yc<)Nm~@j!@*rpO7@{Q!S%pz&SJxPC+hw)l_MPW`XWX|o*^b^gkMFnI z-CKVd0!E5g{A_mTUPIp6UCy{Yy6^83pLRp7?4#T#jKpI&wcnr&;f9g^>eQWd%^Q}> zaY^YhW7_xkykeC$p1jJcHO~lsx_kBaH}dKEQL#bKyI$j<`hHoB755!Zs8n^2+(wC6 zAkHG+VjQmU+k90VaT|{8bT7LTQ|h8V{{Uz&s_lz2OAU@@+=`HG5si*C6 z`y!yotpBoXSqAx=KMYKJq*Y}0zI&hh281vNq0^8R|j1+C7((TelO{w3v+J3a@(h?PZqH*Pf?!87HgA z+fmp0VFJ#(J=Z#A3vrcwJ|YOJSx(HwrCR!{8#4*nLMv_o^_+PBxY%zGlKY9fe9M@l&$5*~`kv+V zoPL(koO#ITwRv{C3zyqFkyyV-WL>h~?*Hy_WJX8F(wpN%v~FnUNheP-W?nOIN-oVe z|MTx5RF#vo5VH0qCHkz&4%$UzRaKrt9M_ZV)p?Uv@@I3Ps4944 z!pUUMBSKG_K792%Rry4Gp`<8))@6L8pEu@Pi}gva+`#&?AT#bOSTgG?isC_8QqgzS z_T(CBN4=vL)zqfig#wsATwX?TSkX_dn zs%AZ~&WhI2VxLm4>NeUMsUMGdY(cY(K&$E<&-L8S#h6tYJ)cvJ<%as+GwEy&zscLE z-3`6t-f3+H6bv;W1^e)Zt!Ou6v7z>|m!7K^^?WLws3q~*_iF^`IhJ#%$Xc?$EFn*% zPiSEt`aWodA{d<|o3}HfRW7sfcpf4ev$)F2hq+l|)zilB&Ad0=Cn0o*eP)|%w?9SC z@yg~Zlf%_0%&*w z=^lF)RHjHfyL(55jnXr|%{i*8CZH`WuY%Nlm#`>lz=UOb)sdsMs#N%24DUHqogDM; zY9EaYwL_kLkCp|XEjz(Q)z@ExnVkBm#xgh`>o?* zYutD1r5;J*c-S<7)^6{57wT)q@wlWucVhKXj9kF2b<9d69jdfZu!0 zwsVL)ueeaWr01P4ZTECgrvBOK&NH)}WmwtH5UU+)t9843xeQ!G6)WAF)f@2Ijyy|x z%Wr1TE*|5U{h%bz3|m(JBhBGpaq#W~56fIkzi*v%&8OzBmW{2}aGw0vv&O*kunaVXt;+_{k2X7ngiSKPr83H@E zYFLziu#YIApX}ryZ(e1kGOTtQp)6hEkv5J$S_MC}K7VX4XFc(DebW}t4Bv~$)yK`t zOY}9ajBTy&u2u+RKAJpnzNnC8eEHwZm@imqRVdVi4s4#hmdDmByIFlJ8HBP$Z?Y^# z7?1m%qNLe)PEX|ydCRhp=_+gH)kzs$J6o{c(9o>yKC|-L)3d0Y?DSg1JiIQSM0xY) z-FRR(*{%F`yev8!S*w+b0oIsRXibWqaH}^78d)Z=>RwXHTJD~my_9*8%64gH3?cHy zkH5=U{>=BQs^PcnDDB~xJgSJ)kN4m(n`!0Wa%o?p0PN244evhen%Km5$#r4j%>oc4S z^|mM2sGVtqV=TwYk9QfhlF!+vk89NJIe#Igd>~twDR`5W;fF+aZW&HgL$bmz<1XV{ zfBDOQrI3H5HIkDpBnMyI;Ok`-kJQo;j}J>}4^ z=SOCJJvuEr`XqzVa$@Or zpw^h}d0zYYNE*wXij&?g3p~FPk-cSoMO6C0dAo(UEXT{&PU~l9mh7rJmA_T}ZC_3ss}hqBYs!ntZRLwmty-d(70XZe%$M_F>d-#Xfx{ARh;)|DOQHK9#AK@upp z+2}*bv}6VnsIcZ;aSmer@lJ;OBvWTO%wb&o_SVtO@^Oc|7En_diwU$KYaSTPk-kc z^Npw9dHVCGpFaK7(;r{YfAaK)PygV0{|DE&xBjx3laNrfN<5@Z^Y)D||C{n{fAYrXZrHG*T2H=8Hf2Ibn#QERp+;Ci>$8Ar z(%yF>&w`8F*|S+ioFcsy&RkZi(%zc9teM2qG(&@@ZS-cXMyY#br+S54*;{8mhwoXe zxtk{hkBZsHe9E|dEqe1neHCa{rDi3JQ0n zKPxJp^_HHeui1yTq`Md*>zBdU3*?zrvSxcw@6La#W@p*vZD%)1wl^7_WkJ?z7r_?N zHN1&`T3M7R-#PhHEAV-FN4uZX=IL0Hd6q4roLA;wS@1H1aaKgsbGqJ%^O3bV-yhw& z&}TYo-NvD|amysK1#heP_S7zb&!nN(YIyq0e}{5Lcf-dj?S8Y`gP!MH-kL*~)iVy( z<78(QBuqva{V2lMk)_QBHfuu9HVCn{WPXg0;(y#&7#`c+qO_K9|M3=ESr2G3B0j@zf;V z?ruJqr#)iYV(4}`#$^FU6){@P^IK!uTYp!J{j~c%=Kx>l-fVR`J2vn|bMMz}pnNW` z3rB`&FD)QnJFUmEhDz;6%+;wK+VjOT%G(p+?~!A;^mXqwJ?iW zH$;b->}p)dfZkRqci$Wb5zHrG5gAh1?<+v?y`pr_cvuMC+o^uvL>^wV%a$l3OZZe z%;>MS_ecqPFzHHvmtVb@7QnA`(e9sz;I%AF`jxKsjnx2xuX`3Bp5l&9xormOYA$Rjl zBB3d*n5nqO3-xM0N`{@mY7e=^MCd2}~)7A2-Tqq^s1-ksTKtq#}9vki1t zXUK!n48&%U+p}h0_~q|7spYH{BJCTcwZAn=zV-K;>=dnWczKMHB-DwP{BelG7RWxk z+}$wpGE=)PawxySskmm3Mbhq^G-LY>cwwII^O6sW%(#VCBGcVN+rR2{Jao~dnZ@_KCBMtQ zm)U1A_zJmhgcjyN`*FSqq6S9_!Nz#iR2#`F1~RHzUlP zh3$T?nT9kW(e@py2hZQ`m6x;_GI^$zq3Ef))S4x*!1bW%s#-gfQ^WmXk6Z$6ecp$!&wrXd&42T&VA9It={=}()HWq)Y% zeMxO+FMx~3Hk71q5`ql=PNVoL_V9SLPrBAD_OSG_w=ik3Cpo2~{G{qtcy{87H6Zi5 zYj)mHv?YnjB#g6OzuZQO*>1L$7nvty&QDg0%ZJgixK}L>eeIyq9_g;4hfCvz2+g>2 z^|W|j6|%DAkb;g*mb-gkLk@aE_i#MC-@9LtF6YhG-wqm;tKpbyUzXkxknT{yCs1Gvu6cBJo6M}(w?sp(R=KzT z&G)L7N44g;SGAe%Geil=-zk}$6zj5%a-r9_qKocTb)#+kg%~8)eWh$@(f*~RwL4Rx zo2u50%(C_FTx*DbWJ2o6iFcALy7Pc6THmlIzomn`ZKu<%nik6AlA^hWOuN0#3e32# ztF6>2dXmatPAd$_Vj=v?8zK37>lpcRocCo{VNZN(%M&)9*M^tx(|_)SgnUV%Y>dP@ zTd;C;KH1pg zooT>2dFF78<(<~#57CnUFr+Bh{?SM@mxl}?U!P<5oAR2wB@COIiAGNLpx-<*p1Rwe zS1d!pVg7^`(p&^V%l5_Z%h{W^?lj)|TaJ*7rqU?AK6S9S>MjIifF?xtDPM1v5rx@Iq7q`#n<&X+L*T% zAE?ih-{ zL;B@j+rw?|)SR7gZ2qE&7~MG@t)6{M^U8uyuzgQdlr`SlcV<8fi(c7rnOOE7N8{w= zZJRw>va&~NWIN}?+-zrG)jH|xH|Dh!-UA3ruHJy!<_xVan;p1jy@9|Opqni&&JB@YwG(^hxX?(mvr4Wh* zo;jlcqTko|+!r(B%Kjxj-nTrx+w&f2Siws1!dli69VFRZU}O54+U>)%qivRi19B|$ zM(<*Id2=?lomf7#IAM%+x1YP`wX!srVGrb4KWEfu9bWF8H;~;MGw(M{((&p;sA;Zp z_$uD*m1@V;>QiUUs^GZXP0z-6mU3o|K0f$JL(!(X8VwtFX5X)CklXk%zFht;8!MST zuJ(<`TJQ3GZKq2*p)u1ie0~qI-Tz`j$TIIEpTz{|Qyj{7cK3k>*!cYDR0(G-{Ltw6QP0y+@-1J5In9L5 z>aVS~TY5vD=Ayy9Y3G}_EgImax_x{Nt65~IZ-x^M^r~JPZq^?yyK|BhLXGYB+c)Eb zq{*TENGljkR+CSAlWb}C7Er-Lpz+;0PP^7B3p91NQ55~nzZrrP+{p!(d!hd^LRe2c zxStb}M0DoqESSz4o&TMDuDefJJ1WbYiz9fb25+WnSl*b6l@)Q=9xb+N6(BsK3j&g} zDpmaK>t8)Ot26TPH$%wYsB_k3MHtjfW;JRx!CK#4tx()-G?Rxk=458S_!mQPmDEMr zX27pZi3g&pUw`PtVJB3miATO;#CEjknbl-vlZ#$rC_5v`b}hzRWp`)j>-?JJ-9yny z0;G|qX3KO{>`5!3gY_1Z%(_$i)9$HA73X@jTO!5rnioOtPNMe>1Rh|)k>7Y(USni* zS*hF6)|gFbW#RsvcO_-dS$NjCdGB}0b{=WxwyNpA^&|h& zItyyIkPU@;tf{XG(%im}!gJ>`)=Oq2Yi-t#`XryET0Ktg*;Yu4f4O34x)teQc9zsw zW;)OEkN&M38bw9Ai^?Lk=kO54+r6AtGoUXS)NU0LC@yL{7EOP=eBAd$bI}swy!O3; zK4O-BovMZnJMF-A5zm}ikhP0#$uqf-P(0zr5EGw;sz)7x;kb%tE|{VdRIVd&9j_S5?Oq`vgMc|$ql4%MDSsF&O~`dIBwOUgf@ z?aWudQCAM8PZpL9W)~#YiCn#BFZcFGnba?-v~F=juY-o0^GN08`t2Bv8cJ9*Uo+~? zgVy_ZUU6jiPM2TJ@4QGQ)MgX$3HS2Y_E@viyC>HT7W=b_-Q>sznodw29*PMn1ee;4Dk!};-U zL*Z5~mekL#9Jg?`QTYrhm-&)oC#&UFANt;_8DM~Q%4qV!qUBENk2jY&*MDbDXprxj z2R25-vdz37&Tr?Gg@iq6lVs+<@zOkbFnfFa-b?S3J2X-p*RJ>D*IsOKxBl*{)?Pe< z?8~^@)w8BmlF0Kz$inB7cTwK@)fv)Ftv%aO#C@C@m;Q|^Q;Hj{Z{4B4_1CZ!l5BUc9A}Dcutt+56yc#`q)!#@WH<4XetqPzdwj6C*ZqWO3K>N&J1%|W2C{gnl`IY(qXD|^w-(s`8I=T<`9Xor z#2T_q{5QL{xc_y3>op$U`lXke{abTzy9=DN5omh9SHd>wE2OAiQLaYL-T$msQGGhO zkt(e@Ckl`D6^r*$+}J80s|b_< z9IL6nOn6dYU7?;8Rtbxj-mC(UHKHobvmE>^ZlW43v#9N9_3q{+4J_}o%OIC_%qUz2 zRCc10#EM08ds{R%9=4csr&X-CT(MpCZjYVzC@WKCt2hd^(Vxz?>U@YrLb{=B)`Qyk z;VnCrL1)~skh^^D^hY!4m@6xYVzXK)T=di}sI?h+#{*P%pE-+7a%ZkA=hS_0K7D7E zX54pyW=k66vi(H-h6vSho3$uO(~B86?>EZPd#@(#oFrcu1>K{t>h?39VNLpt*0s%( zvOcXi2ra6vT0e9=R-46#pm+*p^;@o14b6z!%=}n?nEmS04rMYaqMDr?+qH!S#rfvS zZ`p6BnRy2tlcc$-<(gxsxFi^U$LV?>U-5LZf4%bldwVwG(fjvz$bGHE@*FdQQTNG$;r! z$`~L@t7%O(XEbZkYF1plPXYfQ3XHoDC;IIo4ma6<^k=E*H9XKB@8OocUJ~9(-`d}{ z_c8Wu83hh^egU^34;`bYZ^ubtyN_8we)L5$?d2HlHqx^;c4+N)m!w0;yk@u2^lYcO z=p52&owbkB&Bc1?wi;efCq^Hi-cHN7Fe|$9M9)V8Maz%BZF(XqjpoZ%gX^p(Dqpsb zlcd7yXh2*FVRy!>Ro+vUA4cRo$&IYCv1Dc5#ZbL>s%4(`ERE)|V!p8_0_R1GUY&9* z$`+-_7B9wGn_q>xcxCzRNPAzD-dfR+Bs>={P#hkcAzoPJV>`<3Io&}?QHMhb<$Wp$cn}0jc=WMdkEGyhw=|S>9b6n zY<6oR#3>plYqDW4BvSn`O5zuf?e{k0kTl&?B_!n`d6J~nm+~niFMDYZFl=V$$$40d zHojA~gqFOW7KaQ_00LNj8ZOJtLf9kTiyhIQf1>@K)2N+hyYlGe32>Gy&eu00Q~geM zRuo*kRXiaewi%3XMNU1Te-rjB{|GRk4JyQ zGPpx_JkG4!5vL(^n184DVI9AM_%uYCVPZ7(CVhlr(T8rWS<)(9?az$d{>*cyNB5o6 zc3eAm+O943#!79x0{o$icHP49HspJw57GL?ynVuazjv3<54)InfMj6c+Ndz<{f90 zx8b&@a>U-|FWQnT&(uajtm57CI^NRoXP?K9G@7r3&D&#|b@vq-W3K!<)XU!4LYjrL z%RR~mii@7Zlc;9_i`&(Jj1Q5HwwLda30qwKJR0hKZ}(h2HdG_iRmQBmJ3_6!s)Jt3 zIn6Ol_7vU8=-2z31*DvQP@nZIa^hgMg>Snhy%1X~yELC4-eZ}?a$w$Xln-BD~e1q1#l~tUYu$Jt=>YZZ6=X$;MHw(lf zBg+`scU6>TOTt^No>@=35-~m*K}a~6)!?iv3EBi)OhMYVFh z&859H&{6As$9CWIRinr^>)Co`2d|IE#rrhG!!V{8-+sM(;gO%SOPb7@HH09uea#*X z_<3K(V!ypbJ|LbnGHWIW*-oBE!{Z0PNvQ1Go1(@rT+fguA1zYx&D|Mm%_7?}DzlXJ zg+o;@@^&-c&j?l#Dh`?7Q?2Em$!Pg%yKt1}`zO16IgTdU)0~ydlCpj8(TSVT@qPNs zp_2lxl0umRO;we!vbL8})Ry$E#A{k%I*kix*2vXAk_A0l`Th-lyM}0vt9ddn9sSFh zj4p>n_rC5r8nDjdcF*kYv?l)~K5wD#BH`j{{!(_(DRnw*=KCDk0srQ=hmP?;N}g$C z7Wf)ZvKW@~QaV|R8R%wbG5A>x@_jjb^VZ$PTYoXsDJvasU9z!H^!KNqM;N;AtY>Jd zWp|-Gw|CfCz$(>vUQITHD+1E+>Xm0_K^Ab6B@RQgj?n1q|B5YZ&I8j|)=bK^YYm(8 z->`4HhTYYri7KPsSFO-;p0<-Ao9)b27fGU4x0=>>*J@5I%?FY4kly;cTFfv0CIxl*nEbmL zaBTg*`I{6p@*N{qy+;qpc(c8KGSF*ZUy^0}P~&mGb2m1!y7i-nqVv9&T76vmc8A$j zbXl*s9qO)%eA+#=lZbO`x2GdT`xjCSCq>ZqyV>nIM?LZzFAzFwNe@Y=GX!g_s%8E5 z8J_Z_v}VmJ9%cNSdDWGW``AldJTbs>PzcZ70izS|R2O@io|Fyq0mwD^& zy23JefkS8qE$)@ldoG=&mgmqg{_#~sJ(OVAoy?&4)Y?`j;MZnY9hn{8u_5n_9^PS= zZq(62dzEZDO`q0wbnxWe#gROh7QX)V^VK+I?V;Ds$c}8o5tx}blmE$2J z;l&g-Zr0s$N&{u{_X>mc@~~!NHH*D0f)(aTY)thGU39%@U1Z#;@%>H6J7l#|Hm1dF z+j$h@MTjAI7=ZpV!beKbn>|}$(#d!0|HxC8$=-3749qRBK?z!xd)?b7;aTxrXZJCo zBqT)H_6&M-3-nHXxF){zR9<7;$=9}rGOlk1%(d3$<@J0k6yFJBl$0?TeXQk-*C!;~ zt&(VH4HC%)hs5{xags#z6Wj2-I~$Enno(1F#jl4zV;1gd1?POwlbtyN7+Iasu#_Q^0N;J^wK`EvSS_P6305&_|7?+XQR=t;7d0zXYOx{O_qS=wkee9768q51yKdBhCeA>9Al8;vTTI*ZB;e5mn z(c(zPoc7DJjwCxAjk<##MU}Ft*^IU*TNWVhEQ2c#SWaF3R{n4!of#<(P%f_L#ann$i;O+M2p6t!cG<1J$hf)pWuIE|2QC8XgLC>cW)m^si zyId%%XtzP~`IYrok0IX=eYttEChM$PY0g!C`S{(3t1#u;i~IF2gR6gjp?$XyWjVjR zFE?3LyQ|TOV|xtk`O(RFcKXfM+iyhw+S{{jA3?hL46QDAPNU|;RbJXG)g_X6C^ZkP z@3=OuJr`M@6Q%sC*zm=^-Q=Giy1$I#zr6mt+JAAas89c8?SFpR7z?AV?A5m;D73@g znQ0z4YfGN^+^>n@a}`4E-Gwc0uNq{=JEyEOsf0dBP>*~jKbm&2 z_t+Z6tL2h!b{;KxG48|O?)hXhX2c0R%$tlPrS4(2zsa*v6F=<sR>u%R#{H&!JRxd@vx3rrvALzNW+O%Ds z`PiPmqV#Bd)g*Jx260+$`pCb(a1)P5=H&=1mR`~3xi49HTC-_S?`0@BulJEh^5d7| zu*xQC^o~O7*M6kR=T5Am<92ql^Tqz`08QQI$r|7gnyX--XSX7GRTcri8pr$eK~LkF zi+mPUycgrDvDY)X;^C~SEz52MN@^pUB+WXF2$46-TGLxJmybV=U+&SGY|2wT%b7^C zC}F*#$DxqcPODX;G+pSIq=){qqT_R~xN%veBY%XYwx*vDQ1@qOuy7)gE^N?6~t}@*)Yb5gna+9UXfr+O}e`tUG0`Rt)eK z@7eeA<5fSh4z1=}{djUWCj0P2=1C+wPww-lVs-j23gC{8lQ})c$NVlmShs3nROff_ zr4u&q+i~V4W(GrOb$>rC5V`Nwx$NZ5E zlLl_-aVJMfVOghMVZ_evo2kF5KN!6%=U5PJu&igGt;- z`EpTyXP`JOGa08Mb$X>^T5b35eRQI}vv_YC?Wv3@Yhqha!>au9p3g_gviT|pdAhO5 zJcR%6>&8g9!%lXGB8*?|ajYg=oYlKoVy;z=Jpbn06oDe-^S9%s$cKNy^uzg#%!1CUvWqcA z54FN#G;L?8Cz2VFMci+U981%)?@i!Uwp%Qdm5605sjMbj?{uv^M(lfcSuSKO-W{vT z0wJfFou&Qknf~RI+lA~-G27if|1n!E9z13x+Nys<;chzNr)dQw~xycr?KiWPO%lKh0R=LZXR*wy?#*p2Yapq&P zi!v26vm0|6y*K%NcDq%I2PC!M#aL#AmY1W?F1_lQS&}jedEcr1WbN7TOMcoDkj5mV zci&|+V^~yFTzk8TovXgo8am(-8Dv4M;LF)E8JKsw{;ZM4@2#G{I<}l7#f56a{_Z{> zZH13n8QJVs70%dJZ)md2EL(=A-q2X}uymL0v$vg=t^zkp&R@!vSyT45m@$jZ`tw@7 zR%ytqIw{JQ_XbyIPdIBXI^l9vjH&>gAKYh7N~pZs=4{U{u-LdPAHU9#Y5pZos0`4$IyDGm zlFFU0Gb^bWDgT0`q_uG`qnx+p53NrdtyFz*(do2};;f>aZPA9sbXu8J!Qx3}emOqU zO4QL8DIQ++?i3y;-N)qHWhgs+%B!O}`jcPLDcm;i>ieyszmZiQS1scgWip~F>hvjA zpk}v9(Gw!Y;(TtOBoE_}XGH5s1 zf4pVz5L#SKo;7NFw2b#X!KPQzVQ-A^f(gOE|`vw{k_bU4&Laq z9Q#f|>sEvG{Fukp{wNb`kbfDHcdN!~RddK&*y-MwxtNj7R;Aron$UYO$2?hk7zoeV z>AhkIHSj^AdCKyte5IUpa-N?glin94&uY<8uIIU$JE~XxFY29JOQ#)e zwGZF5bRsa_#hu(z-w+msWN20GJEI%&;M=qPN9cnxh!~x5lJB>BgkRpJk2I=R`?rG} z&E`$fH5@FG^!0g}dfCpfQQVG#=xNQWwMlz10{>O3qoml?-VeW{wL@$O0J$nWm0<`+xeT{2lat2lFH zr}?(}*kWG33%AiTdqhQhSMS@oHgi(b_YrTF2`pnZ>fm;|Z7$z6|LHSr*-RBc6=|CK z@b8V4Jw+M2DXy;)VzilPewAwS-@OIn$~F$R6o<4AA+xzf`c^a}EgW5&!C&pJ?(Lnu zl*{Bv^nE|)J~wd9yFLHo9JH1%<<}dNRfj8C?YxQ3L)}AJ9-rlf5y>X>II=mIla|oZ zT(3(w+1}r`i)t4lk?DliYjYnNk{o7+c>-9XzGp8-5#5Cwrk#P zD!us>&+@!_!f8)WR%G`@o^CmyA$gZ0g^EVz=XVXGzP{14tZh|4TG^?e8M3qHg&&V{ zn5EKwR&5MT-1Sh~+cLFJuX#T9J|$ld<%e+T1^4nadMR3msO)R^F>pI-i>3#E&|SZ5 zRnO)z!=#kNiu%T${95L+iXAy;zb8NFcJgVB2X|H8T{QFFJSN;bsJHxz?bH3eV)vn) zAxg+$-kZ&^!XX^%$4k3_d#mU7^SqG8oZfBphbwk@>Yawx7>=QBcz}x9QN;R^h^i3T zFQcnUbgyx&UXcadYaE9%HhiyfY%SU=)1di$r%bIF%iqyF{BDiSln;bdqncD2)2+0} zN=9*fJ0Nzjd^0agXU&n7X+gni9AzHuz2tkhq3oD#WlfUI>QHb++_+ZNs`aEH6j>cD%kY zlgW8Uj$i*567x*9aYv3tn*8KM!1^`jUK`zg_3S%TpXb#(OX_)k&T6^|zFqEzF`}L6 znKKVahh`}@S^s6;*qWz)a_2$oTfVLvu7)5i{742R`OLwc=zuCogst|rEI5kqZDu*S zfVf&Fx8EJ019NqguRH|D)T2+T;*=MX7lYo9@~qH+gFfnzJsbo+UajqmE?r>_O#v)mF9`{ z9nZ-dRdL@8#+|y#%M+Hj#<&mv8nyLak8x@kB)F(6Pv)g+9Ur%+dYMVb>9NhDiGu*M zd9wQI=c44qir!)kF!dK%!-+HOJX?U3`SjfuURubRb=Wu=-_P2NI#xv&+5fS>-ikq< z%EwRJtoPmJp*T@{YmTaXz=h$uy@3cY=o$DCc-cf`+HLofrWkcm< z^Bo|sCm7*Aq){?)lki~!duxY3}2JuupO#>pERXQ_Co8>_H zbum-lV!EeAdgEj(`TJV+xSx3GRVKGwDXK`Z%;kjaGG^# z&mOjuw({OsK_xu@*E7wtoT%m7_(WqqU#6B1>ix_7?DSwV+nJbI~6?r#;Hb zUsqEvn@4{-D^oHuAF|557N7OdOZ?!iDn{{#Rmiin9{DtDRsLw!+gz)uTg|Ng9Y%WU zJZNo3-tizkXFGJen-iOB76Mz))%i!QS*02GMW@-422F355x_1JBed|?(>J;$ocmOaB7YwjzLwat^VKCL+L7psIUINZ6&tTu}$p`Og1HyP{6 z-LiX|na>V9PEQPJ-!hpLE6h%g?d8IPVoh`9pXKUy9eGE-5~lb4h&kFvWwYUToUV7) z#ZEk(@9y-1@{jEye)Zz*iS%4W+jA#`s7@~Ji|(|beBZqsZL%VIPU8MD8=Av_`)r+b zwC7G{!n-Cnv*$&#dgL$pRryG(pKNEf1{AJhX(cECT*+U~3#?~9aX<|EeVD1S;WsUuh~K`Un6uiI@zoxq$_n&juHt+ByKh*Z z_0PhxpEHYAk!n=j^Bs~1^H+6Z4M%qAt!#j_sk-L=)roV6B}X=`^LlH2?3=(EbZXC0Ug1-p}Ll!$QbqKr}NxU%kI zHXmBwDrtAy$PUeLw*pkA#kIaQYJB+j7e5>|NH?y^VT)TLVKE~bk2OF7QY;Ei%6NQ? zc==>92{*E18a?+kNtdkHB`WG)%-vn%BK-79o6Q76R|g3FHq&qlRrfdJO@_?Nt%`$w zpE|zSsMtsgod=jx$nH_KAf_GzgnTJA-_Eq-;QFL z3o4*GWXncR?pAx2I!(b1k|HYlYF=XZ4!$NmQ20hjUS)EjzJ#S+lXpyttHxta>UDuX|?ngnY*{%hI;@w@#i} zOim8lHLT{4MCY$s-(9+gc4QJIY>vE#liqb#xO#QcJ@{Vc+Io<+H@Js@`!6u$ZRQ$w zqvgbaV>Q|0tR^d)T=d#bi_WUMq8IPGrATwT#haGqSEy|FRc3L&ow;gHR#h%wz4H2U z{Ov1FyQi#^Hu(Y z-`YCCXf<zL(LRgAC8;2sK5vdsaq1(T?YFZRI_O zU`GpSuUx!%T4biZ;+L61Zo2fur_Gb4#{b&in$3LtZ*RgTQpcf;2USH65${7cU+90@ zEAL8sypoNz_eeS{EbTRl+^xfQWCEU&)M-P{V~uHe+&Ut(J|fzpN3&V|v0}beS2?oE za3V_0@JKcK#VNX)m$>(n&5Nt?pIwvLyjQf`eDlBfBAeM-o`RQRryj-GR+#17+aG08 zzoe2(^2sBW``9Cu)+`V5oK%cDb*f3ls5=j&^2@pTg4QFI=$M|`1@?BCST+397G=8y z%ManKJxHfuLyRh~v4KC;Libv$;jTRiwSGAL`z}H}n3wFsM;)!{1^fJ4uhP;cuDQ zUC*qM=gtCfdEV2>8HiwbF%KC29u#YkW`eZ2~ zoA$mYr=#+8yN&85k6!s&eb+o$h=@ZkeS=}%W?LTpH`jMX5;N@N&FU=dP>}_yXWi|m z46JB)tZIH$6d0D4fA#LyS>Nrv3$?@C;VdgT(!1{^LS=Tg3h(m&+DL#l$)=fWH(QA6 z=`Ho$=3uF-16sSPyw>v3Rzt%wqW9bRz*nKZORBStx9*5O{ujo4amUUV?~5Cx{_*!D z*)0iw@tXFXXErlyd(3I*FMlZS%bR3JkBDEN563L|X=^;jtlG!PJ!X-U`93aYy`Dvc ztszA*E54@lut}sCmBq1SGK^s5zlw+@VvqYTdQbA`t(`+P!tJ>ZCa05~-yeCDTi&%Z zjI3>O>S&C%&K+kflB3mNu<4KmiI!9oqV2FPgMtG7rbic zW@eRNhfOCYZLe{^od^?~Nyd>~F7K>573W{>j-zi;pmy?SFkDb+Y45npdT<(<R+%S^tiZX#e(bN8kU05^M)JH)r07WP!aidag-(%FO{*_-pNL+|()7%`8?)+EUl4@v+C0fg zo3Bc(S*-l-nw>{=YA5G6SEsVdd}b{?NVeIbeEoy;g;*ViM((8mvgFqrV$UXb7oPeT-Rs+jE{_obxsuYV{>S zvsJCo)~T4MMVp>km#5a)u65Lq*1Ky~_as+&BX#z`N6+n!Fj=3F8r|L9IZ_O#`%UV5 ztMxH2MJ~LG>Bd5<_5*0}#F{Gw$Rfyz^_yvJ#+)o^93AwkkeS0XZQvWWCXpGkQ!undfR7u$@6FZHcL z>ixa~$t$zIu*IrI?JJ{hw$N+y=1u0KgQIDlv~iE^cBWS3;l**<-j_5eI5&UHJCwf9 zEsr6UurW$P2_uFp=G7;DLanfDl=UXNJ6N!rw?{I#vEnip9&UX|mto3k6|%jkNi zA0A03RLnwooF2PDl7=9G`8)MQMH@ z_Vi@M^hBDwoy8k*_{Dz7l19RrZh_8H*wV4DM+wYAj}|8DelsKsukEcl+QY|2g+^NaKFc3H`EzARo3Gwq}x$zCc~jdi|H? z|M+_Tlh6M<^M7#l{PK)GrH-Ldo{;}OA)Yx8-ADt1Q-}{+YSigN9#Mbk!;W!vJ(wG(ZT~CP8 zy>#4<60<;|MZ;xXxWaGqbyps@Vav_~%iXHcn00may>t4zASiyNn%CUDNG#ATz479E{^XRv888OIe~_3YkHmch#It;<4L zMShR|)(;)hPE?V0F|r+5_W6iuYymCt)qWwW%(1LI-cGfwInbT1^*z}LnifOIVoI=8*ifdODHG%xTSR?cA~(yw>*-|HEQC9&bBQHw&A+#|g6}rSXbt7PXzN9@TX6 zgVn3_;bD2fvZ2%4`{5Z4rS~dm~mg#@=S)tJTAJiIqq;=ZZ5znS-@`-78WpV=nc?NrfoyfRwKS@psDA7I{NsPa3E%Q9G}D3j<}Z<;?=|C}R?4yR>&5u_L=o1=vbUX5gi!bV zgT~Jp6N_H%V8~Sg#G8E;pnP^Ng=$?>dUL+gZxlG zuGlW`vcuI*K4m8J{Mz}!qBN;x>&dxxt>tSpRfbY@L(%dEx;W=woxm8`ZaZxse>8KR zj_xdET$`z^gCy=50-W%+(VlqWe4K6ZB&&u>c?uhg61JF~SUr5ozpNC7w&%!`pgA6C z<9Mc3@N=rsp>}(b{QREl^HKXGGbD%kATPW16p%onV*dQ@>3jiU9jo8|b~&8#lT=q2MchAfK~#^b)KO}n=3 z9n#Z$GB?N%Yt8Of^R(V@ksYl1aN0eUy(AO87CR5G%Pdij@9g%V!I9s1S)4YK zL>J}A%Nnx^>+|RG0o>|M&PLX^sJxlWcJ7`%{$(GeQnfEFh7d{R<8MEfN%Q^cYjlRz zYDuC`CyCWY>c@LYg>A#qsWj1ayfWh?)W4tW_wm+!$b6RIdMTjgZ+bild zU%iHX5P@AB?UQc!HS9QHJ#Mm(a)ESS>(PIxQLE=P$C|46 z3<=l}T4&eUTR0ht#eMCp6z}C|+D_~rPH3yw&dhmAIQH82;IulNTmE2;av8MmoI|VU z(C_j0IJA;aC`9Jt+RA&*Uq~q*$ky@T&7-yKR5P5Y+HQqk#_h+C|8+6v&pajlv12>0 zB;var9KNjVk$T$V`=Kh`9BFu)1~-$L$(}`ex}UOWGYb!Ucg-9XJ6+QI^*CiumgLbW z-`Ff#TlwU-;W z=0};GFsp*@{hJwqNmz9#*!S2Us{@T4HxX0VvhbA8y7XzmC zvqwvIRiet|R^ew;w6V%D`H*~`R6T)($7vRojb!;{n3MMI5QQxCbu?|3Mn1N7<0ZdL zzId6}lj>&8?p9y8&)gi@%yu2gKi#ES^PaOnIQPi=oyL7BnZraE@ z`sdYpBx8Ca&Gy6TsH&NDj6AcY8+6`tPb-Hw%@e}phvBBNuA=*yfxAt*T{-_`14hKJkrYxbn?x1P(zeLk^ z2a{1~x;$X?CzIwpQb{I5%RI=)wkDiqvyYL ziZ&R6KAFl(f7$-VTUhYYUm&A*^A@ObZ!_<{a7$i~h`HQyyP-21`&%vTan9efU;vJQxb)A(i!3CC%Tfu}59V9A3bigI3MjUbg4t@!0+-linqj z)}@o7WoI8zqxDFo^$)#CB|AHanN(i#pcPsb(Y8N@@N_9F#_8FUo3%{pNJigwvqvi4 z7FVLKsBcbs*6-X`s4dD)HKIIC=2$G@MdoT>Dib;X?VO!YnYZ2EDA|6+Xe*S_dHTh_ z_qcdb4s>#=RyeuuxT)Ul?XjdZkPcDao=xjL;$~gRYZf$J?_}EB`n^87b$Q-AI6fPP z+Qx0Cmo2pBIL&IzG!K5Q!YBKaAL_TNjd?mBwn{tVtTy^d`<&m0JfX{K9OMh3ns+A| zvOQXQY;85I$6I&O=UsK!?UIjIl%qyh)|$7pgJE1ZfxHV7Q{7LlgU+oW? zC#~ar_sPt&nuzDkLz25SsQ>P99gVFzQqF#FZ6iWC-9?yQ?S;%kayQ9t$%6`w25MC*}#W~A#jkD5|dBq4njdjnqJ-6;6|Mv!V zed8EajnEld@tTLLQ`Co@@%mzhWO<S2xr8$4>Odz4?A;*GbZOf~Qx!{PGH7*qya*)paQt*`Ozkz3T6jC*3dFTBG$A-B3s4PCL}6afMm+sBGA~S~iWj zouSyT-c?-h{JR(w(S6giEHU4CsxQN=yLUTAJJTtX3>y#+52TBS#upIz`P&*i*4(tX zz53eb*{P7>+w!Ha8@~5%x9B77c3QpWOVQgqFo+oUKm~t>(0y0Na#pGrV+DvV>&Gv# zMEz?1S&rtp-iOrLvU$H@Va0k7Y+n`Tils)kC!F zKl#PGOu9m3yOMLAaJ$^!emzUT;?CJabux;T*Vy05UJ)457pwIJ#mnnC`tqSs_wSuV z`o@PpC00pi#b>O}pOp)s=cl2?WO3Z?|vi0 z8t1p=pn+A0t9Y`J+io`=(Wn0twQi+<~Upo_6?}3vKsyRg5i8@w^p-5 zd7=1S*6B?xjFH3qe)l4qPBvWB_uH!S=8-mrxTs)V>xXZ9_QeL-yg^OV>k91lI!2QG z%|hxMKZluY(;obKZ|m(@7J~HZxHxH9C_9x$ij&T^;_9La_UwU~-#$D95wk?R(^E9l z-S)*l+gUlR6-CyGnU^uyYdo3tn=iwK`g*Cn*KFGN`vLSCBede)#PeA!-B$x?PwJS0 zkFMUm-z2b48I&C3@$5fzWK;M}Y04e%buKkYeQ`*&gRJ8t+X? zzRdTN%UbPPWs)uS9oCZp?MaHh_jo;14Yzs9j`y39?j+Z9{8xX8wTh}eQjCZ=IQE5a*Hpkp9k!-HM)kp8_oV0?eeX?%8kpk zEMPa9?j6L}*O(4P&B@_u>++}TvQM1)HJ$o)eBWP|-)R_9tiYPfAY@sG{H2}0{oR6Y z;d^@yHuoj%ECa3TB0u{!`iaEw0nJqaaWQPr?RRH%?1G=l2cm|j$4{&mBKZl9Vx}rc zh+6;peBPXX@22^$XB)$#C=$!<7xR_v`Eymq>v_+e^TnrF6Kt@|pOn24j&Wz{h} zi}2OYmv^eoNJg)Fmxf!mQruFU+K<#}(Ut?~t$_G>aFl?2~6!^_TBR z@Vhg*C*s)uI)U+$8LROR$KmhWaU2tx|BB<#VvkoGH$FrR$2ciA{p~oTleJ(CqG33D zi2q{el@p5S^*q@j`?KcR;Kp9{|JoUyIwGdt@3T~i){9xGUP%<%|9M8YN>B`5-}l>b zMwkDqLUu+cy5moFsdu(GZejc@@ykK|b%bii14Mu zU+#N8EaxZJtyNvW-(J0OTJ6lLSg>*@u!!k&Qm>`?c;jp0Z0-#OrgNCbch5-oDA~RL;)xM;!m9>|e9N zyg>F@>uVMaXUpI7aItsR|GMw&`y~RBcNO^4`s)*yZ_oN{V2@X1l6#%?>@N>I$MXP7 zervA#{=XHf7OY>5uQ8GDBS`oBo0FXltjmdG7Kbdg?0|>LNX6maF7rhhrii*T#NlQ= z#3IT{{WiS}gw65W@@MQo&Q53BZQnJa=rYpe~M@xK)=FPJ6% ze)E5gqz(;YzU{A(OaL9Pxx#)j!}BeojL;jV zVjh=!o_lY6^=+`7+i&i1y_?!8NkzvC$@l44md`)u^t=`Fv;OZ46@5YssdP5G^S)z@w;(R-uj zi6r(5E$w?>71Oh<3m;*!=d4REHZ7BNMi5@B1U(}&l*>D59fIl`%7>v&B=s8!WtVEz zXD?yDY{s(Iwh!sE{9?dpnyfQqeRzB?soP7JizK1A~$o|yJs@m#XRIA8l+1#=+9yuI1*^xlQAN>O1n z1d(%J2avz`W1%>n*4Cr9m3Ljl@`;5b4fML;-Pqs7nc3r<@m-3gZ@U?T2eAlR4#aB=5+v)II9N_>lm2B}R zIhr@=V!8Ccs4shxq!YzvXOFHo{%Vu%WtH|Vg3UT!+_Ptu&-c$No*G}ARX*2&t)9Ov zmPSqupy0Rjx5Zc`{)WT!#DF`2AFIXS?)UaOi$Scb=WmNy@#44@hwR?I9rLke^~WMC zG(Ue|3~Cok<4ayD6I=iJ&Z9cX{bd8!wsJCb;-FJzX}VR2u*s0Log%S`)r-&%G0(6rN~7(isu zslK39s@k(Ru5AC#O|o&LU0b2}NHfpn{}?0I+~~LFxbOdqTe!)8?2ZNM+U@I2z)q;~ zCW*)6WG4Ok;0bsj4}*9pEsv0x2Q^n z=7_K%hVEVOP31Is`(bZ2DWqBNThNd-FL!OdkW)=HsfLB{c^YJu&k^!!751EsugVCS zx6)Z6udqXR>nqCp&#}py{!i^Yd&My#BfCL@pZmMBWU52RZ{|MzRxaora6DBET_qX* z=ASX6b@N)%+55&Yj$Y_ImW1<`nXy*Zz``L9gXRN0w9(dD?qJ`c!%VyP9mC|tCioQ# zm}{??(^aEvam-8xJ!Iz)y}Q1r_@-0bytE8mhmrPd-|7%DiRZ7U?q-dz?R&p{oaV{f z+Oy)~p*Mnur12`OCT}t5x#!roa@P0%-5I@?Gc*>0*w-&n;82zK-#+5D(~Ro3Y0he{ zJIBs~?8|?>kIMSRi0h)M9d#n>L}8`s#i;we`LHJ0W)uG}o7_mz0yWG2&pxb^rL0>v z&Hl8q%j@5K;C=e+%WHGj^%Bi=(PM&rJ7PvUPO|E7vlrR34t(oM`-Zp(*mowh zeb$?Oaol`f zKdma_JG!z>*P@z?J+H2?=JU->^@aPLx3kYv{$w_r9-$zK*}zUQq^rK0+L$Mwd_d~Hg$}VoLeUM!0t~G4lAnTrVDa8$sTkX~M$ZqPV)cpf4}%x*o?huF1d2y0Jx4=rh_{ zu^(L6KXxnvW29Ngyy|v$iwIYpu-%xIU(Y0VV$*D>n@!5C@X3yKa&bYKrnQROuN*0mivH(#-EZG}t?D#$C)%#nGGc09L{_aoWg+># zFF$X>7AfyM!D?i-8h&-J)%vN9JDZJPAk7M%vFEB2_x9^R?*Q{fUN&o|MRqJp@A>w5j>l#F=Auf(%+|&qd?f;p zZOY5pnr+~PMS9DSBb=g@!a3{G}jc-hHXI}_rod2Hvo zMWA)p^Tm|w%RJv&{a(rHvm3W&-`a$!YOm^f=<+iz!#ie*I8U!P7M)c*q}W^jv2gJT zU$SzX*xudobj-2$&$z~@jAFjoy$4IO>Za%Tvxi#mwYO5Dk%sZ1wW=z6rds5@k~E?x zkFah$Q>T*z`J;7JKd%;8*M(Q|Li&oRc9Po~`_>BW`~S+B$d6s?hDrPUO(Zg_;htQ2 zry()ODm=6JK9(doE~zuG>RmU$Uhyd#W-X}Bnry`a^_5oaTM7OtcZP3%Z^f$ou+*so zME0eCmC8}A#H!^=d*9gd#JJSYaMi9cW%#zXQ-qy_e$J&3`z4p&`3f7yyE}`4d)d#* zKgOlxj<>5F?Ru4C`pRDM`6~w>FCT-qXZ9D5opF#OyKanyNL-cZF3bMx7jHAHUDTxW z`cC8f!l7rN!>+5eLq05IV^&!eQe{wv+(}$Gt5dBi+6j*5+U4NtW}@Z7(2VFP7R~~v3~<0{wFKx#SNr|DWf}YSk%=A-D$99%3%-KZ;jv0*dtbf{Zghg!SJYMy{-3>val`S9G(x8HeX-Se?076VC9 zz%UEcy+b*V;DHcNw`Ta?SjS$SJiOjf(X#JCr*DU;nv7744UaIF2GCt2dr z{+HFN`nHzw_8>>2&))6X{gqpa&1SY=O#AicY4N2D=`fBd$Je=!$CJACuV0$YS~Z4X zv;B5_GSs8eZ1Xp+%P0oqlb(G3%>%v~!%rXjW})ivzRZZt<8U)$8c~GF7MuIF?BdQH z^PgXJ#zRGT(Qd}`y}{0g%0t$7)o4{{Hd^KSd}~ndrYp%le~u@9*;@nmSHH%NU&>!K zofY6BD^w})RBt`(Z0vI@8sn>M#ro|qpRzxM?8%*kWD9<@^S%QH}))&J6*{B ztC{>%4cW7CrO`ZKpXKeY;qFGWe->Y~@OPQ8x+l!=eQ_8@zP?5{=xp+}!y6lk=d`;j z;!B;@5xMiUPDrr=-z;~NrT8YXJ5M{i^R0cx_y1K=?_ha*60nlq(a*2H&%!t3Rk-NO z@N8Tcu{Q-f$EN+xY2&*5%hz{Od7fAoBF18$)j@pv|LeC|*7wWr^(XWg%Xb#r86Z!Q z6DC+}J!aXeOfpYnt7kt(!?c2EPs6g6Sf`WOVsURHNU>PVU*&-tHyY~}$uzkBVPE&g z&GvG?b39L~P3j5zKBYWC{(W_Ut&%CNtyKOAv(`KgaJQ#j6{D?*v+$Z-s{u&jY9|x8 zB9vHDG`nwqJL!5zn*7*F?6W$#peN3678^8bM!CY1t24~4bMC6V0^xQ4Jyj3nx#a_E zz5YH6PcDPC6IuB7m;XKs>DmuBwE~Up|J(m>>n>iz2z_J6Y`47HI2`O`nSN#V{CwHp zthzgv?>pHsW}eUQa@W0OJ^Pu9ZO-xoZ`o#BvmD5gDR*MH$+&x-NRPwYv9^2GDET0~FtJZ+ucbF_0= zww3xFUbY-Ed(OISQ1xK!$9}7YRPM`D#pJpyarEa&b5;KgXxX+q)Jd>WO~0#DT`%I}zw(h;`#PH6MO;1K zzJ|&JjTbG~O?x_~e*KzdQd_HM`W7~R+ne}$lzO(;JFzWIR$*ANNM@aNBUP0o7?;Ne ztWs_GD#>YUhgqj6Zhke}bPAbJ@@g9u&bVV$m_0W>3+J_0pOJshd_RhD))PZCy2@>c zrFGdXER~;K7iU@3vwK^YB-IX{KD1b==goP2?^-0a2a9|S$BoYq!!b@`UA4fle3m)8 z6m7-#$zP-v-&x}_aBMcKBvBeD8)EfmkJ*WaHkqWerc=GiQl*XNT@WqN`&0 z#$NS*)aIJr*2 zy8Cj`TKkg1&cg=8mmiCu&Mxo;L{^h*ZFb$6f0(VVv35IFt?lcIjel#_{Qkdw$h%J5 zixd{7Baf-j-rlXsn(2MN35r996R7$eDKh_%!Vm0K%;ee6aWean0(RJDZ$Vn;l`BM( zIaSR=woDVTswI zRpX?tv&ZZBt4+F>Rob__c2=?L);z1UXW7wpR`Jw*xjCzN>Zt>(e9LcwzOo2}Cn3vN zv)?&5t0YI5;`if`@{6%?)YV~-`u4suEV+iA)kt=-YL#5I0!*Z5*tCD&nog21)oFef zBa^G=Z-TO2d}9|YPD8s@-CZuKmla=eK)c3ro}}R-DJ~$ls=Kwr?@l+S*Huv}5^L`J z?6WkbpRIq>zyEpl zlYMrANuxT9+gX+kTIbpC?i8wZ@+HmtR--d$ zn8p!htkz+1-G*`Vd$YLpTL;$bJYL7^e@^wFv)ZI#X{+RQS$o7j8~rl3{ruBEe)^Ypsop;Q&-eBH|KEQ4*Z2SY<$F)~cklP!KK%2q zpZ@KA)-vAz{kW?pV4?g|2l?+ma_3)uF-|tP^OvFi;L~4y`Y)e;?|t9j|MVB1{`r09 zfBWIihYaq_*@0?^Y^E+BkSRY zvd|*Dej$6R)=6GIQ2{|U7OINbo!)HsZ6se7W#OwD2ahCeeFS;n%S`CIT|LFlJY1*0 z_m^cLIvftz;CM)s*{_VG@ZbBLe;F&ERR%ASF5RS&5d{Jz1TiJqF<+6`Xh43&oTf6 zVQBWCIbCt&u!YgZid}n(y{+ddmZH;kqCtq#3$R_fJw0VT`&Jo4SN2+-_EYaHSb)B? zmaWzH?(q~mCvo|us+Mf-C0Ab>y=rDgQdSd&y}iv7u~xlv)v)o$?=0lRm`=kiYyH_b zzPJ0MPk-|+^^c!&y>bhgaO@&SOxhXKlLMOBC9H>)_?<06N|Bi5_-k1!g!4!J{hbHU zG`)F%ZlMQis}|RTW&h_6*f2kNrbu@l2R%vNS&I6i?4Z(O>G9$7P8|tE$D}OYDrCY1 z;j!E$Ct<8mU*`xpU!T?K2@I77?=)q(4K7rLVZ6Ew{;W*iRdaQ1{7{A`p@^lk(KtI> zu)0+tkMELKyt2_#hFL{bz06>~YJnd}a^rGi*6MVKC9+1$8{32!dsjn*m}iWHnTr&x z8TW(&TJ!MU?IbY-@OMa9?R4?%9^Rqn>KIn4l6d7T@l3)xd=h(ZS+L(WD36G4w%EzQ zNkZ9Ivv1Jv!sdv@dI9 zi_Y{H7w!1xKjF7Rl6@7tX?wCw+?vlwPMp8MR2r&oD(_2pr~sR^0;8jt^Y zrM@N>k5j4`=#BfK54x&M!V0Uu=Gu)zZR}k|@io+bStnj~_K%;c!v5xc9lW}#@XN~a zb6yqKd=u-5P`c_2-af3$K6QU$MCTI=#HzR|f2!5;GELYy)bEWz^-}M@o|eu|Wt%cq zb+`D!X}!aFy`#eB50UMi9Dgk9$NGJLLIP-CeoR&~Fp@pTNw1Z~@C$n)1}o=dyO1fq zf7MhehN;Ed0kG=M|!a zmtYUi#)K-0YASXJry(Kj;@8FL;>YUNhSo1R5$5ZK(kc7H1-!VI{#SM3dgJTN#3y+$ zn}2&G*~$rHr)Si;A3m$y$`Tu6Pu3Y)^Fg|jhhN4bJLfz(MRM^>Q{$_$S#wN$^TYqX zBfIe@=derMV${0!hyk;>+-ZDyRRoGhV<8HU#C+*yt~=$?KlzXp-aN6)NYAR3agz13 zPMEk|k~iEfuf@Hs-S>C1N4w18t=a6P3Z1dUR-rRL(*sV!lByXN%CLM@9Lqmhv%FRA zn}*&aS)*FK9-!L(^PDCh+jqT8+^{HzYIvhhH6cHJ8|(h@2kZX$zasF;x;#*C*-6Fk zs@dOFv%jlm<9r$SchxM$i=9;5|9Pv~Kl$L**z;@Wpr7-q&QGT3OIvJS6}G-v_1@{0 z?|l1u`f6I)s4P%EQs2o!_*&&IS5?c%EbhuCdhXU#OUoWI-f}t_%4Ahit0wD3%2xHT zS82M}yuBUKGcPx)m|9;oX9Yje!kLv^XwUV>Wyw3eqDT2s4cxQU*f&1i-0!@B)$C9E zb%3<09$L3jZ)+a=&%ScFk@b%5LpMuxGPo~6I-igW+~Y~!kjw2kt+VmffbRL?f^@4d zdai20I#n5E_UFB_aJ_gHYt@8$jD2|cR>|LdQhiXbNwPYGJJG1-!DOPiGlEV&J0X3& zb0=`#x{`yn$+^Ajaq3|I@EdEAKmCZEAOG9Pudzeoq)jUrs@ii#{Py7wC>XlV=VFF^ zoi^pC`tACd&HH?oE`)~lB%2k2in)3oI2dAvsQMT2xH=eO!%R99m3M~Vo=P@vsQBvQ zS=qgQ5=&~~SOCJ@Yn8r~u#YvWt)F-5CRL&9HR6sc(Xe}Kh=@Al;yexs!z3zR+51im zNMrY`BMx_#;12Ef{4nI)xmfJ49bl33Ag!BWbhfP$^S{M8+wYA?=ZU&#_sY^@BrDn7 z%rHPh^?a7M%GLE|?#*obu~qw}1vGl{^=?es={&@wK~||FH5O0vq7w;`S*HO3X-}%_ z$vhKBu`b)26(_#l5ee=rN}X!X!kGALxQE}H^<_8l_wRZDM zxnKQ`aFPF0-wcv~^;qroz2L8YGuU6N-J3xcg9p)8uUW3qU%_2@g}+_XTI|OS8|}9V zFh*W#F4^8be6Vv%&z0N#f8CrhxZ0{=t5LYK>dtB##bn|8m}LkHE6s;%r!33YMMKz% zgSy7>aBB5v#L)_L7`|`2VR_6q4THMc_#x|Mr4l5(j7iK|LT0PxPa^I zL;Kifkz9<}w|Ij;UI+_WX0|il&Q3}BnAS#%l>EJOfbOuFm08$2uNL-1Zvn;r)~6T0 zo4*Q-2IblEk1pE$EY!}uGuoLh4PY*t#^Gi7xA)bPi!aqjYPp}k|Chh8g1fzgqwlor znl5H$!B%vhm6fe%o+`3`$9+D*Pt(Dkm~|T8n*>jGVo8GdwS8q8lG;JlnMWH{K^Uzb zq(OG+eHkR0pGKsxN~eJ_=APJl$9xj%&4+92LpHzXEmqoF*6<+bq-PQ6iQdnY8Ivzr z#RSbnb>Eh+-VD1|5JfCqt-<0}z*R*I)+E=^E0^y(_1TVwSSFk7yU%@&x=`kgsay`AM=M3;ryNNI{B@-g2P5JGzc=Hb&g?EHko`i;v@KOr{{*^P9qS6sE8eaYK9n^jLcJxCqJnm@HqV5C<@4(i=?#f6 zXMVEBnh9_}N!*_P{hR;w z{tOLC2vyeLE!Kk0GL$H-wx|{<8r4|Fi#gI-Da_#|`l#@XvtBhyy-!>-T;|s#HlP2j z)4n*EF2&Y8k_QifIn+D@}678LLv$B7>g>?4frOt48 zAjDS#X31r;FiD#v3**f!&y;_&^)v~GVG`TsV|Ew8))3im@Bg*3m`vxgfjo-I`Mh(8 z)qr%7mE$L#%ofSkE}iZeXTf6}n44wXS&maVjw{% zrzL&GUp0kwioKXe9PA`1UFg+UEnz*}XIEGZ=T*b@Do1n{P*r6&S0b$F#t$sJtX(yS z!KPVRvGvRiA9=)?7n#y7`N$muT6c_1>&^(PyW)b7Z63+0In$?nB3|rVJWNhItV3ZL zmP=QBz}ikBiXUhUfp_-k&h!u!)l91|;*>fqQd)hnWbH6ef2zvQht@cT$NcHUdON{n zjp{m4_x3(R6qGaAk<`B8Wzkh7-DTP81XpAZue_HZSGmk@Yy}bSiVyOHzxcs;nY#Eb z1LkG6BSl{uhYhm3YTeykwAd>SA(5-PtGIxVT&bp;$7-!c7ryUAF%K7gdA?YU2V1if zZ2q%5@8Ab)5f40hF{Wq^4a}MBu~mDMaFv>$sle<(+h>!U;ue$|4s{ zSFiA3r%7hOdnkb`NF!HTXH!;OELk_-sw3Xs$JsV)?{tE%jLNl1(K&89Jovtum99$M7FWJMeUCNswOC(gmy9%AI@T|S;*pu+`_P&w`uDY!{ zBJs~Z=2oG`OC+Qv|4g5{$K9VcSIOCTIHU;_g*(<5(&(c$3cJPIkkLWS(kXo)> zM%_MEUO!_0ydv|lOHcE2++RPOzv@=`2=c7|n&KIQ-8kxDP#@(e7%QB8rW;*B($( zJd`o>y_v;BXCLJi>t^d>GFwBa`}LyZI2zM-F~na*nWy5gWjt)#sX@QD)>}V3sZ%9g zQdpxZuranOI^l(E;jPGK`KpSlAt;BiEUk-*>+Lpl?K~r^hN2=jFWI+fz?@lozO{Gt zf_t92-=z0bm683kKs>rw3=ypBd;=mnGlQ#;6e8wZYwSH`wUX@Rx$4Uzuy^1)e+;+P znnQCwVn-{oAkUMdbG>pvnRrYWpVGdrB5xRtPdgvTKJvxh=IsoQb>*p8wpdvek2S$Y*4d~%&Y9$uWAQ(l3tdxM_G?JQEP*EeURwxO&Xu-o+doLW6qKUe&MTx1QN~P##~O(_5yUe%TA3kr2N89Mdi{k1?}HnXh_e zXVrcRJMj~nsMf8?Zmd^)#A1-QukF+MGmn z$;4nabv&03tnS|Q3w3sU%4XLvFSbb2iqMio)d8;SRqaL#9@lroT@Y`tLoSPk_jIb0 z5;u9cjNds*^;Ny8Yn;$5?0HXgr#-VanPZE1X!Txs5jLQUyq&(0zP|w!ri-#p*NR6n z4^ucNAG^vcd^#K2{dEO4w?dUq>vm>f^iFNY*4Bc{w|DDV4GYH(o!jAK7T#L~(tCyt zc5)vpTEFTpJF)~j8V?t;O*|`i74NKTv~G``vS9wEO%`VDcp5UwwOO=yDt@lpG`rm} z=6Hx^wBg6{O=sirc=@Y$17+s2fgG=^olNlHI)=8tSX^~Xub97C@$zB`9#=9i(R+y#@JGKr6}Ig68*ZjH&Ob$xdZhY|A8n2==Y zvRZC6W&0Kn_3q^A9AH&IUg|`F9L2n7APfKS61$AMvjxT-cXw_O3buFXB7gl_K3P^M zuGu1nEE*Rb?cH}6*^87H?U<-MT~!R-WTo5u#=fvn6%!6HG##y(6+%;zuPO-ZJep;p z+Vj;^+g(vG+8~(B_ABKE?oJ?o}6MNc^9qc4s%!rp^WZ$BU8_O$`h9#|T&Fjeh1Ox8XL9Dv#r>Yt96+^TvM}}Uj z^P+Kf6Ac)JN7$QOwBB1KGR@~^^LQ&~DY>6z`L|e$)u1&Cwo_HC)m6N!w?!P=9H-HW zhQ_PZMLca=r)-#QR}Yb?{+nm$9`{(my?O?ffPKO)AA}5gTb%~gP^7#wRkjWX>9NK02R<9>dpFj}T$WhY;L%v~R<~-sA}hns(DcMo zyV#dZ@JFLgwr4l;*Hf`V5lRO?wM&(32o|NR$PS@oHSVfD)enucUi=G#o~tgWH96pu zHLD3(xjZRoF#j#sL31!I~z<-N6ycfy9}vdp{?g6E+;#=}{Z{GB@F zKYNy4UF}q-9m+CkT&%=3%Sd}0Fy_ZNW-OCfaeZZ0T&5u7a+vGboi6Rb+Ud3T=41G@ ze2qrZ!lmd8zifvkLh15-naK`gl4{GsFt8ZXAj=!x|oNhqd@| zckmlbiqSG?Uf}JG*E99*u}HOMmM!=5i1nd5u6AAL91rnNamYH=JL5sJtEb;SWL>7q z%WU2|bKa=uta4eERt92=>X!QH-q3{`D6KomZ@MLBUo4I17A50+K6u9VJR%<5 z>w719ipZ?AbDiBQ!u1k;T~lqd)6+!`K3V;~&WKg3D~7IeD9eothFZGqnia_dna1%* z=hPxKHjW`yDd#P+6az7i{Z#;Yr3iCp^}KObh&ifN%Eo27x*~Imp)#iZt5>VVU`Z~3 zlx2xJO5+%x74lx_8ivA_s3=xK!s=4CD%;d$9Oh(=yb=nm!-H00x7X@2nbN384~3l( z#*rc>YmlTjk=b^2Rfw#&kJmlX6-ntp4w(Z^#1_qA!OASg@9HO9+A~9W-^#|1VWpGF zty%@|c_XZ9FB&(-|8?Gnu+VHip4~~R`DSi@b?r{CL@*nYJkA?J_(l#db1hr4J9*&4 zohqgDEF0v(_|{rQZ%BN(UrEI2fY)nrw^WjfolG)J7KBAhR62dqn&cL>v6PwcP^C`cd}8$i#i&u>V)rn1Jn6q*?=v= zE9+M+$dHqQR#`C%7iBb@RJ5}C^9+E5>v~1uPHTp)WPlL+bk?4A7T-L$I4Y+s;?kig z@#HXHl-l`D`FIY7tyGp_SN?-|Qj5LS`^Cp>OY_cJiYfcEUgP|<=T#&jC>zq9T+20+ zpf@n>s{$zM#&1;!EHv4m_U!|8R_sg&a%hZw$b#iBLLm1vwtsPBPH1Ea=x=;pOa6H* z>{QQ~&nL~lSm+E*)OW`-usGSpuRG)n)mcu~>j|C+w`0t9vC^>adNJJi;=^v%F}fbI z75lx)#?zDba!qeH#~-Vr=l`sk#AWe#{dxo1ucokH#sWLXvhq+#UK$yTCqq-nNKXu4ZJ89-+cnu@Gj$Qf!#3+f7#)Y!@mbLucjxz2 zNh}|G9bVH14-|LR607^M6|#kd|*6Vmaoa~Lyo z81fu{(QbL#4&j5Ii{O~AJQ0eQ#nPp`)1KM3S=Gf*wn{Ms;IVc;{H1Gq(;Ob2nRl6T zE7Q}Q)f>xdS(V0nhdu;(f{bN+T5fAwX#to$azddFIfBypLOe#LE!nw|2y{m+7Vb`fQltR8aekMYobX;zf8 z1__)uut@uoWM?MjI%p+{Zu9NKa~wfC}pt zi!6WHuZ-iVSFR^R_zH!z=R0HbgzNau^Q-8(CatS-O}37Gj9&D#@7ueCR$ES|u}q-$ zF(c2zL%CyT2(v^|K}yk`C+dvj!{QV|=p!EE&={ckjgq|cl1`VJomZfg2f{@9RZ$gH zNxj6t54>G4|eCg!*a%=!p;KQ<79QS+5wT2evhHSs?D| z+`-KLMzGcLGHc6|P+HWtN_N#L!E4selFg$tY_NhiGgi}e&>=Vv7n^;O3z2FMOSS#H%@EC5&Gr=BhJ8|C3Rm^{T=9-;?Z6jNCYQ-z<D8+`%ORghs5#{OLhu5&&Lhwq__83 z{L)D{%k#1>ljM0650IX1bj5wEy&fSn;MH=Mwe8LCAp?8O1C96QGoD zxH8&4TbrfJP>r9qtL2Ne=EpVb^_-#D!85xvjtKFy>CPh{aFGy-%gOPBtYR&9E}M}H z&$LUn+b-^{j5RfeoyUrB!wjT^cyFI%;W(I_d$ zRO1)poi&LO*7no1EVCA|ENphYmHgPXx%^&^!WSgVQXvIqj9#`F)04-Vvn)K5c@>l_(v!SHW_#<#%oFdB9b(`l4~?g3l`JshO5Np| z)3YbG%l-}#+3leUZ1)(Z?8}y&uv+OA1?zudb!U$|SKnGB>HQw*X?d2lYi#Y_m@hrU z;qX8TGt$S*RVj6$WwuT%<%{8z4(Sxq7Ejij9Mugw125a+#r8VQXtJI>rYd&$@lJ64 zG=#HP-|y1Tp6iy&|HFwVsw6!PZ`8zQ|t2N)D2;fK|lRR+sIP zJ!}@YRl^|@`+2H2KYXR`%KxobCS8rt`v=zaR5oO{s+{^~_IC&BSC=iS%YE`Cee%&* zrI=!?vgIU$y5hIIb!_8_`Y&;6-uS(E!8)E*W48+H;$9Xqw`x+IM~9-k&c2hm@EW@A zRFZvI4(^gVTg6PBaIzTG>|07cihEm4tU*X|Ft0CK$O4nbvn@&4+)wuP+?^@K7|>va zGO*cMrTiq;S)+A$2)feS9W`k+F>CZ>o{n4Emt5l$Yp@x_#s>K;|94(c<`Xr^6IR)d zj#egxd;6**nO29y5Ik0|GrNYs?9}^>G6v>tClZyTWHb9NQ_xl=2c0C?TAtYXSWH_5 zZM=9j3Q6KyuQ2{GR@K4!bqTD~^Y#wGu?KeQ>xycMM(ZcmlksJ&+BhFtB`<_^S-1Wp zkCBtTV1#VNSzSz$u(&%REBj}$5Y@@+>hsp8N9+iFWUxmJyV`<0@n0y5m5ZV5600xw zf@szJblGQ3Q2blpXcRZ|`$gWyg=% z8*k}1V2-uZn!iPZF+0TVP)dhp^I1C2moeO`z}P#FZ52L?7dqk1Gp$~QStPF0&#y3^ zeaK{2x>sLjpX#I5sn*Kd<#M}LeW?5Smo@V{dxxLUM*px!Q~T^BusDyI!oAoP$vkv? z*$!DD9AqETB}qDj&8qr#W$$TN?pb`xZADl<3XiLrs(8ag(H(D9t%NHc&0BZElD}A> zT47$QAK4c%{j?e{+=T?|S%XJeVo?&mvBOT~%1H4mU1!;}375t^=`M?^uC1ES?k||~ zWioe4vFgKqvmXD%{Mc{xB>NVhL(s75S^e7Qf8Fs{|D8mCS@HLO__sU$`a`cWZto%J zl8tbI3|Q2O*ddT->7AXQcfu@$iSO<}5X`*2KM^~}uZ{0Jg5m={jbi~jbPf~e4`pp$rq#Lj_MGMOiR#7tAuh(ew-Qz7 zti}>?7%9hxMLYg|`$4N=gVn5Dzu_6&$pgF|Yh{H{67ogLY&{*|p4aSo02?$JtN?ON;anEeeas z>en&_i>!YTwb^hyUFT@szTR_H)=ihqTkk?HW0Oh^YoGPht~(;kJZTs~m z*GfCRv}b<-$Be#3a>dqTnCxp5Zf_KiSc_H5Hk}$&6_O^#$!0}~zAIgNTC~M^TesZd zX*=ox%MiU2F-~TbNCB!OO8acnYAWYtw|^9EabB6*7E z7|dE`RlhdBojWnELp3gIzkRGjl5}^)BU!#3#!kHr6SZNAJ_pywcU(H2W~_PKr{ zotK}j&-(3Jm6g6#(Z*NZxyH-O9{p_`zUowKHb__F@%32dX@m8s)~u%;BbM>ncT$=Y zzs+i_?|OZJYmv#C%{gltze*>TT_nH?l-c3({be86?i)N?Wb zrPV(=g$UOejhK3uZ zol3-d>0kbpgGkYdPx;!c&J|si+hw}px!5a*Pb+JXDI~Ub--uutyLJ{+hL{y0r92f| zvVE3UkHE$s}@ov;tflzXyLoJ7~YC2^H}I%VsGO5P|>LzbUNS>6~I;EQ0J%Ytc1~U^>r0UXcgC(_?pct^gzE_9UxR9fnArlvzIK99}FsV8$~=us#Uy zut63k0d8iEjV>A>$nNol`K0kg>lBG&s_yKs!xs&$nwC$#-ZS2ofA7Cu@Ut(va^A2L zr1MpgA%5T9ud6;{`|Nk$6|o%M(>M%~oQ-*;-Z~4iPT8@_mY>bD)0O?55M}3O zKQr2;KGgLxsU7oWIcD{em6sQb*G~MiADr(i+m^+Ua*)$Is*{~?f zn(5vta3#-gs}ch_O3`mO8kurvnS{A#&uu70s|{@SUNN-}%u6XK>Zf_2KC^);>2 z*&45nS0=&!LPS5aM>P$WfwwB4PM~Nr&%?Bz+IKNkr-x295=thiNV69V z#bLgTgX%$(i-uE-K%gz#ClhAvRlX~ zPbE{Vy1Y?F-5KbtSghD{)_3R5R_wYA;re9M3$Q?y!unNGWSPck*&cLXRt>xIRQEz> z>xH|WG#Bl~PX4X>$lB}Gpa+KPaiDOq5KoW_BK?17Fzi|ERc}>2S_d-V%oF4*cJjBU zR*gZS9t-nfijm0%_{@|F0acYsqUe@Xy z!yUe_jyB%g^svHYGhS8@XZEH6baY)rhb?wog%Des6H4q< zXI3U#g>FXU$sbF$uWR1yZF1S990WO-l_u;KT4P>#P8+>gJdww;Jy}^Y4;7KIQH6!)vACyVi{-4pS-NE0LEUEHs-oX^rPhOI7dei@=&`Erp4 zCt15uT+w$Z`6@Q4W)lT=UN-hz{_VU4_8zuTjmm0^4flDvRiQO3@Dp3PdlmY|@^p9eOe@B% z@rS34R{PthN+tVu5@8goy_cS(?ASP|c>JnkKDKT#6++Cd0*-0ar{rjSvoYFJv)G15 zHx~-(Hap2_1*=UuGpuS>lAQ!Bx4rgVJMA%-aZ!{d%Ok42vcsL4rW>Sm!s~guiZ9ol zg=JIdVVf{D99Xl6YcJB)U3W&YjuX!6CSWnIhu(V4;w)Y!Ptu0^od_9+$!s#Kmodf0 z7_#{>ef&ZyC};UQuY7y|DJ`w(I+^iVxri-1Q}v&uo?zp~>%LhlsXB3Ot$EHqt~=3n zFEs5;w=?19l=1raWj#~9>}v5hZxmDY9oA&QP7_6K+>V>fuft68Mds?!I}JOn)=$tX zxmbY4{CGBn@Ns>W4M{s~Z~QV8-{pa1%4THR$}v{*R1ssxkJ-N(cd>xD3%60Qj{8-J}xmb6~m^(F@C9-CFJl~yUf!+?(G1LomLUeB8`aGzcTT!y`dwb21dkmNV7|EuxBi(p(4P>DANe zh2ORkL>i6f_Z7mi?DkF0qN=Rezk|SEm<9`yQWcwi7_7B-T9TDEy7Q`Jf^(A2gT(^> zRSjh=yH)MWblJ(SVmijJVzp*xX4$K`=gIi&D&KkR&Rk=c&hv&o>|*7rNq&~oLQ6Tf zs6Eaif1Jt#Zy!(RGxC$id6zZAYUqS{V?{sS=JVsCqWQi92=Ug)4%IMx;>j@NnJ2nq z)2b{;w`1O7H!G5$4wNrmN8!(Ud?_7;7du%CnyMYa2 z<7wW{#;&U3%H2hDSgOi3PTG2ns?_C&YNc$&hOO-`%d&f(&8~1W=B3%#9^1fJ+=O{! z&1EMYnf2l`_vB4`#Rn=&_i(!PMT1#&)U@5%b9_UaqNk`|M@1$`vzB@{8^JGRk)V6M z9VteQnF%AwXO+bQd6pqM^B@7t(WsLfXUd+bMu!Xt?_T(o5kg`$J$@a}XSuRj+|ntG zo{vzp&SKfp6WKxUyomILfK|wp ztZ-4Csj4!!Dq!Aa_3~g*;^|PqMs=B3kqp?Qsvs+sSE|NBRJxIjreUaaBvR)+sN&Dd zee_^AI;Cy(K|kGTVAx>KGVr>W>pRvHz(@P#-^N{EW~n+DH{baB>7;5;*Ut`dCyaR@ z*>_FLo^97%Z=46{YP{_5+6Q#HR}~Wq^G7i?UGw6s#@dVRy@eVNTs(2ix6j%p5e73A-wN<3>I%(#$tWAd$OHOF>Ec1;nHlP58V7} z_$PCfc{?FvooZgV6_1@YbuyFfjI~GgQ}}Wg`1XMp{s=YstlwM-LA1;pW%}ga`FNaN z^x{mqifB)T|Gq(l@Z?+kS$!;8JgKTYC#O6av7fpZ)9p->^@rNl8%p}Az3f}YAO&5r zLVDG8%=>xO)9YsJG2|N0>Q>^v%X%@K_?@M1t&mV9(cYn$U#c-<^|)}^)6SiZ*s1Ih zf~rQcXpGd_)!q3-mOfdqVe*!*!vzWF7y7n(C#Pm~su@4?DT#`ZEX2Oqr1O~~$v)mU zzP+!V4p}7>Ip?yXC=C(h^i2LYtE{_@K&;34Fv`w52ii^|Ha-s}>9kIjCYT9)JIjwO7@pZ&4Vlep0o|kx_f>>@ za~Dsu-@PHH$>Jt#6>DW!@z84HqTW*A3ggMbh7e9JyRRmIrK*TAo%!*7TK3L_j!6{T zjGy7Peh)@?4x)-K_#CFIOBXv3lLW1kB|HnSNm*1`zYLuRI>o3~WJl|pKfWqg<`=ed zH|wzCveot&@6xW5be=BC@)5i9SapWgW1h0|Fm8MvaviI>3In{pQwzRbwawO7HykdE zwxb#ADvFrqXT46^A<3#kepZH#)4V)4j4w}zaT<*g_QvHTBm3glwOnd8X)Xg9fBOaY z7bo*%{NSgZH!eo1{gZj`)Cmq7yUudG zpOq`^^F(pI+_FacS`C`pL;T-YRjaIx*6GkLoX1d@k;M54-uHaosW*ugWdW>cwe?VW zhPKvQ7R>S@5)*7k^7^TcFHXA5GFzz_JwFb?b@>EKu~RimSg;C4X1(kMK_p?95I%fh zZ1c!vhfb+!jEm~`J%R1F<8$qzU!4yP$6>RdQLD9Idw156{X$OBx`?DtUMe#DuXV^5 z>(wc29eeR#Jd4HIiw4V>knroC@`wXb##($^T!c|xSUzWQQOe${?&Q1jB^y?OufHZa z4QU5i*<3x^xY73G(J(faVD&n0&>qd{Eg$<-tz{|lXDQaS2M_ZK z>&+{zWaeRh`87@b#5UuiDyQ9xFIF{l9*2wa7%h1_X6~oUQS4EDLT)pMfI3_8>6t30 zs)L?Luio>B>%6$XSI}uih|AArj$>A3#3(TihG!v_75@}tELe|P>@=fnkOXo}y17^H zMe6XK-u?Z8&QYBl9A2xPX}CVzzQtYKwEBs4ikG@qIr>8;sW>vkfHja0rJ$ z2&qF44OQ7Bqg_~F`%c8$ZE?yzv@JWA74{y%zUvh25x2&QaqG_0_{u7N7ENIiBXl;4 zn{cRA7PoP`^LAI&+|b!iu|Zt4?&qS8wL%k);WHl0`$f8)(z)}U@~U~$%2TWA#tz{M zYTWY^nU`VBp1rI+?u()MqdKcoq^d&|3+=E;Gh##6RSS8~DEsmR8@igkc^wMhevqCV z{AxxgZSAp2)t$XHwN59;I5#g2^PVb`$c3!8dZ4H36Y@dz4y(8)rr4+&8J6-$T$cT4 zXXe!i)$#VJ7AWh)l#N1m=m`&9#{ltl=et9ReHSTwQ|;5~PM)qd)#qNbJ(|{HTZRzI|dvR$5|vdcf3`lvU{NlSCQVl z7=m1N9IG=~cxO0SIL(&#c{;XP=NS^yEZH%(eX5>|#iW~0t(;w(gZ1(T{=*GqXP@lW9>#YfN~cxKi;>XUxh_eeV7*q11FKo9XxbhSyl7?hEH+EWzE2Nb z{<6-nRjmRCNgm3x+j@t1Y1mm-AMeMN)i$dl*!oabCA#lZtXjmi=Q1`OTgCs9W|3UX zlr8YPdwF}TW4FCgxAvGGx@8GZWRd*qy1Z$Zr#<)!65F4*aoS;GQCsJXo0Hobvv4Ti z*;YA4ZU5_b2(UxGYFaT0uJU#@&-Ql;VeOnBh z?iIV9wg1oG|CE!DY448lc_yq6Vdku3i9hpN zIh|)?MZR6t&BipG+DpsI3uH@0O1+UPls7{`0 z!`Z`{cCE(^(_!!;P&AUWyb!-0PgZBSZ%i2JBw7MtcRWXYF zlbu|P@MKgEv3?!oz6d_MurSu_$-__8fYHuBASPtTjCS1{f!Mjz#`s2DwHB$d{@c4o zA-+sd->By5q;uU79E9B>hwZEFlBgI6>7=V8+OFo#gXFAAE?&hb4MOhzD(Gs`PPSJ| zhP6&5)4K{6@9BVBkSlbxeiPK@I-p2DAHA6hNYBd`# zTcs+1FJcVi$2_4jt*o+KV)a$sVgBB^WZnE)-(8JB^Yt|OHosVlx5G8>=ie~VI;)ww zL)twNa%YR#EN{fM&#G}9n^RDEh_z>ru9vy8NK&~I=VFNSOpMz3QOK!gIW97vuAM)3 zCRimys^;0Fw|wiXlOEp00WWn1q@%ZIQL~kcC(kZs`H-#CkvB0b`-a1izj`wr&0*EiU{efZKNhkJ2}v$KHoiAj#`msw* z<>Q5RU@us*7y0wKsOWDYF9VC6GR(3upA5TYL)PiMvvta;A}yKWqAc87qfW?r!{=G^ zR6;D@TLo6GDijUI``Ut6-3wprA8U_YAS+z0CcN54tw7(4QY(!kTs!539JW}eUVdCn zx_6)PP?#<6!Jakv>g@;gRYg<{<7|~U3F1duRoS&858B--RWZ%p*P%nkq9-N^=~?dB zalJ>marHHn@?tvriEQG7#wXPnczKRoX(w-(51U~pMvt@94|MO$wit=2WOdr}w#tMA z@O!y?kX}^=xce z4O`yk4RKJ;x{lv!SznBVZu<=XBn=TP^Xu}3if~p^-Sj<%8dQB<-doK^Gi<9m9(%KR zvAp^?Yg7Zwt1!a;v>AsK+2VHZlk8Ju?;U$peO-3-TfAEgUzHG&?ZIpLb-f3B#XLL3 zi4SPHnq^&Hu|?YbJ0x4HiUWE?6D>kp4CP&Uef_GEG_S8^k={v%PT8!U+N$kfXO@AX zcr2Dl(>fJ*JkjYwcd~kMUiQppm}Pxg)iVU~G~BUBdoPkiP143!ubfeoE#E$UTd&-k z21tenyDSgK5sPvD-`UjA)Dvtp{fo|3kvv)@Wc*l}#g>J~(e2as1?3QoLASk)t$!=m zlb|<@apq)>L9z^#<`r^;-MrZPMQc%DJ^9b`>OG^2ZobGW=^Og5hQua)RT@WR#c~-< z<=^pamECe<<7{3pWOo_mX0r-!^QqCU$yXJ>_d0tA6^_!p)jPS2vFNlowi=`tb@nN? zAl$w3Wv5kL$Fuc`ttV6QYfMXrX@W7rMZXu?zI8;a^qm8A(k|ZFHd*Y`IdYySaqs)L zYFT9QHHIo4^G;*rsYWP+U?aQ{8mryRU!@ppcFw@(8}CG+N_w%lEKT-s9_kmVS+RY4 zl08^F%R^kU8NV!Tyq+^&$?8Vguo|?QiH}I%2|_;FyS8lHo0%9C=D$DV{Ml!J-wQgE zoE|*xCpPGJ9ob^c%$m3|REMy1W`TSF$*x$!D4px>@?Fs$dUldv&dwO(`aD){9)9J8 zcoDMLm>shHy1{%rj}+zcP&SLZcvl5y9kN-;Ga;v3vf8`6lx|^qo`|{avG3u+KD?;L z>D1oE6j|9|=fyEIR4qRZZ!yf<$84F0j`Wp@ofuh<6thFIWKEA>Dr#t zbvUk;i3{vl2Cdpz+?h)kze`8U^{sh?jmq}b%SOpylzrL#?Ol4$g)B1RR>&fEe&K6x zY^FQO(v0`2Nv3C(DAsY;+xt3p$S!u3oBB)8b-eC|9`fzYv1<|78BY~W(pghZZ$!3H{w&n_jnQX+;=12ybv4P>KR3IW zbbSoAGY>0=D))6v?AW#ETrILfC_C|!Xx)CD4#48FWLPZ2Wx>44vPl=Bm-&)|x6KYE z!@p7Y>tXYMZ^5j#zpg+|w(vaUlMPbgwi98QCXHl-*PTKsOcldWn!oEf8l64L&zI+t zv|53DsL-Kz|7fk0*N?iV17NLr=#CX#p?Ojvvh}gV%e9$#^1G;l1M6 z{8xVrX{=HnSQXAs{iV!t(>fU%*mdUwVv$ddrmoBEOYM5q|lOo_FG&kJovrzIIlOnPSp3 zjirmDY}q>j&$6313JYR}ZddaU*;S0goc!{K?^353joY>O4GWX5+AMoOs=NC2{mr$y z;X1{uEAVnr1_R^$Vm_VQv8=oZp{cu*-*vo9ztwI%PuE@1E2%|;EA{`4&Mwvbd{gaX zW_yK|7^m|y_wZHBwsX;*SXNox%6FF&V*IM?-c)&trmTXiN$|{5bwae+8HwvSkYwyf z8>68(d7pWB_Nc3EZYOGAu7E|1_U+~P$m;C2-*!5NGvgvGZZ*4aJz<0YA*Lwi^DGrN zo12|k>Y4kpAg-tuqGxr^Vk>5~8r1KMtTUGNOYm!b60r%1I(-m9v2AEvZ5sx7qLt&r zYGKk>O_mj&%vbN#DdLl}jI?_g!W~$leb^g9XJL0piy_?UY@l-jKFbf?DZ8+~C)znX zuUDf%wa029v&&2KbXnZWa96f1+VdFit@>witeW;QY}H7ZsRyTDvmlR@)o9hyRRkdq z_k{*CLjW6-rW!9x*44*JEXUU@rt6(=*QI;jSe;j_=zm2Cjj9sE$KsI;onm>idN7m~ zX(t)(qDNLW-dj53R`1-2Y#XP4_@pzNkz~lB_c}4-P}v%FZIdt$^wvK{i|OeUgJ-{K z4*|=-spOn@pzTlXY?Jxe*iR`=;cZa~Z3-Yp;Q5>+Du^ipU zQskLGjbB$utE7O#EES&0RHSg8JKw9Ll5N&&_jxlc)5AVRL$<4Osv;5Db}qs~U1K_@ zp+SCo`@x5FvXdUGZ|alEF6LD;_lCvV8_6z4Iu+yT`~{E3v#hJm=8Us=>{lf3JTM+% z)fk@L=ePX94qPPK6Kcxw|_j_po<_qU+9T)I0>v^Iq91r3ZcEJr{ z6}oql)_$GHHK%ntA?m$ER$Q&=9EWaDZ_RaEpS<2L$=%kMw^`}3F6Ka2-^p_?tLF2bs4AQ>jHJN>}fn z=VKh2TX$VmJ7aF^mYw@b!n3j#o0(PTm?ShKJAAQ#Y?;Fhy@nvr=FoxZam10<<^|baqt|5;%Y)KQ=-R~W% z(VaiAG!Mu6%Vt@PrLw1pB^^!K_U-*TT&-i{Kl0UmhL&RoNJ@%$pxTHPk1b`%I3s4; zsg=?B*BbdKdyzBgV_5sfC3M`{<<~Ho1?kBixWY^bNxrHE5m9YbHx8ek=oE`IWiGq! zEybQFKk-;N_KvJNRUWg;F%o&}n$&j?Rz*tI@xD1%la#rvQN$F<`Mb4ww0smw>zJyq zcDiIv{0lSCP=u^q7;^6IwS@MGChKqP~=V2s{1zoN8rc*v{bQXV|dtY)?HMAB~ zxr(E@UdAyVZd*6`>=s6h^6&Ta@atdOQ}4Da3}GP_=&vWS^r4J2*wfhRGF;r7h5FgN zw6SdbW~aQ~S#HR*Yjt0B!m6z@!Oq~~ zxHzi(J;n>oyn!KFdHa*A^CY`m_rfxys(Ps#EeC|qYO2 zHPYa%Y}rq8SrNPO(`)tLJY%J#$xHQLu~Al0DOh*;vaT8yu=?SWHfHVL{20^Fk*?*? zWDTuj6j(8fe`D^RF#bHro_~#p#X2~WXHDZYgU^s8uIv``ROi&Ybruua;^cC=`Qm`q z*xqjC%=+t{Rb|QE9QAyAuu0Yz)kWaGfp|T?F51eJ$s1OxEARZQyhp?I?L>XZ#RcEb zRkZy4Uuv!*^iBZt^wj{J%F!gmE$elYRpj#0PFpZkUL5O$3gcrVsN|E*Ejr8M6By1{ zEC=6EUd`2f^;7)Tmo&{c)2SouJUgy>|0=Q7FHcTpLDFyU?wJYMp5w>9ASkkailtw( z0=xhCyLoX}_6g4+r1yTs-PO^o(;IHGdM<8A-}rx3!qwR!Q{+Ka`LVj4cgjMsTW6R% zHGf(vJC!l2>dHqXDyECGs*rRlJClbek3V~+`hhPXyc%o$m>KnfX&g5c)p?svXNhrx zeX5u3%8#&XwBBZ;to9u3dX;{DKU9~YJ!?&o&Sv;^=K|GbRU7849%Ap_4OnwL>$-^R ze*3ysm%jbrr7{xz)l9`B<}UZTTFxR#OxhYTBdN_PPP1eFb3N|0PN#HrEb-aiva!3> z)vIIJtX}r5)(w5dj7T!yu8o&z)T~c`&@?>e=RAYqJ9%24Le@q-AHQ_w!W&|i@2+>4 zg?5ptx?{Z1md~o{ z=d&jcpC}B`;;OfH);bP`gW@CpXtw%V%~-K)zv(@YQ+f81wBT zhU)x6GcNA?6T8S>ykfPY&U$i?J0TvTvt@7IaidvP5w5B_8ms2ZN=C9AJ*_#*R?TOL z_1L`UzKZ4K(t|X++9Dg8YxU;RmIa$v_7z8cQ)*s3mzCMHTJtgto!dR^ki*^H_pEl| zFKf!^^KzL&7u;{q$&{pr8@ndw&Wg#hlMh$bx*>V{r@g&;yA%S~d1O;I@l?5xci6ys z$!46_LdE)<^3O6!_Um-KJ+o9uC4JHvRqxC@SM%^`2&*bu&$=`BdD03b_W#SGu7}Il zy;`gL;jrgt%l#&#QOIjuY{%|K`|^t2!f4)bCM63QUu}C_;JK&9z2Aw|$K__LE#BT& zsd}mU?#zf^!$9wrvbK(uCtAPgEP7|>I;{GzBu~cmbfFfOc0w?DV1zxlzSt;o>^85v zzB_SPu}%&?nBB_hbQ3-6&}f~fj+wHxC=k8zLwl0I_~de(UD>@_!VWT%sHtv}lUdci zakJI%5S>Y|EZcZ{n#~X7&y!UY`KIVHPM@EWPe1QAoE4F~tj4o(uGlaBiX+#Z2(7y= zOUX3!!(LT4`JUdPb#Z;?zvPccR*mH2&V%dm@|XW*iFVoV+OYplzR1^!}{loj{$t^L9zAFF<-6=CRz-Ii<+?X|!9%AK_-bh0nJ+(; zVcFjbMyjRkaQTG)|L`M@>RNXeHBXDBIKv)B)%o^L(vZcv>!8>PlER3+-ri^9kj_C_ zm&JLsDz|v=JY{d5pkLg`7j|1!6IbLRdXcW@@yngrbjR+UElscD%xH+Mk29*5gD$(q zqpNbDj^?2>Ze6|8-t@V%to%|mr=^vx+sRJ0AVb>LMJ(=%mF!PL*5B!X-AE;`vi~w; zTG6HGsFv#FFkP(D8BqQ#3mLuI(w^m>Sw0`aP?ER0v!WOqChcjR7@Kaf@$>w%TBADD z+HuutD=Y9uHce7v*)D$c)J|iQvwV^jLPAmQjEdYL#!umg?bb_m+ErJVw>l*br^VJ* zi|y!@wq+c$+hsETCUS*LktOq_KE(RQm(!~2d8E#}yq`2h@V&M2GmOPt)eiLNj7ZEb zBJ&lT+rJZIYlgRCF8r(tH#E-@AN%>JG1R^z7c-cN-BYm52e zy_kX(NLaRw4QN>A?aZ^DS5AmS?OiXkS zV@OAlV@CNm)YOlr-Rd55@i*H*lZt_D%nJD;ibajmx~{`>@!=3v#|VSg5F=!Ah9c(p z4IlW10e(Aa2+7q4Y+7X;WaO3!7Bj@VG zA{YWZll5CIn?t-iWZ5n(SB=`7PL!J=_oPv%kCQ`ju^$%VVbM;Sva1z#>S(Mi;C{Qg z?_Uc3ub=+l(_el1FQ0zz9U1=f7oYz5)8Bsjw@?4@e*N=zAN}p8e|`VYU%t=#yHEf4 z{^{sVMOCIK zu2L(@mDlRbRBiBWjU;WH&!#6mSUzvYjhL&raz{Vr`H)sD)SVU!tzM1kdKG2}TkUC$ z&KvE;+P%59Ml7{D$UXe^Q#}1z>(H5(I<@2%TCo%D-C5N_x_F8_uQ9xO!u)EuVy*av zl`Lq~4l%z~ic{m&jPPxzs_B>`)~>dac`h<|p%}Pb#Z~KDC+RoGc-a?nvpz0TRaw8Y z+0f8;+eV<$`k|-pb*zE)c_Yr?le|{ESIg~hkLA5qsphSdvNsfwcUFp*GAnAMdEa#LNEr#u$>vs)ly*8ZYLx z<)1}FHnJMV&EqWDyjAGcp*#O0A*7JdI^Bf@l}EUe)xvd|eYt|4L`VGo)Qq*;FS}cz z`|P=^pTcjil?qnj{W>u}kr>9H%id*!JdxFtjg^X^P*cYjXY9NsmaC#TzIHc{k7LR{ zIL&?`CX|d(J#jVpBDmek31)0OOy*-UvNt~t6|q%T3}1PwDw3pW+V?lH8x-)#kNq8= ze|pc8|NiOkKmF6E|NXw#*Q>-o{fOy*exLJ?@3a2xhZ+*!tmeUiv>;p20!d;s4(%>G z(GIG1x2Wa05QQmkYM0-Cm*sn_+}AHqmK&3U-$>mzG(2zjs_(d~oL!}z=aQpJ zp^Vmed7aKx>kwBjSJgF!3ZM384cJ*FLXNRu2v~LrCq;a5xN19&+L;7>hEh4fUUKJ8 ziMgM>a5)|GcR3U@gndk`&$CyRU>sa0m$Y>NRpOlp_ct+Ii>G2}nT*Uhu$pAqlW)p< zX4iG)vFyi>>lcc{vk`Pv3Chk{r5wXvMZU@?9gSnim@rhGY9tQ4xa!V*A7z(hvWL9(n?O4Z z^UU}R+U*Z=zI|NP?a{{yTrtxo^| literal 0 HcmV?d00001 diff --git a/src/serena/resources/dashboard/serena-logs-dark-mode.png b/src/serena/resources/dashboard/serena-logs-dark-mode.png new file mode 100644 index 0000000000000000000000000000000000000000..1b4f26008110c70d1ec55597dc288e9e6806b0e7 GIT binary patch literal 12994 zcmY+r19T?Q(k>j^_QVt0wr$(y#I|i46We-YYciSG$%Jq0fUR0ckSvZB?U=DI6OEI5D-LZDKV8V`sb@D!9acWsi5-~Uj)=uMN$N$ZkFKh*FmVc zmb8VuJP6Gf4+8=TiUtDy5AwBxfZ~Bb{)Yzvkpad3FRubh^}iS}5ReFK5QzW9=zP(C zEZx8EzZAR>^#52a1p6=gPa*h!d8L1P0pLXcMhojCrR@p=!a(+~fqIq+{`fMbZLOx| zrX?@OW9n$nU~J}SV$R@c@AOX#gwK=bi?ug*GbZ-5w{vji@#H7{4}#~5|0iZ7CH@b@ z&6b~3OJ0ds)X~M9n1g|dfr(TAj+mI3&&AAwM@3BHf7QQ^_(`qY+?;qA89h8a7(7@R z99=9KnYp>S8JSoZSy<@55cIBI4sOPt^bW3M|6}C;wW|Hv5BL* z8$T)OKS%%F{%4$S))xQAlY{I3hV>O7kpcnkmo@yg2 zmZ}`R$%#`!5X;asFT*!PCsXL>TApncTKQVa7)nB*X_P1; zRL3z|h9ToDiiXiPGN_~9`NS7O_t2HP@*F;Ck?{m}f>WdLq+ zU`9$HQ~?+0JIh_xiSAFebYM=>hrpd;;Z@YuE&rjL2r1-TadMpF(&y#(np|4n? z5ECC>o=~LN40A~3CRIpCKEEO(0T{wwNKn~lXIaHBM~v(9odRZNI~e!v;vsTX4X!2E zFNvB;Jd_pBdF0=2XQuC~s%Te)OZ4t)o@5jX{)9Rh_mC>ArI^={S%JhY$+v~_X3bWv z)VdwLr^(O8fh+Qkjc>Y5+z56#ry>nA4ghQ4?OW^~gcFP2|tzm!KG8WMyf2k>0{jy09dD!Fgvop($;qV`H*(vm05=a^CrSv^O?+ikGwdbNLpy-{?z4^%KfV_q%xxFAdh{ z!`ZOX8XsL%C9T&TK<#SguNM)^9*>-0)Cc#Sg$`ASJ2@d*gDx1oauaclIsHqLuRR;v z2JoPxm}u9($oJ!jtP{99%j(oA(g1l3KxCp?Jh}0-8bzQ z{nzV6o&GzEr{p3DXb!nieewd>73H12*Qp;Fc+PKrFTb#Y2#&rt4pWDhC1Irp44+G_;@8&uXe1844aY zG_C-avJ|({Vyht^Ja=YcZeP<{haOZ{mr0w_Do(oC=(C5S?(Q90(sL5?{H&!0i#D?z zTK>`Lx8tn2*8QzC$rh1dzQgRt_NQ%+gM`X&T9y|8(|a3Ty?*jfjaqN3i?!CXzW0Z9 z!q>+uMn0bhyO|uvKChoD=BfSWQ|SPR5=*2A_!WW@OGJgLT+f%gCB<#sMicQ=G{v?9 z>Kl4Bi!86v_EsL4_zA@q1Rg(K;N0owNl8LJYl@(Tl)KV9t!AB^NyClO2O|Tcr8SES zc{4=VHwGDoY_^VOvk&oS@k4gt)`KcW>x8v_-tI?ExM)Ep%s4e&7?S^1!%1sB`bP=~(k>yaOzMPI~M#^CM6iYdaCbJV)v&{oeNM_js7A`b7~PEIaX zK1Ij5%%dVq^&U#P3bpT?>L^K=$R$LH!$<^oXr13T!-$Ba4ye+ia z7k-?exeX;{FwrFrpYsw>Mg4i{WD4I|cQYvTtPUiP?=<>pKl7sO?@KR=i4 zCdAI_R-;IVkq&B4s{?Z4#Cz*if^h$AkWjj;OAEw6&R*5b&bMf#QU7u8i5n{isgYER z=hWZy%g7RU2#}1Nnp%9d0l=)co9g>2?yXp;aK<9RAb;A7-=~!-0J6(8d^mNkPxS-mL5Oq36V9L?*S$yW?*NgoDpCDx}UimcJLjWCb6K;yzmSlvPqEc>& zuysNVjJuDE_H6-0o$B1;eB1NYM({M*8P_;>UsN=Y%PQ3u<;i#y|| z;;OIiMi5Uxrytbk{HoKIRii#Xkixqi36b$EU9l{-nBe-hoH3%Os);Z3qIT3_TZ?x# z3lxU3p(Wv=(%Z2SJh*i|%bvAn%`NOnMo+$3Q@t$JR!WGK6%}bGgu>Mirg>Aen~udd zt3cZ7@0aqNAWN3JN^-CV0}5nR{lt6M7j{tbde2=wMt=*CP>*uwQg>YM=BJ zhp}!{t*2ReaWHCj%1^AzoOFoKj~(cs$#l+7q)Q^MCKW4^WuY($8$0eJ#B}GWFsX`` zN*D*ueAK0*BkD?8MVf9y@lt!&sO$&QcoI=YJ))AL?^d(e zw2#+Lh))iGJ4`e!qqHKE`eTIXW9Q$3id0e>4#ww+m8EQ9&`kImbo7FWqs7yIW)rY@ zN>Sm89e}v>J+B=$q_%0y?$2+Gv$>_T+j(V=sl}ty7gIAHo+aIWSA_3~&Vs70L6WxJ zqbaR(qm*6pwR%r6jRC?$cOZ>UYIQtI<^NGY(in{>fp!m>Fr5gr{NweUSuXj?GOGjS zAY2uWflQdw&5Xvp+edOmM_@3#I0{ro;GMj&Q&{Zhbv$TTA)g-yCWUsO4i3E@40xT| zI3wj2M~5P2`6l5=HnE*2D}!=x`(BVTL=0!R{)tv*!Jx)!A!XGLqBKm%?YGx$C-OS0 zZMWF7l6GS3e{;DzP0$o9NKdD9WF*zRJ(~4Vm!B{YqH45zI3Y^oCwrR+EpgC=F0BD-$Ap%^i>UzOmC~x(@%PcBZ=MtIDHzm+KTKy3@3OKmkHkvVL z`aC>c`U7{^`w81jjNF@TZ11qs^Lj7$PqXD`NcilXos4@T$jCDmv=jg61bjTls{p%e zqDU(O*pAkgIooE=MLrX#Q7i)C)VB`IXql-Z$9fzXSYUXp>T!{#94mwyn;ROobH19k zI=JYITi99`)T-+NL8@L=h-+9zQB@z3o>PZZ9d6q34dkm2rwx;EV+aJ^d4!`FUBXb> zZ9P5V%{b3T!lA*mKjOO=P5UoggW?vf^y1NlT3ycOd)TVR5F65K{Du?rrI^-Mf1lmT zU1FqXcD4k)2BSf2-*>*=%F0UIOe`(m9o zRX&CIu@iCe?EJweJ}6~Z7{h?~dv9Bo?=fWw35uMo04xo1O_tN%N^Eq(i}JCVzLs^- zU-lCX8!?^x50z`D-kyHA7pxUHNjmk z67#A#L*D{wu_p2HcU1mTz(hr&c+Ru8+p$7TAU3o^d~ZyV2*Sgw7s`B%Lb^s8xpi#C ze>iU7rm9UA4Ef+dnzmtMV7L@uC$??F-Js4j zm&uP%4`urE=MOS7j_ht0tEGjhR~tdNt-S~;JMlt`^e7Tf9jWBrQW`tr(51IPLc*(e zCm<(bGrU(BGFIvYs=#3`&=h-eXXlSdCObPUR(~53nk9Y2@OrxFZv|l|u67I>yzIh0 zZDipOB9w!OUTm2=J%=KxwzP;UaUKj*bLt6pa)S=q#_ZZT+!$+4C~Z7@7c&^8SC0y3 zKEb<0^iqlC6$M|7_>Rm9m8?J6$?Ootrtq;celma9)DE<;ges%?eI9Dmf-{bHn?g$W znbK?r7MYeoDnL!I97I&3s0UHEdy-MNsrmdTFG8XZjt8Gyq60j*G~KVWIG+5Uhqj5^ zJdkk;RtZT(LX192NthV=XnS7kJC!uF5mV|fU}MS+!5dLQQ6d1tXD^b2=TV}&@0M54 zjvlogK;JjLpemcRtq9OJM_H&`33GL~CMYAleGLf2J7@T=*Oyh9x_LPXy)11uI=a~S zc`BYY+(Ip4v%R$}Z{suBwKUJOKdQw8T1RQMrKJb`G7UF9&$q`*Oxq`(aCD0FVp=`} zhh``lPe*BX#O2-0TwO8y>7@?Eumy*=Bp2taiTN{@LH(Q$d?yAvT$q{dP-!CD34TTN>N2p?^hL-;uLZQ=1Qywm z+^$Ts6i|@@E&0p;d5YWLfdjP4*O<8SW9kTW&xd8j?`=4W&An#3PV8VxByDhT@DcVC zjesdehecnP_z$IcYy=01MNnj8npR+gl9}(euOg*#Q1lNvo7Fd487j{LUm>G{yeYxKPDI`HdP;c-#XAaH! zrRRBBKiE8pL{sB8ZY-8acvS0j4H{K-Wza!i-n{1?ME}lnYpH=Tn3|j?$PO;&YEstN zxDnN+rgiaf(0FmP#OU}~KIgd5bg~S5-uc|FiSxBLD}*fWM$V`^e>Z_P4CEpKqOAJ0 zc}w%#Ebo}IdcW^Ety%bnEm1HV1?(gJ4)e46PKZ;a-2*UVe^N5R+GLIQ=mNqnv z8a{G)<=nwVB7jFp?$Vu-yGq77xdI^J4-Pre!*9RNB8seutG1X83=B-NNiB1QV5ef} zS9e^K8UsW03R$sfHBL%OYL@4v<1!eo4E_~&S}0*x#DJPd+BPJesFT&8RKZ`f4uG_E zPBu7~=)p#fs=T}`ggfa&+H!|O3Ve$(_f1C$2w3vZhQQ!!0d?6RAU8(A@TE`;vN2I5+P^slkAc0dnrDOpWVkI2*8`i0ZMZ!oH{BBR9m z?{uAy5|I}Rn9&S+fxTlcS3kXvp_8M3S8Oso;RS#IJ;rJ0i6 z$C3lR33h%3i}`)0kHpK?PvpSEWX_yW+wie$NW#(tShw;k&H4`N_s-)elm9|^hnFJE5 zLoLZ_Q!<+8+YBOtJVMHg2Ph}wQk}2CKx2|H@P&UO@LMT}u`%v8N?3;^hA86ZC)Hvj zB^Ig@#p2KA079B9W0H9gvJdWSs`OZ**49>)7=nZ+pD}uv$mx4}?moxsB)VlwR7EN~ z%qI66(okvbVY(br%8yFOXrFeNc2~&)N?ohFzhWhF>x6mlI$=kz=8kni$ed+Ef>+rf zo6eq<_iQ+9H?FR`j&uIeSUnc3#g7X3Boj+ZAEG5cN8^T%;irm-es5Nv;SGY2`v_oTy>xAsdJ-rbKQzv zs5fY< zT5ZTMb{&wq@~S}@@zhlqs)^a=p}U6Nl2%5j|Ln4g11DyehJHC;dQu}_w966UK*q)K z@Lkx|HK>R@$X_Wis)a4Z;$Ch>j$kXOHRi;7NwcpMHZqIApLEO67|IG%0rC6e$em zmoqFUQsY7@Vd3)2U~0u(J>|(g;Hajk%iLR zM>9r1T|`$Uf*sVr#@Y$OV{}dyq@qD>Bas%8gKZ{6D<>Vvd{(6iZskeA8oM4J-!C_Z zrk3V8UF4xiS+v!Tb{zKI;IJvCyO1n>4zK&fht`W$s@hs=L(sy1_@{k1Nb zU7&G!rjjlI<(n9;VUR8>C8jp-$Zeoe1mumCZ@S_=m!zhr*$;XAx--t;9*p6^k zS7-z?!y(EdhF=>4@s)69_(l}nWP0!f9!{4KHZn3|*zoj?zNPI_oKvYa({k6zD5fzk z(wBE{|HmSGadbePiI{?9s}PbEySxnL2foMM@$ubchnxR-)_ueX85LVa*L@c#(+?+P zyU=p4wRRtcfmy>v;cdIk_(lALA-aw;i4J?YT$8dG4PPikc1rvZ6~z_w+SsUGo((9W}Fll84j@noDN%|abwXf=frkW2)*eM)ns=8am<3`uSGkVM7aUmo?J zghU;$7GIyRcvF`-q(dnrrW4&cJ1hPDaJU`F%G>1nJy@XB!AEbSU3jbhQZJUzBR8E5 zUWXxxtGsYn^-mB!Bq1cCRo_$Qo5_sFqy}izyO<;qt$xquotm1klWPm~vX$u;n;ErH zXv6Om{uNOGv@sq+jc#|06iF8&q=eNp%sg2@e4dcN1x^H})+Ap0H1@=cs{`IW|4?z` zZSk`OKy`HGq%XhR{{_9&$v(=`8!V%3FdE5V^8RRU@Sd^Ya(}}xC{%^J|K&L&q)w5l z-vfisVIPt6O@~?wUp~{*=Q4``c!5H`EE@fATL&)En6e`33qrJ|vjLnxUqGB6TFYh^ zGv&T?*+q6zaR3!9lOXRLjl;wN@-{pFq6b2 z3Xd&*i(=Uh54hvl*BxE@l}N7aC|NL_IrF|FIS_HPn6qSWX69x!+q1)+Mk| zRxvM>VI6jfht9Q}7q2y(gc;E^V$L7WBH;wdW6yUM%cHM@CE$b%pA@HtUw7?1i*MO> zpL$NuxV2`-g8-aFGD&nY`D)BAri&y&L5uNcJvqufAF3mkuM2PsVIkf>hK|R7%rnd7 zPBxN~5F`ol#;uL!O?Ohwj^qNtmb6WwMJ0}?gE~@jpgW!*aC+JWI}#re#kN8BGZU;W z84aC;uMT$UL*)h|LEsb$0U{*EhZPMqD@+EhVBe8OVs^HqN=`T>MW$-(w86q7VgC%HZD~xS z5$W7zh-q^>ZkB^)NQpoSoC}b$MZcM@l+vxYbH|pkkuq4xizWZO20a^xij>dch+`k5 z;eb?a6q%+(fEQ}{Q7)u0PO93!SENRlc2g@POodUMF?+Vjp}H@uPv+{H^Os5 zGz!|=RjF~bF={xr;XFU@svMTz=dTpDUiq}NQXQ*BUCF9Ldoj;EPTxt&xeMRQ6x5hl zzoue(xO?FIGK_3L%6@PwFIHIy3(Y*52Ab-M zIls<46|AMBMypLt4jAc)(JW1e$JKt}DsT*Z=@YlKeDQ~- zgYkLz7S`9eFvJ(LO;;n9J=@G)q^=V2=%WHED+kk?BbxW1H9lsu8p4MQgSprSSY z^mN&M^<&KsE}O)3{E{YhcCJIGQrtxvsa3wX!_t{8`?48vxD+!9)ePL9)4qC$b1L;Y zy_vjhOeW1$`|mH5?0yUts)a78gv2b>qxuJ`v{uBOi~X_}_VhK{v<}PpJuB6?78`%U z%erk3V(@VN9O40l((D5C(QY|Aj+T>+NhNh6RYGd3 zt9e9@Ty0j9H7nIRa|3mBM5J&dWAQy}H(#3_06tfkr(K|ea#D|hRDN2DLmrxMt3_aa zF17vk3{ge)dCwt>PR!uFmAKpArtgQN4}I<+1Qd44N~Pbst8ea}J-Q2@iaobvFlV=d z(`?&Hq9z8C4V9oHVUp`%*JT$V6mz2OoqyfSV{T}(h%n1z#X*c~HDU_N0O54D*F*+! z49y5j&w8v+q`&cxPfr)_OC8Oc#4uyRG2gZsDImLA@v0v&r@b@~^fOd-v60-ofA}D$ z=-G984-S`+^43nXRXCL3=%_Q^o0*?@l=R0_rKz(I)Q4e)@ejR;sj?rY@a7vL0qT_h ze1wWNoOXGywtaShb|3~=*VYmYee|;L$_$_ohrR+DIL6Qoy{|nr0 zdZeSp3N>u zxn%Ap&8p$~HxvX~YHVQtn){aNM6EM|B7)*k#-Q9~IdvllnI_F-5!O{h#f93%Zr=wp zqDOjSGAQ}TJ_{%fZr&`V;`hP{9VwET#uFQR7OTv?@&|GKcJ%a!eMC)Bli)$@_OL&D z0^42;YEY6au3sxBay78CC7|vBlZcR2L5h z708^0_J9%lPb#G7{t$@FWaVGUyK~{o1byn@_a@TsrfS+7?ss&ddzxBnQ_^V&#HfNE z>#@;~tXnu6TbIpw#|Y5da+rR$(L5$duv+PX)yciLfC&^kK=H36hjP5t-V{>y7oh?y z#AGsW8pl=@u_o5r@`E5W1V_D}F|hEV$Ukz!ySM+qx^8bm-p&WKNp__e1}_JTA?jdq z5~1`fOJGz&zxF|v5>N#w!ven+w#6I6j`k9-rwnHeOk5^tYlABlNa(0x8L={izSgS} zF@EYXfI0{V&(_3!^NT}J1-~sv0)%AY6#~lL;yCoxjm}zOSWK!XEc^IsgvIx#+D(ds z=bVPWmTk#FEuM&R@QBI_KOuT|R0Q}jc;44C@m?gfx5{jQ_v!*{eotoUg=W3i_@+c{yhQ!4dPNVFfyi-BjZDTK@+A)i`7K_8ocO)k_fpD9#a?)Q)fHVflpp_YNekB1z9pSpH4) zXQ6dx@xkF@_)pc{qF=8@k5$F#nxuefhB}(PK5Y3iHf&u| z@b1&(R~0CUl3oC?lsYlPM+FfC}wm_7J^UVGv}gKq70lICqW@t|G9N^ zOwc*(!3dYIL4CGkWo45_J{03A1l#m^1iT8CY1c8$Roc>!ZPfG5QKij@^t))6M<>A` zbABc-Nave{0`VS^=@RcDWoz4CPj4m1Q)#hJn-+IqSFMOMz_&Frg&}OVFhHV68&FkU z8}^t{FZ3W(OADw>L-%6ENHB|4;Y=ySYr>357+A-C5(mv;y^u*j*p!_@PD+X%&E zy#XVI!-q1u*XklmlnIG%7yx0Q%Yq%;Jv`8ah?|(S&O=lgFaGmk@{=A|fIqAH=hwMg z9G>*=ng_a7A38u&tR}e zJg7+b&|en)X&3rEtNH!$MhoH(++$Sm$lpu2xko4p%|Cy?R(cxTwcYbnWT2@a{SetE zOgN<6`Dyu?u#M73a+zrLy=7at9A;}oH`j%t#hxaVhF^a+su}!O9 zXLqz^qu8$^`k9cZrOc)i@2!CO+p&JLvbNJl7~;2`043dYuPMz{{GynEm-(($2BQWxLj*^R$S{mR{WOn$9a57FrvtjDuogXc3vEMSTKOrq&r|8GbSQ z?QJ^ew>5&vToR-u>wC1q=`ONP5bYIYVvXv{&v?J7)iB{D2CXIz=Tx4UqJj<%4nb{K zW-`q4xAERxuEU#nyQNTN5(yTmCFVfuur+k-V@7l@v5?dSC_Z^zp=Nsfk*e*rS8fj` zbZhPlOf<~q?IdMQ01qEZ5)-&w%P9bj`xQl+zhQs?1A0mpj1|K1wjg127=u*RAAlQr zlBP|<3e-JJ^kqZhql4fv0ydH3;Lm#b7fQeQMb(m|VwkBccvr9rs`H0f`^l(yQy9_S zciC4DXDIvd(GpRt+u2!$ztH&vZpb9OoErLd5nV__4Qk+KR!e(MEFfvKq7}bqfMH0j z(CH5e6stcMv{hKp&goBbva`Pm2#>+c^o6GEd3g|NT(rb8jo3H(uBh}l`9KBUY-nQ% zlR&O&{!sd!Y!dcEu$nK(aJeyom&?n9OFH61PHFf-3v_Vxp3UP%4&`6(mAUL1Sy(tD z(&_YKs{~+5(p7KllLWt5&)MCk`E4{1pq3X`bdDH~{mrIP_6j8dv58_uG9Q1d+hGd| z#xjmVx2kS@q-6#D3#PU4XT%=NhAv*f9h4IJQeieb?eAkC?S1l^MFkAO@lJfm(fY!G zOYV_LrQ1+J&pe>B*R7G$fz_&bK=u=XS7hzBPdtewf#Ap%d&m-vQ zL7-C-4_6{}`8oY`XXaG{FeddosPDOfOHEf7F_wlrz`(5uCWCg5C(t`L_^9*XC_zm3AzX$d*HyNVduLsv#(j!zKR~xwV)0RZwM?u4{ zGc65f-=-h5{&Gst6$%9r^8IE*VaYCqzAmyiizGa&(BeDOYvB@b;vtrt(+y4@9K zB!%}~a9`_%a+mZ6y>uZEX|46o2UML$6clDBV78&UOFpdOr-Cp*V= z^^P`JKx@hHjBX7kxsE-;Z+}M2gS|H4k45iS;WTb_Zqnsz-aLIGzm0A&{w+9P5@wl*)VtCqvr9jvX78=oanSl z7BY6I%va|RCH>O`w+2%~Raa$;aEWsIp=N`c{bk-#A{5Xz>H>x#eLA`OBOKicX2&+> znVj(yX{LX3{r)^f$bWT;#Flr8gx($$K2F70BH39^6@J$jzD38CYv_?MbecCrM>R|! zgXOu__!-m{Doj15h$6tvw&^I0g<$K9rxl|yh8v#z^Z2(0MM*$S?MceBq>tUpz?5r! z0wTF6RL+={@42*+vAUdeE0>^E}3 z1hgR|C-BxKB44@a3AapQH}1*1W4~t_^RLw)Vw(EdDOl;i*f!EN_=`ISHWet}7ybk~+} zUKAw~@ve+4PB6u+eBY5ygl_kDQtx0_2j+~yuVlhtWIt22aRg(G74HZer@?Q9w_yP2 z`P@(NM?no-7s1`(N>SHvZBZQX%?b%P?_L zFn7IdhDpzQDkU4FSC=pv`^QYS{RqI)lNdRRKQl(&&c4d%j>8cX~I)m%!X3+M;uc7wY<+!$1_~Ekmr6J)cZHnAJu8jy_d$p|5CC(#8UP^ z^oU>Tkmn9@rAprqr3L2#j-k6T;pWR>Wr?|JiDy{--y2<)gkPO*)6kcp0d}u8E zjCbCVxs%N*6BorkPKn1(RnTwST)piP@p>!NmB{af3qu@Or#bj&=;`JbDpyA}u3b=9 z#eRs-XlqHO#wPrcj|>kciVZwpPv;vt4km?9qn!DD&y9l@2`G+wpPLsrL1gTis%|3}k~;#^@=x5|7P{fMA%u8u zwKX<5O$5(9l_1`5H%WJ{>hC!fOy{miIc^}I zZZNk6`;y{1kUBG0%0j(RJKo`aGIf=^=ZT94Imi$lkbZ*WzH)ExT%OZ9T)*k z&M<~i;i!2ACCEaq5}=pTzSVGxnh7!_<{X_NiikRqj3?TYgWlOF2@*=(Zt^p

h02 zAvg6B6;x)l$RhlBwGRmgyTQJj#_x-b;`~GJ2exv2ncBJDTx;TvAB4=T^BfaT+$Du4 zb($WbE4$^US+5vNxATUrXUt8<*0*NfG9Aa5CzT^UPbq*_Hte_KI}yp z8QG-9@!0|I@*+HLoo_J!;I3lDyBY6k)d$U;N8z>*Y=o;+AwT)N4h32McuyBpuONk| zr0Dpe2LeOx4|_Ms8W=?R{yb4zlZ*8nMU7gm3{*N^w~5U0(Q&mqTsi5q@34 z^dgMK%b4ryJX>!`C^a!CFHQ*v@4Z%6BAuS00eJbVJuv16C7u=$gTeJUTcs#Gcpy1j z_HJv$Y#P+gOrL{W;d1nP)L}x=cl6$W74sEIATxuP5(Q!T-l*Mtbvb_l;QaT5&aYT? z_J6{re!1i9mjaQF69)=(z?O9vnUb+6Sr^*XjO_+XM(X`etSv$4b9r+`!2+aV-2fE^ zvH9UnY_rYcUUY-=6s@9^(O4ry@(u{sf>;QtQ@(zDM1 literal 0 HcmV?d00001 diff --git a/src/serena/resources/dashboard/serena-logs.png b/src/serena/resources/dashboard/serena-logs.png new file mode 100644 index 0000000000000000000000000000000000000000..8f110b0578d04cab0b3a00c704d9dcdd1aa46258 GIT binary patch literal 13719 zcmV;IHE7C-P)f6Xi@@54ZTQ_E-Enz5K6$1 z03tR-RB%L5k){YTDBysjLy@r}iiH7DvFijGMAUI`6dRUFWUU$Bym{}eS9UO(Z2>7`&z9wUXbV-Il z#&6`Y8GKGQ04S2&F6MJnWNa;Ck|;8QE#r9r;7G||@X{|>%+C|c55>;RS}qbKr-&IQ zTvLXPlM{>K&(BTgi^a?^4mXV>;xX8n8Ce|RasXz}{8imI52H3ZN4bf ze_i~WlJ|C&UW9+{8AKoW!}eExnGFE2re(F+`iE_46#!l90Z_aBhs|Iw0E)7{bq;-T z9=d#9QpDmcXDh4R++0fmpKB>E=%LdZt9g z$j;($`3&Zthxi`{{&gM}5&R^+h%b~yM9Zd3AWW9ETgVfL1(`yIK=_}U_z%PWq}jQa ziQ4!P(3V&Nr6C$XejWfQDiI(Fdt@un?|lo#M+5oIi_w{wo%_#%{(V=tO#a9gB!7-$ zM?^BX5>d|Vn*3S!?g~$*UQipUP zL&zMmg;!4Do9IA%up=Rh?=qPj=x&RGBx1dpI68aT- z2O}^EromdU5o`ssU{5#*j)WJ%$?!5bA1;Eoz?EiTr=n?cd`V|I)p<|3O zju?MT93~aB0<#&j8`F+Cg&D?-VWzQItUA^l>xvDRIYI4MQ`g1<+DyrL=EogS06Xii({|v`U^zjmmKqDIK93(F5q| z^fLNk`gQs{RV`IdRle#b)i%{Ds;|}NsClUI)k@Ub)kf6bsWa4l)YH_rsduU0(?DsM zX@qO!YV6TCtMPOWZH~(v?wpc2hv(eZgf-1HBQ#fN?$aF5oYvCT^3%%Fs?s{6^;Da# z?V+8jy+iwi_M{F~$4y6|vqR^k&SQoO!;_KDsATjprgSxR{dFa}^}2()GkV5)QF?`X z?Rxk03HmJkB>f%wz4}uIItC#I1qQ7Kw+-=zEW;GTU55RJuZ@h2VvIHzbs0S}Rx=JT z&Npr~zH34@aW`3J(qMAU6l2OVO*7qXdf5y%vo}jIt1%lghs_<#1?IcWhb_<+P8LFo z28$a^64R5J!)#@aTGB0pEekEXET35!SjAgyv+B3{Xl-wuZrx~o$A)4PXj5p@WAm%6 znJw40#`fA=@?77!tLJvleQsxN$G6*KchjC~A7a13zSsVPgQJ7Uq0M2^(ZDg$vDWbh zi^d9LZDyT!LOXdmt#&%*^w!zIS?qk+`4<X~g?%562@eae34a)26HyS+zks@6 z$%2*zuOhu7%OdYYnM6sVdZQJi6QY}=U&naIl*dS8tzuWkUW(I*6U24LW8oFzvR(TOpMEs5_rp_~TJ^wNN(wM(bC zZ0;`Z6P^ce2XB(^$}i_nB)KM)Cp}7bP2Qe7nc|*Ok@8f)7E}wKr~0SXrM^xJP1~RL zDLp2=Jp-4Km~m7{5vB?IGPN`FGKaIwvx>8%%bb_(Ts9>N5;bK**^9Ef#WdN^)PTf9 zvR*Qp{o-l7TcBI8wqSIn=gRt3(5j`Y zdRObOE?Pal#&6AmwS={4Ykw%TE-Wv6xh`g1Pmxy9nxe7we(PI{6^cd0H#WFzsN0Cz zDA+i-Y3`<~O&?2mB^OJrODjs>Z{}{k_?699m0x|@lC)*8%%N=0R?Jr6*6Z8cw;d=~ zF3&F?+a9vLa|dHb$&Qyhm+ZVyVOLSNi?B>BD~E ze(8aT1AWbo&CM;EEoH56tE6@EV8X%6-*|u1-NtOIZ>P7H9s-9XhaP{M`0e$>L5F*f zu#U8SXZT%h2eqT56Y5;vIn|ZYCGC#u9zGg)w718lr{jCe@An_mJyvsE<#^c%!il02 zpHAkVoIaIx>gnm^(__6$dheWxJ#(!uyl?Pq(Ao3ne9xWf_v}A;-u3*k3(gmgUSwVD zy5w-FbHIL};|Kd6ItCpEJBJ*Hx-UCj?irppeBz4xmD5+fub#UWaP88_{E^}7QP*$Y zNVp-r$-DXJR{E{yw{vdK+*xxMeYfPE(!GlNn)e%iH2tw%>L5Kn>ODH}V8MesW8ASP zKV|>)e!S=*`C-L`&P4Mg+egPHeJ3wJUif(YN!F8@r^P=j|6Kdbc>FRj6+1Ql zT=e|YubW?}zu5oM?q%P#7hGGnbyimj)3ox9)XQ;jHmAAnLBO$uo1tPJ!;bAIl4r zxGWoNFd0@O88&w6q2^&&i6PX~^dcbxo!lKkDw9K5brZ;q6Ke$+25{kZtV1Rqmm}GB zn_9jNHdqQ)AsIIA@pyOxLzJO-UM}`9Hm*y3WUyydu#bx*$lD2gJsiazPGV;VvCvf& z5BVi;XqMJg)Hc_bR5nNoZq-75X`MLZdYR|1nFaQBO;UYDL}S>6^*MmY7m#cxEcJ{w z*kCLyN-}KB@pyO(!w7wGf_+_J+Q88ASwq6>y9KxfqA=-%mz@mf3j!y5kvjzsngfs4I)%7>;56egI|9cGaBcv%v;)U=fmG;|&94&oz@y zweN=@u^t=St7J-I2>5$C`r#!nF0>m02JE84yt+h(^@lg+^;hKFtSVFQ?b7P#p5a@+bv7f!3G-)q1`0I20I21n?R`n)PT3EJ*3QyFPb%^t1tFO ze(f!*?YaOH=I0jl!UKKbA5)|1KiGA?{@ra^J`K$>el#(LXE991x=%r(Wm&skHvF~G zE|Os*rN;Vi?R*@?LRdPrZ`tArkxn8Z6TrVGk;x$+$5bTN`M5fOzq=ziJJ_|S8ajdH zIxU;r$LWc&5mm2zcN*6Jdf8nj*HE5>V)z4};bA1&c8gcQ1{*k-MKWxd)Ej(=VIlvz z5M{RoBYRe@m=gmye)3UWZG_Vl+b$S>#OEOm=2Bk7V4H6BvuN+(4 zBhcNSe|>jm9;`ob1wv{XXe9;lcT}U-BE8`bK&dm;JaaI68x4` zSUmroG=CyK2U7dLilH|?+hfQxothIHY%mulkqjFlSzYYH(3O8ZGT2MDedXvXH%GCW zU^K7w#`aA3^?aex`Df*o!;&wLz~tBvn2{I;Vv#Y%Ag1BIzJdOS-kMx8F?Ek$No527 z)p9jFq&js4@Q%^Vf^b8`-jwh`pi%vii#TA!VmVIH;G^>&{*yB zw9Am5T?%i^NkpOvOwOY%g!sDn9sl>V%4x6t;&S?08UG#h$FLQj^*$t8v&9Rx;V%iJ zNQRA$G!rz(q(QKkX2p~(o!rNp(Nf!QIgt%Noys+2DWd(U?fH z@@WfB_Ck-JQ^qwf{^Ur_4~MV2@@|nmCq$JBDJIj|9)u5tr`I z$3SWNnNUm&<%j9b8E>-YX`nPwt285hyX$Ra{$#F4$0=I3+g*QNIaEdxw0;gMgX*~f z%gE7{*4UoxLoRbQ)-Opao{-SRUpr!PWj%cMyYVK2Vml!`GkqvTh6h1zQ5kr9bjQFD`2aCh}Tua~Da>kc;>k)jZXYr-IYmIU_a#7w!H9Cx+ zf`?>l)TIqf;lg^XLn^+H6jKsgv)&xJHh|%G$ge+8XGFObGiDcfNuWoFf76!obB!4pVRvK zUMZ-m-F7U;g_q6S`0N)j{x3#7jFB+4;bHtV&{8tpAy6Yq!uOG6Ok``;+f4GZWWclw z@EJB3Sq_nFi5X=q}gmb5caQc(*hu9dRE8d&%1McDN0NLInJ_UR#_t3}nabGhXzZ3|Uw6TX81 z_`)t*qCCuNDVRpE4bU+NrowO{Pg_K@X1&d5_}ZXeEgGL;X&dEi0HuMuId})thpNiZ zOEQcUf`{+Oy z935FpVPG_irj<18Y8vi3!)RAcRa@Q>^C>GXhN;i5g+rH%VPs?w^^^|VSB$Ahd~Ta( zS&RK6jg_`zo^klX>WqFk9pJ>9cT9*+KajaUvNb_OqcP4@T{o>+ZwnAig(cKZ^bw6K zr@hahF2hhy_8JCtk3|;q*a$1NwTLR*tZsl~SB>k%UVL~Ic=-4lj58pbL`NmbN=wTj zv|Cq$@l66gymu#M`MISni>h56?L2p^99uH%#T|YmmU5tq`~$=4c9y!q`06o4Zlei# z2S6gxd~LDIn)T+!MwTF$3QM?;MVb}MVaw1-GQ1IkhdHWq8V_w-Ii}d%Nvv}mM%bO1 z-^!9h7e>Z*gNb7Xw;9jbwPkp8H0!EmNe1+c%BcbE@mzR-o8Kpkh892a{sI28K9BF@ z6O4P)yt3G@yD6GAgQ83a!Fp)@;jm`Ct)YaXJflr0=RHtr5n06s_fbg#rS?=?v#G-G z`g*cAL1_uWV&2Fe)e%7+dI?6@clL&%+bp8H1ww4kF7W!=?;*tB3nnJTgPS*Rcqf-b zZgwv0IdT?i>RTp?_Z+N5f%D~2*6 zd(C`w)N^A0VE=&;e$u0tN>uNMRQxMIX;^Fy41L|tfKuy~rnlG-TBta@|mb3-2~dWR|ex^QWT^oGWU{rJz&Mmf{8o)x%h z0d|UjdUjBLcv_(q$uRP0Jd9SIldQbU5Xg4tZhb&hS?uYW~T4-#N0Q+sl|2)KEsFXCrt+HF-7Zk+iCzqLLjKO{>sCN_atVmiZN|Xro zbMc>&7}Bu)c&^Hjl8mCs#_>TDi!~%d%{MVGuqMHDltq)+3qa2o8kEX``9|i|o_Z(g zxqhZCO(!WHxW=zfoa2Aw^eH!7l>ksbH_-`|JBuX}PJ(9;?0k(fRn zjv#50LS<<)T+1s`lZ*k&nOf=V$p~-GPpsQ{GS87WFE<;1ur>I@oiZwKrRb&a>%QF> zi-tQ5?hCDtWVZ|YM7R_q(^KQuN3u4@%EnrYP`uXKZ63)uuTm-en<)>)6^n$7!Litm z(|LL)pK1Q-`!}Bg7Y|Pum)ISG{C!~Ig#N5$$D^~xLPd2AOdJ#e-foVt@AzeiK{4^Z z!cs_#>IS2e65;t*) zp{(*NOzQ+G%alE~LMDa!CK=?G*RbbT^7T(hOQkaS{d6u2hz!CpqzHDN%7sY-y21YR z>k!l354_!-jea58SUxL8@lD!Qtc%)N&lvo{-0_F2G9tM?lAXC5dNBgm8+S;w*BFNZhF62N}eZ6{G;Cv2m4vzOxDk`gnRo@;1 zcV{t-hzWs=tHtbkG;en&)}7qab!qVEs7OeP2~{RgsM2#8xABFeMV}KBLK?n5e2qUg zyC1`M1`{fwPei$G<0=av$-q6^igF{Mmt<20gcxjnWdHZW@obH2L;HopxBD{KzgK^; z8~(d|9*CQp)V7I}?jOpAqQaZt?c)Jru~?nf-`^J&&l&<##tjCUOa_n402sUIKkEC{ zb&WiwMN8GrW|u;7RXt2e>dZnap#V6%{g?T0stD5@!`Fw_weiND8*9#VspeT|TN&JGa3)`Tkcdd4Zd;ZQ z8>~{Hdkm@;r_qZ|9T-{;ZMbaH)+l!N^@M_(6|LfZzUw&5nJ^UWU0hq;BiP&9!}6sM zK~-fnxO=#%uYa@WxJEp0H{Q5PtNZEK&7Tc5^-a*#&lLiFmH9k7Ey2(u7!8q7;5alg zK>F*M0@eF+2c_0Ecs6WwX(B-zZNT@BAHgK`BkA9DP6 zQ^$m~90yfjZTlS_dw8SxuArB@F>J7)vw_Xu_=xjEO&CZ1`VIk^kUAhFo z)AOc4ZEZa}1|tiPZsEbKNz{M7-rFjVDZ_aZO?#Y5sTChBr^aApzaZ_dnEW;_*5%=< z4UOErK(cAJUd(txVBw-x^K1^2_ZKOcdI zCom8c60E*%Z!d-ur_RHlxkd1|8A{v4lj&z#^(PshY= zo#5q%c?QXhRxf5GVV&(o?tMeOASJGgInf4Gl} zS5aCD|N5x4R(MF?FtB&#En4OggC?9mcsvOjnx)E#WpbR)DA$z<$kD!ii=v~w(~-Tp zcr+r(9C(*#qe4?jmebB^+$or~eBTBo$pZNox^!~UyG05gk)1tFDu~qhpQb(zt3TQV z>vtW4|C@h5bVu<}?4(p^6y)W>Q?Gpk%~Dybv`q=cWpJeob($zC1=`F9FViw*w;ndLu;VUi2CFOmaECd1=J8B4w9XS}PZruWjR0;=9 zoQHS5*R9#MN8)JDk12Tl&yc>sY|zG#ej%-{@%dzq?xeGiv$m0DE0S?a4}C_bIi{C- zTR>Y~{jTTUa9yjQZ7?3KdNL1Zv5TI#8rYeU*Jhk?{d+>MK0S5c#PO25kJ5nG)%lv< zJaS7aK`v=#7hI~7>KI+E{^kgjRyVNYFqNg$+m9vD9G6y1O%=W=Tnq4W)OKmCOADK1 zAd55;io0##3}{I#)Hm&*YZdfg2BwI*y43v|B(oE;B3GLvBd=6z_g^p=XU6a-So-L6 zFh`1SsH$s(#wHm{GJ2NQ1Ow#?YW`&t zx->5r2Yancy2QmK16iaQUD)l%d2L{);;kG;C> zwA%L}jj1LK>;)Ay4Um2@AIfW$6VgBZaRFj_2E(wyaeBQ{snEN359r;e7fYUv`&%0y zELrm{{O{GL)JhUvJz0dVWi2|P&^j6qS4TTHtxLK{rq>jyBadykwNT4IzpI3K+{*9{ z`wSfwCL8koa`pyP)G1B&sJB}5w+Ue9pw#%2z2TxY-?M*{B4hcwT@bkCAgow$KSZI} z$cXfe;{-yGG}goK8JaEyzI)|SaC3ErFMj+T{<-FBcz?xXY+PMEnYpm!IwQ_ujz0X) zLSD43;jO3Eynq1HT7lLx{3n-8|SM{4?YNF)lzV(Wn{mSltnr;dcbzxY0k7#a^E zXE*Q)?FI5??xUdUjab#bLcJV@T$T_K1$a)LtAwOH5oqcz1SYVoytYwWg+IkuyEYT8 zBq**S=bGzHa0y`2k`d0YM=I#|bo>rCKGQYdcgO18k)a})8kKd;wf-KCdP(N*;RKG3 z4$vt;w|V2R_+C0mW)3dq-h}4*I(95ZtEuOXNr0@gm!MyC9}w6(vcXtrt3VjN9*6a# zsJx-=i8tL?W=dotB-V@O)J*Z!Xfe>n5?H<=cj6mB$Exo;UGI(xmAI%zSyiJ1Nv1bJ zpr;c&I51l8ieh`QW%qx+o}CX7krC|QNdx-9s*ks@Bx^5JN=1PQB)d^oXO;&aGk1rf zMx5PdEbGD0+}RXv^NBPa_wIDPI~-c|WQ7&=iasIwO{T~XW4~TudhZJ@ygO#vsY_~- zIk~w*ZDTXYc&n|=MPt3vwY@2NkG#@aa|#_{Mj9e!4mRXzV0dM+)xgc285(2h^1ge= z>)jF2ie&jWYYl1~X>&d|PyHkm3N1^r&1qTi_=DrslhF^1h=xCopMgF-LzNza+nDE~ z^=wkP4CFZ8q`xVR48ysrN?(zt^kjLZHM&=x>PKpORzY*(*PU#zhBl8NB}j^{Jk zy*m_IiHlje<$BjbRW~$)i>tHVd9Q|=YLG}II`5ea(o`NjnFWIf#k1$Jk=F6{ zU~12)JSWA%fpSL2^5F|NDqu)o{Tn}Q+;I>FM|Zb)CEwfhI}9Ee%dQ1;_wj}xA2-&A zh$TQq#m1nnNm`YaQ|`{Yln=@^Wqi<}KMj;t>lRzo$Z6T4Ip;hy<{>fdw(MKRIX9he zpuBfMkL$ZTS?`Vn5x$5Dd z@$(k1B+a!I<`+OSyW?ar6Rmm?ze8PaRw*E0pQ%!N;~pYYv1)9y-` z08aEwGc&E(v+l<3u4y2B8nnZt%o59FjLE}V<*4L7G7eR(orE6_U3XkQD@HpmTr8-7 zIf1&JSk+WjLPL{;HK(!$w(LIviyu*T97E!I0U*g}bv6b5N@yBmk;`uyYPI~`^4!!t z)7%)j0`DL03vP~LJtb;lD4e3HGVy14CVj+ZWy{aIWM9-g{~Y`OWx zRwEeUVs49T1G3%^3-AOl2O+#QH_>Wksvwog8xEZ>;?HZ4_u!7`b;^bg2$p2n#pUAH zo|T{JoOYkQS=Dfp?|ZSNsvdkjT{M=Q@R4yb$Ep>TYwR${!2Iz8VAc9PP*vXy?oP(_ zM&{zgmC~|ishmIl+TDiFMH_5TgRdr}-b_93vHe)CV&T{b^-b!{WtXiNA_Y|~ArZ%PdWY0yv zN5@1+aQMP%9mz9QbX0Ghna~D(VvxhIhyeKhm`Qu&8I9>ln)9A6o}LgM;00f#U4|VQ zxylXN@%nv{fiQP)H{F?-f%+zC)y|W-e1!+~U_V=|02}Uk@JUAEBCR!HTWB7NjNc!= zc5BYiaCX2?hQSj%>)wCjW=SbTb@774ZoXC_8Le3xH*%oXRR$iLG8kU}-yU{>CMh#W z(P6Ax1Y_~)_oo@&jxK~>jNH-11{+wYNk&dOX$Bwv{91J_<(pIfGX{077YP}s{9AS4 z=BC?j`1HkWmSnwyJlXLPjgu@1vfwYcQ2;@~n!Y>|V|zodeaGOnIjvU&SOVoWO=Vx~ zyUZ6ANoGy4N@2GV2cC&?aq$%&$EW#ET*|4>+lqfGHEuk9!p7m_55^bZh%!Sd1By=- zQ2b+T@{M{!=>))x$29bXq(z5i_;K7An|$(PUGdXX<0j+hawMZH$5{c23Q#cH<5JS! z)D{13fwt+!MhwpZx7%iUZ4-R3>wNvP$$gv(D(YCj^}4Y2z!`XJRtzh$#e{l8+Jz#W z@yx}C&&`4lHtvTv{zb+I3~NeNke3fvbBmy$suA*U)j}6X-KJwk)4H!@_Ionxw8mEzKf1xXH`C0aev?Y8$}V zP#=qvtY?ofNXRaPV(iWQI|sq8Uk&5_`iH^Lh_-#^8-tvZ z+T!iUbNQK3_q^O9ncqPiPmK)~DFm#jfxma@iI?NL_g{7DPhD~Ch43D>_em(@;Lpo6 zvA$zLA;fPS^)S8)>Yap&^?R1|?8+yMy@& zCiYy5lv99RoM90DRrr_a4H7ydkDr zAnWq`Qhq6`Ris6i6GkLJ%GxdP#tZXd!q^sPr(;GA23b=hNF@@ubTt=NZ$AoCVuGM= zu)$42+r*Mjk7#QYKf!ln9j(r7gj_06fDx8t8L4rrkrZQSeqW%pZ=H4h!l|RPegJv; z%h6MT5zfW;F$3=hB4H{w&lb4M*d&)>uTw#3C=Ve6b(qH>6b~;c^Nb2+7>C11z%JOe zMF^C3k^AK$$In4YFa+~d3l0k9QgIYT8=A%9@WhvnsI3TMlh5X5 zrp7&kMEVkGgX1&VTDBx?Ix2^jD26_KDmkV=DwFRj{PS!{*IqGKk&J$rMshb2jG&Wb z_=P%+y&3H!IhW2!5|qev?7*i5a;QVf2@1o9ah4cz88kICKz)4!G&D4V1Vy}Nsgym^ z0?*Kp&LFhcG;Xh}tYDpPM+!7sK{2ssmPM6lA0()M9J#!3N_Z17^NG0~rbiT)Bap-N5C? zZpvR$#!pY}*HzG1cMFu3G9fatW}VNZ000X5Nkl{`C=_0&u^2M6 zhk_BV6^Vp(U3~Gldre!fTn}OPz(G&3}Z2POHrCOd!3j-$Zi%vp)K$E z3YiRw3JSs3*PC7ZdFaGh7%?~&_N1MFDWel%)2_qt{qLDDW@sz~bmjtLV9?XnaOo@%wT5PQk= zB@M`L=iP2epTjWYd^a+pqFHv zsnsm}bFWk?7hTT10ogZ7A*{1`BA78?kjMc+0dVL@25ZqjHYpLl*t7@cJunJB_-YqC zHD?U`boe|>TJj#OSv(D1*Ql69xxyQs6;Lw7(Z9YzuxAotJJ7ky50c7D zt|TIfe0A7pDq3{03iUTtC=}f-)&UrY#+i9i8wi*n|QrNhDBNt%J&GHXx>riZaZxf6k77G)G zM#90UwWkXjq1t3aB^MidiilfTO98Yru%hN99c$Sp2swSvQAyMvFX8wfEyg^KE0 z$SEv?zCD6r*pLLcSyZef*o;x|5fbe0lZL>%U+sq8p`GAhRyI8Q&Sr?~d6YHvI+tAn zUvK^eWHLEix_ljW9X<;;N-Npxgys3^5bEm+GZMpKRG%Pr@>&~Dtvw{k%DKg#qo5h+ z+4v6b%CBd8&}et|mL0^ZG+6HVAsYo+ZsJ(+PhqE=`DgH5Dd3QTk)Oc^Q4Xxyo~glc zTM&trG%uE(5?6u^CJAFlV$14Bs(nW3GaWKg;?@8onu{;rIW{?+tZP`$UKy$VA{Y>7 z;%{Kj=E*1)>y?p z6Lu)(<&;#gL~_RQP!oz?$XNnk{`eb?&*bpZhg)Dszi_x*Ujm7}gJJxjK5*>pb=Z6S5-guT7JfN)0Zv}Z zM{!d3#wj_aHLzx97Hfv}VWfD|K%(jMWM}gFSYS%IfAM4D5w$s9|215ncZ1=$>Ar$WV=A#7Ht1dl9?& z9!*YH)-}PlQ@OA`JqM~ABv4*c53f8m6+|Kde7EN~oW4>3ZqD{_=1M+9g!sdV=y3M) zb_0pp$;%D%5knV0Pe_OiWeMCToW@)tu(K1v+|HfYBMy=wKC-KFyp8|Kwu09d_T@2A%hJ$fj8sM?$5hF zE;&5{8#wI*(iMM?aDf!g;dwURyXq)rB9KV5Lc^YQTPcOd`matuOsO+oPI!V5uvSC9 zm`*wwVjE7S#{Z5)BtQE@#Q+}o>)3O$j~GQo2~9R*J|AGgjxrGM!}=6rC$|S{I2uC& zd5k7)WmBDO(QST37J*SD!`CjsA7m!}c>;slRn=RYGrcb6SJtgtG(;#AFgpETcp!o1 z59kZhZeXTyTkUqbgFZ&>=^NO)+%Aa>8_=4L50HZ`+8J=kND1nL``*==&` zZZ+W>Ziad!X??TuIvX%fZG96|)zd~3M(te)j@X-yiV9*4e;;^#zw@c9rP|JM{=o2* zQJLL^5QyZol(mW5{?|#DA7+d&3LE;<7-t3cFDp1J3~DRkNRDuo^#)BeV>bT=kyJfT zL)IUsQeq#%LDV`%p$L^J7I{BH&I-L?PQZTF;7lOfi3L5hr zRLJCOVpnCPS?irx8zxO6VdJ}mM5C|IX5cwQIiFW99Pr$BuWc*F6!!>pH)z*H4S#Z6 z2pb5r*R$Sjj;w&+vI>jmzmw+QC~4&k_!|t5;WOT~^D)K(MZp*F@PaXMXcEgi13|c0{q@;g@hmTa{G?dph@XfXE zR+zl23)iF3K=eZl3su+4svBYWi#z-mjOIKoft62EY)(5$>j}ecVDbs zvo*`NUbj*t2b2@dS+}!ZHvIL_Zdr}yl8|WRjPVT&q=8fqrb&c+^5a7RZ~b@%9v>S~ zJgI-Mz?bKo$I>9raCxP*MO%(!+kO7aCEwEOMqRxq^}_$funOyb%UX4@!3Gs9G9bdn zO1pT>MzXz)$G1RhlZETW)$ro~PWWShFh5tAHZZh&=HRXkUH#nx7^bzSVpD(;pd>4& zym-^0YaTmKH4AE)Z4o-Il{PIgHR zSy*^s052DN7#r86J~G&|D%i(G66Ea!z8;QZ4=1s+gIMU=k}t>`nx!=rwaxWNB1yrm zTF5W06K7m6^ZYflz`m|Ys_iaY8)W(Y0@m+@C7;&@8;pTfRD0N%cO%jEf^tU?(%gmW zibw7ZHXgW2#;2_eP-)(*le;517DchMNg!7!NLy)?#*zne4n#fIZWGhlV1vbAHT^j@ zY?_Grg4@0{4#N~sZaQGp4t_LAU080i7MIlV)LW6Za2w6*nVNwOHdq=~pQPgEp3=Fn z5=lo3yohX*FbszFYaMA)ly;xkK>>@itb-97Y-lHL&rC26oYG+p1tc8#(V(6!kP894 zzg6e7ty3{5H!DziI$L0FVG&T;RE)0w*8+ zY_I=kA-ETaxB>tuM@~{q!#%@RT|p6F72;ElN=r0NPUM`20g39TV+*AYBT>4i=}(b8 zY8_XR4}6h_u|Q1kxPfh5iV>ESkm`GbMufK>-k6m6yUEG{!CBAd6}T_;Ps&|kIZ?*u z2cyp`0@rTX=8>ELwy4Y0Z#X0-=b|b{fl*?oFR{3?Q6MM3>Xa(7KrF_+vQ)Vno{6An zz`@iqm;1JsBEEcuTMW`qKqNRYrvVrT+iiF>pv2f^)wV}1VZ17Db~RtfWor2$2y58B zjg1&e%5idXQDh>Z_u{xdWJ)t|q3uuEv-?JRm7PhXn%2GD6H)QU{u$fFYA2d7ZdWFzBt zLPAJt&c%%MH)Og*Cb3O?E;ak(Zl7#1+*Wkg&~U9>HQE3TYDK>7CwZhkgHhuV#Vz}H zD7b!}L&rWae3#R=8=+O>pH$Cye-P)OmF7q(M~6mzUV}|2Nf6l z>A(Lbm;3r`fvZe5byJSZ<;|=EAIjrM~4ys+s{uHbAd$f-k zLuQfF!F~Mwt1aSwz_6lXq#vqZ3JWJ)8j(zrX>j9JMbKooNo=0a*q!0YqIe92Qu`&t z(Y5!<1@z>u4X06tNKm7)=$%I7>;X)@DjRVY1-`?;@R??=uF%SKKh!Cjfge+X?y56}f2>a9aax*)yl`vV8 zMH1$)@jdUz!MrmL7p;Q{trab=5m+1FU#G=y6IQu6K3WVNep6<-EcSH5Tt9%ymXo__B zKFdvQG@P^3)aHxSpf^EYmSzQA>P2aXHNVzKQbHiygT)4c27fTZLq)#pW>;0tpu|1~X+KHWz zMiOCxl{UiwfuJf15iJE?s@}C%K>P~~PR5(}a3czW#?z&2d`2#aaOvXReth`;7@d+c z7K%+3&t}d_oHOF;x6q)J&@oB0UyutSEY->_k=?+TSwE%3ScxxDmVJ>4)lmj}%^8kP6X>Qp3< z9H!36>uJzv*^>q%X*g8jYi<{p2Ka{YsTjH9nX@^u;yJUmVhvg&Nle7LAuibZkLUeJlzk@Ye6WlS8p&V2ZVp)}qafrzdZ;bl)?XEfvlt?uf1XA${-DmAe<@F4I!}A+?6!E-+7l6Vyw(Z_jKN#5Q$=N?%kP+ zdR?P;Fjq#h$X75PUps>Y4gP~|YqY(b&Q)|e;Z=8TmF@;z{T6$^^#aFMu-JPnDA56&5X+i(7hu}L(w z${6{*))@@8>qKwdx5qu(_+sTX3rau6o%;_d;8R$MfDDv+7d4&NV)DJBdD_mR!+%}yV`CjNvzn`Ca7ZO+aJ4q zLkCA*$^-M%;!%a6Ue2kE&E3KF*ulH~CAdmhNPBw5v2weFN z^cosvgxF?i2-d`x*8@*3Z$brq8nfOm8kiQBsV6TCUW&$AOgO`5m$jw{7%dpo6t#7i zRCEorX{J?Z19j$E#yiyWe>RSq-Q4YbIpUdJ3HFkvy8~r9+qnf5$QI#zB1CHizx8C% zuq*CnQ?lq;8h#K{*XI98j8O}Ag%T$d8}6aO%ejxv^ou08r6t2j(Ya+>z=BWx(Wa~{ zhvC>k!|L;{$V(e%i#SBd>E8!FoMld+NboF>0uK1uE?4!5JNSmCKhe6mA1_)cr|kVM z$vsO<5!+N6=9V5;hi?iO3ASruG*YJ#ZSxt|g?jAyw13G15r`x9%zZn9oTdko`B&yF zWdYQTGYlF$L6eEH{&b!oXLmR9^6v)H(8^>MArDdaN8KjxlE2o;L@h4OnRH$tO4B0^ z77V~%-w2h2spU-950@~*kl_^R$vU?v5qg9dD%?H}It+5RRt<}hZ_*i_xf!u$Vd*}z ztfreo4%Scvf1y0WTU^V)7e-LuFmOYrfK}a(m)DWeu;2vXQFx)?&3`&(%$uwlbi?>$ zW@|zu%vSp{Uvla^{KlOJuQ57^H;6Tbs{|Wk|AFh20YzuMMJZk5Y+Hp&Oc{cfRkanokNvhR#^L8v#4S(74#;mH7w>lYjn?FHVaTrNX?p%pq~!_OL?*btnB{Q1 z2?IuETLIVH15LHZtE3kCk6M+A$o_g$F>3Ae>*fC3-QMpWC1+adGaKK!N|y?Mlg~D# z&THY+Y%R7Ak}n9NWbcaH@f=ZnS=Jg6ye9}HxG*w z{VZwmiSoj0MF?6f6$$1fUr$Lv3@{im&ZV*r=zkTLKl)-rs-Yv+ZAPAViA8(cC43bQ zITOfyCP-Q)%Ey6)^6WVr5;E7s4m7Dw;rS7ZD%enj&vPY((!B`8&4S5sURrm#Wu(~d8Lr;)qxLM<;*s{ zpK;wT1E|gpJwN*BdB4-N`1K``=d<`~jv6fF;;+?vFo`SLPhmYBcWmVyI_rgQ1t4|u zR+B(?JR%JRhMLSTk-g1HR)JX7+K7oU;ejb7=x^&b;-`b3b4rOq8gIgF{x~HhZ+*Ju z+3dm8oB7+JIg7f_Ust^svDiz4Ol4^b#k(B}mqz*3k=USdv%kN^v&b6Q5xr}k4@XT2 zAQy5Vj6)28yg`o)As)kYfk)_nJeP)@*c&NvOC0eKx|;eVQbsFc)VR;(F(38}86B%c z6TsEQcMH=9bg^w@U_PwrH#ro(>)$Hq23TrvgwwhUFJ>q(a&gvgnedwmMo@0L z_L5ahm1>keySPzE zyPuOquZ)co>k#X?8)ij1YijjRrK36anc@~EcYgPZWcU|i9FMYW75K-Ds?=qKAmO@mLjFel1;Z&st{Ppqr0=zbbTG>->CmZEgW@mna z7or=NvS`pJHx9`GzaPAoJ6+2G^MNsHhvnE=C`(g2dfRcIb3#0QZu2$r$L#WAvMS5ftjr_k zDY){q5!3f9qevMqHxVxrCD)QFJ;1b&OMjiK(Hj?9qsqngBC7ZoeILV}Eyre0E@>g+ z*0#(iQ@1rv^@JGK$GNCq@U`wn;uD4}J<^r!AKfuv0ZnV|MzT%~v45J&JJVW5w)3Ne z%dE=u<7-=|e^#>Qw?S9@&7t5^FFDZte#^+NaR`Zr-%QEHM`dI(r>GQ;KWh+qh)n^M zy$D_2GPGQ!t|RjC&wkBYM*(mp^XT7D#_QO3w?sL(h>gQr3ZtWu7)?%&Xu=l`1P>SU zQ9M3V#O{W1ikNtRd1{Mh|HiUE6LK8AG)#7F;;q34Xzia7i{GAp-go_)+vBWtm(x2P z{Sz%fJ3OnXLVj*g%as2^Di9OLCf!oeU#9!zI7dYw7*2U_F>orIESlT`zL*M2x|wb7 zRJ_L_cQk^HXr;GutcJa6>Ru;GyU>%f=piaJFV1RqRW;_#cJg8_yai>dYLYbha)pKo zq3{qAa!Ro6Y9cP7>|_W}cPTiw)@KiuT`W!Bu6KNlJ^2u$Fc6eheN5eYm=o%Cx*l5B z|MK#OOcK|VOy#e8oE}E0ub&o<$NnFl5q<89{+nk?tsR&D3(tTf|KXXfbVC~rDagRd zaedVywWNGOHJ>PER02?L%W7LLjt^;O)n_PhK2f1nQvIt2Dh`_@QKc%N#TDITK3Xmz zxid2pH>;&3iSLh*6wA93J4*X`ocYEhhkY}phO)V~<#&^tTeQ-x!<169m*rzes*uxm z1~&;{#iW#BKh7uci396FY8w6ybsV#6A)DjU7|GHvgyr4?_bFrr%_q4A`M!Mr(VB^Z zWR+!t$D6zxF7hJdH(^GAQcKiCr{x;AC6E8)vWs1O4Ps!8Y1cv~M3SD%dz+&^z;=uX zKpY!mGrh@rkpYH;D!>0ZH$JW$a^VN&u)E(nX{9Jso@hl}Fe*}YF{)57lV@o$} z@!*j-4Q~*jde9Fo0(Xj9CC-Z!;y$wt9^)Dw0Nk-4ip9I{cro<)Ik>mcp2;CdT8PU7 zfopC?O*gP{Ua@f>I|2akf#?I0Fe9nz)wPdLy~A%u)(a4_gQ0E1c>dL=&`R&`5<;cP zi+J`3fF4I2EW3}~M}jif^no??HQ)xGC|@OKMQ= z_EfgK;7|X!6l7=JJk(E{d;Hhr_E4OalQOkaJ46|Ob{*r zP&;)mM(iC4(4OpYcaRIJDzXcL_@5~Ae!G-$m+pA+A*EEiysaUJ7nfo zJ9e;}x(m&n*2)C|WOx z%t9zv00L@yCG+gdWzks@h8Dn8RG+Z#-Rr}|Td%kTs#F-PUjnzNJnAbJIW)3K!;1+1 zydsk7B6VuIu?$J|=zv=oY{LFE(O(_{1&ME;J(z*;WY(|7>^$+2-iyP}U$bc(rUZbe zmpfB$Pn_A>M>fSy9&L+4ME{qR`y&{u5#|)(s@i@Zkk<{;JZ-%k8PX#BRxCa^l=|2X z3iYb+(@COzoi<7fBucNF-!?f13Fxyci${(vey_nDu-MwNmNnsixV;zIfZjSB&}*{( H?_~cMTAWj= literal 0 HcmV?d00001 diff --git a/src/serena/resources/memory_maintenance.md b/src/serena/resources/memory_maintenance.md new file mode 100644 index 0000000..6f84514 --- /dev/null +++ b/src/serena/resources/memory_maintenance.md @@ -0,0 +1,33 @@ +# Memory Maintenance + +## Discovery Model + +- Core principle: progressive discovery through references, building a graph of memories. +- Initially, agents are provided with the list of all memories (names only). +- Agents should read `mem:core` as the top-level entry point (graph root). + This memory should contain references to other memories covering major project domains. + The referenced memories shall, in turn, shall contain references to even more specific memories, and so on. + The depth of the graph shall depend on the project complexity. +- Use topics/folders to group related memories in order to make the content structure explicit. + Folders can mirror project structure (e.g. modules like frontend/backend) or topics like debugging, architecture, etc. +- Memory references must use a mem: prefix inside backticks, e.g. `mem:frontend/core`. + The surrounding text should clearly indicate when to read the memory/which content to expect. + The text should provide more precise guidance than the memory name alone, + i.e. avoid a reference like "frontend debugging: `mem:frontend/debugging` and instead make clear which aspects of frontend debugging are covered. +- Memories themselves should not contain information about when to read them; this is the responsibility of the referring memory. + +## Style + +Dense agent notes, not prose docs. Prefer invariants, terse bullets. +Avoid obvious context, rationale, and examples unless they prevent likely mistakes. +Keep guidance durable and generalizable, not task-local. + +## Add/update threshold + +Add or update memories only with stable, non-obvious project conventions that avoid complex rediscovery in the future. +Do not add: quick-read facts; generic language/framework knowledge; one-off task notes; volatile line-level details; behavior likely to change soon. + +## Maintenance Actions + +- Renaming memories: References are updated automatically if handled via Serena's memory rename tool. +- Checking for stale memories (e.g. after deletion): Call `serena memories check` for a report. \ No newline at end of file diff --git a/src/serena/resources/project.local.template.yml b/src/serena/resources/project.local.template.yml new file mode 100644 index 0000000..36cd3de --- /dev/null +++ b/src/serena/resources/project.local.template.yml @@ -0,0 +1,5 @@ +# This file allows you to locally override settings in project.yml for development purposes. +# +# Use the same keys as in project.yml here. Any setting you specify will override the corresponding +# setting in project.yml, allowing you to customise the configuration for your local development environment +# without affecting the project configuration in project.yml (which is intended to be versioned). diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml new file mode 100644 index 0000000..aa4247d --- /dev/null +++ b/src/serena/resources/project.template.yml @@ -0,0 +1,158 @@ +# the name by which the project can be referenced within Serena/when chatting with the LLM. +project_name: "project_name" + +# list of languages for which language servers are started (LSP backend only); choose from: +# ada al angular ansible bash +# bsl clojure cpp cpp_ccls crystal +# csharp csharp_omnisharp cue dart elixir +# elm erlang fortran fsharp gdscript +# go groovy haskell haxe hlsl +# html java json julia kotlin +# latex lean4 lua luau markdown +# matlab msl nix ocaml pascal +# perl php php_phpactor php_phpantom powershell +# python python_jedi python_pyrefly python_ty r +# rego ruby ruby_solargraph rust scala +# scss solidity svelte swift systemverilog +# terraform toml typescript typescript_vts vue +# yaml zig +# (This list may be outdated; generated with scripts/print_language_list.py; +# For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: ["python"] + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# optional shell command to run before the language backend (LSP or JetBrains) is initialised. +# the command runs in the project root directory and is only executed if the project is trusted +# (see trusted_project_path_patterns in the global configuration). +# serena waits for the command to exit: a non-zero exit code is logged as an error but does not +# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety +# backstop for non-terminating commands; on expiry the process is killed and activation continues. +# example: activation_command: "npx nx run-many -t build" +activation_command: + +# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). +# must be a positive number. +activation_command_timeout: 180 + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# The settings are considered only if the project is trusted (see global configuration to define trusted projects). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings +ls_specific_settings: {} + +# list of workspace folder paths (LSP backend only). +# These folders will be used to build up Serena's symbol index. +# Paths must be within the project root and should thus be relative to the project root. +# Furthermore, the paths should not be filtered by ignore settings. +# Default setting: The entire project root folder (".") is considered. +# In (large) monorepos, this can be used to index only subfolders of the project root, e.g. +# ls_workspace_folders: +# - "./subproject1" +# - "./subproject2" +ls_workspace_folders: ["."] + +# list of additional workspace folder paths for cross-package reference support. +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries, but these folders are not indexed by Serena, +# i.e. the respective symbols will not be found using Serena's symbol search tools. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +ls_additional_workspace_folders: [] + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +fixed_tools: [] + +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. +# This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +default_modes: + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml new file mode 100644 index 0000000..efa572b --- /dev/null +++ b/src/serena/resources/serena_config.template.yml @@ -0,0 +1,202 @@ +# the language backend to use for code understanding and manipulation. +# Possible values are: +# * LSP: Use the language server protocol (LSP), spawning freely available language servers +# via the SolidLSP library that is part of Serena. +# * JetBrains: Use the Serena plugin in your JetBrains IDE. +# (requires the plugin to be installed and the project being worked on to be open +# in your IDE). +language_backend: LSP + +# line ending convention to use when writing source files. +# Possible values: "lf" (Unix), "crlf" (Windows), "native" (platform default). +# Note that Serena's own files (e.g. memories and configuration files) always use native line endings. +# This setting can be overridden on a per-project basis in project.yml files. +line_ending: native + +# whether to open a graphical window with Serena's logs. +# This is mainly supported on Windows and (partly) on Linux; not available on macOS. +# If you prefer a browser-based tool, use the `web_dashboard` option instead. +# Further information: https://oraios.github.io/serena/02-usage/060_dashboard.html +# +# Being able to inspect logs is useful both for troubleshooting and for monitoring the tool calls, +# especially when using the agno playground, since the tool calls are not always shown, +# and the input params are never shown in the agno UI. +# When used as MCP server for Claude Desktop, the logs are primarily for troubleshooting. +# Note: unfortunately, the various entities starting the Serena server or agent do so in +# mysterious ways, often starting multiple instances of the process without shutting down +# previous instances. This can lead to multiple log windows being opened, and only the last +# window being updated. Since we can't control how agno or Claude Desktop start Serena, +# we have to live with this limitation for now. +gui_log_window: False + +# whether to start the Serena Dashboard, which provides detailed information on your Serena session, +# the current configuration and furthermore allows some settings to be conveniently modified on the fly. +# We strongly recommend to always enable this option! +# If you want to prevent the Dashboard window from being opened on launch, +# set `web_dashboard_open_on_launch` to false (see below). +# Further information: https://oraios.github.io/serena/02-usage/060_dashboard.html +web_dashboard: True + +# whether to open the Dashboard window/browser tab when Serena starts (provided that `web_dashboard` is enabled). +# If set to false, you can still open the dashboard manually: +# * When using an interface that supports a tray icon (see setting `web_dashboard_interface`), +# you can conveniently open the dashboard from the system tray. +# * When using the `browser` interface (no tray icon), so you can only open the dashboard by +# a) telling the LLM to "open the dashboard" (provided that the open_dashboard tool is enabled) or by +# b) manually navigating to http://localhost:24282/dashboard/ in your web browser (actual port +# may be higher if you have multiple instances running; try ports 24283, 24284, etc.) +# Further information: https://oraios.github.io/serena/02-usage/060_dashboard.html +web_dashboard_open_on_launch: True + +# defines the interface (application mode) used for the web dashboard (if enabled). +# If empty/null, use platform-dependent default. Otherwise, possible values: +# * browser: the dashboard is opened in the default browser (if `web_dashboard_open_on_launch` is true) +# This is supported on all platforms. +# * app: the dashboard is opened in a separate native-like app window with accompanying tray icon, whose +# lifecycle is tied to the Serena process. +# If `web_dashboard_open_on_launch` is false, the dashboard can be conveniently accessed via the tray icon. +# This is supported on Windows and macOS, but note that on macOS, where tray icons are very visible, +# this may result in too many icons being displayed when using multi-agent setups. +# * tray_manager: use a global tray icon to provide access to the dashboards of all running Serena instances, +# opening the dashboard in browser tabs when selected from the tray menu. +# This is EXPERIMENTAL. It is tested on Windows only. We will establish macOS support, but it is yet untested. +# On Linux, this cannot be universally supported, but it may work in some desktop environments. +# On NixOS, when using the package from flake.nix, both modern AppIndicator trays as well as +# older Xorg-/XEmbed-based trays should be supported. +# See https://oraios.github.io/serena/02-usage/060_dashboard.html +web_dashboard_interface: + +# the address the web dashboard will listen on (bind address). +web_dashboard_listen_address: 127.0.0.1 + +# trusted hosts used to access the web dashboard. +# By default, only allow access via local addresses for security reasons. +# If you want to allow access from remote machines, add the hostname used to access the dashboard to this list. +# If the list is empty/undefined, all hosts are trusted. +web_dashboard_trusted_hosts: + - 127.0.0.1 + - localhost + +# address where JetBrains plugin servers are running (only relevant when using the JetBrains language backend) +jetbrains_plugin_server_address: 127.0.0.1 + +# command used to launch a JetBrains IDE on demand (only relevant when using the JetBrains language backend) +# If this is non-empty and, at project activation, the Serena JetBrains Plugin server instance corresponding +# to the project is not found, use this command to spawn a new instance. +# Specifically, if this is , then ` ` is launched. +# Provide the full path to the executable/script (e.g., "/usr/bin/idea" or "C:/Users/bob/AppData/Local/JetBrains/Toolbox/scripts/idea.cmd"). +jetbrains_launch_command: + +# the minimum log level for the GUI log window and the dashboard (10 = debug, 20 = info, 30 = warning, 40 = error) +log_level: 20 + +# whether to trace the communication between Serena and the language servers. +# This is useful for debugging language server issues. +trace_lsp_communication: False + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} + +# list of paths to ignore across all projects. +# Same syntax as gitignore, so you can use * and **. +# These patterns are merged additively with each project's own ignored_paths. +ignored_paths: [] + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# For example, "global/.*" will mark all global memories as read-only. +# You can extend the list on a per-project basis in the project.yml configuration file. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# This is useful for projects with large numbers of archived memory files. +# You can extend the list on a per-project basis in the project.yml configuration file. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# timeout, in seconds, after which tool executions are terminated +tool_timeout: 240 + +# list of tools to be globally excluded +excluded_tools: [] + +# list of optional tools (which are disabled by default) to be included +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes. +# The full set of modes to be activated is base_modes + default_modes + added_modes, +# where added_modes can be defined by projects/CLI parameters. +# If this is undefined/empty, no base modes are included. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +base_modes: + - interactive + - editing + +# list of mode names that are to be activated by default (but can be overridden by projects/CLI params). +# The full set of modes to be activated is base_modes + default_modes + added_modes, +# where added_modes are defined by projects/CLI parameters. +# If this is undefined/empty, no default modes are defined. +# These modes can be overridden by the project configuration (project.yml) or through the CLI (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +default_modes: + +# Used as default for tools where the apply method has a default maximal answer length. +# Even though the value of the max_answer_chars can be changed when calling the tool, it may make sense to adjust this default +# through the global configuration. +default_max_tool_answer_chars: 150000 + +# the name of the token count estimator to use for tool usage statistics. +# See the `RegisteredTokenCountEstimator` enum for available options. +# +# By default, a very naive character count estimator is used, which simply counts the number of characters. +# You can configure this to TIKTOKEN_GPT4O to use a local tiktoken-based estimator for GPT-4o (will download tiktoken +# data files on first run), or ANTHROPIC_CLAUDE_SONNET_4 which will use the (free of cost) Anthropic API to +# estimate the token count using the Claude Sonnet 4 tokenizer. +token_count_estimator: CHAR_COUNT + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# (currently only used by LSP-based tools). +# If the budget is exceeded, Serena stops issuing further retrieval requests +# and returns partial info results. +# 0 disables the budget (no early stopping). Negative values are invalid. +# This is an advanced setting that can help alleviate problems with LSP servers +# that have a slow implementation of request_hover (clangd is one of those) +# or with tool calls that find very many symbols. +# Can be overridden in project.yml. +symbol_info_budget: 10 + +# template for the location of the per-project .serena data folder (memories, caches, etc.). +# Supports the following placeholders: +# $projectDir - the absolute path to the project root directory +# $projectFolderName - the name of the project directory +# Default: "$projectDir/.serena" (data stored inside the project directory) +# Example for a central location: "/projects-metadata/$projectFolderName/.serena" +project_serena_folder_location: "$projectDir/.serena" + +# list of glob patterns for project root directories that are considered trusted. +# Some project settings will only be applied if the project is trusted. +# A glob pattern can contain the following: +# * matches any sequence of characters except path separators +# ** matches any sequence of characters including path separators +# ? matches any single character except path separators +# [abc] matches any single character in the set (here: a, b, or c) +# Path separators are normalised internally, so on Windows, both / and \ can be used in the patterns. +# Example: +# trusted_project_path_patterns: +# - /home/user/projects/** +# - C:\Users\Anna\Dev\work\** +# The pattern "**" matches any project path, so it can be used to trust all projects. +trusted_project_path_patterns: [] + +# the list of registered project paths (updated automatically). +projects: [] diff --git a/src/serena/symbol.py b/src/serena/symbol.py new file mode 100644 index 0000000..8eb0059 --- /dev/null +++ b/src/serena/symbol.py @@ -0,0 +1,1363 @@ +import copy +import json +import logging +import os +from abc import ABC, abstractmethod +from collections import OrderedDict +from collections.abc import Callable, Iterable, Iterator, Sequence +from dataclasses import asdict, dataclass +from time import perf_counter +from typing import Any, Generic, Literal, NotRequired, Self, TypedDict, TypeVar + +from sensai.util.string import ToStringMixin + +import serena.jetbrains.jetbrains_types as jb +from solidlsp import SolidLanguageServer, ls_types +from solidlsp.ls import LSPFileBuffer +from solidlsp.ls import ReferenceInSymbol as LSPReferenceInSymbol +from solidlsp.ls_types import Position, SymbolKind, UnifiedSymbolInformation +from solidlsp.ls_utils import TextUtils + +from .ls_manager import LanguageServerManager +from .project import Project + +log = logging.getLogger(__name__) +NAME_PATH_SEP = "/" + + +@dataclass +class LanguageServerSymbolLocation: + """ + Represents the (start) location of a symbol identifier, which, within Serena, uniquely identifies the symbol. + """ + + relative_path: str | None + """ + the relative path of the file containing the symbol; if None, the symbol is defined outside of the project's scope + """ + line: int | None + """ + the line number in which the symbol identifier is defined (if the symbol is a function, class, etc.); + may be None for some types of symbols (e.g. SymbolKind.File) + """ + column: int | None + """ + the column number in which the symbol identifier is defined (if the symbol is a function, class, etc.); + may be None for some types of symbols (e.g. SymbolKind.File) + """ + + def __post_init__(self) -> None: + if self.relative_path is not None: + self.relative_path = self.relative_path.replace("/", os.path.sep) + + def to_dict(self, include_relative_path: bool = True) -> dict[str, Any]: + result = asdict(self) + if not include_relative_path: + result.pop("relative_path", None) + return result + + def has_position_in_file(self) -> bool: + return self.relative_path is not None and self.line is not None and self.column is not None + + +@dataclass +class PositionInFile: + """ + Represents a character position within a file + """ + + line: int + """ + the 0-based line number in the file + """ + col: int + """ + the 0-based column + """ + + def to_lsp_position(self) -> Position: + """ + Convert to LSP Position. + """ + return Position(line=self.line, character=self.col) + + +class Symbol(ToStringMixin, ABC): + @abstractmethod + def get_body_start_position(self) -> PositionInFile | None: + pass + + @abstractmethod + def get_body_end_position(self) -> PositionInFile | None: + pass + + @property + @abstractmethod + def body(self) -> str | None: + pass + + @property + @abstractmethod + def name(self) -> str: + pass + + def get_body_start_position_or_raise(self) -> PositionInFile: + """ + Get the start position of the symbol body, raising an error if it is not defined. + """ + pos = self.get_body_start_position() + if pos is None: + raise ValueError(f"Body start position is not defined for {self}") + return pos + + def get_body_end_position_or_raise(self) -> PositionInFile: + """ + Get the end position of the symbol body, raising an error if it is not defined. + """ + pos = self.get_body_end_position() + if pos is None: + raise ValueError(f"Body end position is not defined for {self}") + return pos + + @abstractmethod + def is_neighbouring_definition_separated_by_empty_line(self) -> bool: + """ + :return: whether a symbol definition of this symbol's kind is usually separated from the + previous/next definition by at least one empty line. + """ + + +class NamePathComponent: + def __init__(self, name: str, overload_idx: int | None = None) -> None: + self.name = name + self.overload_idx = overload_idx + + def __repr__(self) -> str: + if self.overload_idx is not None: + return f"{self.name}[{self.overload_idx}]" + else: + return self.name + + +class NamePathMatcher(ToStringMixin): + """ + Matches name paths of symbols against search patterns. + + A name path is a path in the symbol tree *within a source file*. + For example, the method `my_method` defined in class `MyClass` would have the name path `MyClass/my_method`. + If a symbol is overloaded (e.g., in Java), a 0-based index is appended (e.g. "MyClass/my_method[0]") to + uniquely identify it. + + A matching pattern can be: + * a simple name (e.g. "method"), which will match any symbol with that name + * a relative path like "class/method", which will match any symbol with that name path suffix + * an absolute name path "/class/method" (absolute name path), which requires an exact match of the full name path within the source file. + Append an index `[i]` to match a specific overload only, e.g. "MyClass/my_method[1]". + """ + + class PatternComponent(NamePathComponent): + @classmethod + def from_string(cls, component_str: str) -> Self: + overload_idx = None + if component_str.endswith("]") and "[" in component_str: + bracket_idx = component_str.rfind("[") + index_part = component_str[bracket_idx + 1 : -1] + if index_part.isdigit(): + component_str = component_str[:bracket_idx] + overload_idx = int(index_part) + return cls(name=component_str, overload_idx=overload_idx) + + def matches(self, name_path_component: NamePathComponent, substring_matching: bool) -> bool: + if substring_matching: + if self.name not in name_path_component.name: + return False + else: + if self.name != name_path_component.name: + return False + if self.overload_idx is not None and self.overload_idx != name_path_component.overload_idx: + return False + return True + + def __init__(self, name_path_pattern: str, substring_matching: bool) -> None: + """ + :param name_path_pattern: the name path expression to match against + :param substring_matching: whether to use substring matching for the last segment + """ + assert name_path_pattern, "name_path must not be empty" + self._expr = name_path_pattern + self._substring_matching = substring_matching + self._is_absolute_pattern = name_path_pattern.startswith(NAME_PATH_SEP) + self._components = [ + self.PatternComponent.from_string(x) for x in name_path_pattern.lstrip(NAME_PATH_SEP).rstrip(NAME_PATH_SEP).split(NAME_PATH_SEP) + ] + + def _tostring_includes(self) -> list[str]: + return ["_expr"] + + def matches_ls_symbol(self, symbol: "LanguageServerSymbol") -> bool: + return self.matches_reversed_components(symbol.iter_name_path_components_reversed()) + + def matches_reversed_components(self, components_reversed: Iterator[NamePathComponent]) -> bool: + for i, pattern_component in enumerate(reversed(self._components)): + try: + symbol_component = next(components_reversed) + except StopIteration: + return False + use_substring_matching = self._substring_matching and (i == 0) + if not pattern_component.matches(symbol_component, use_substring_matching): + return False + if self._is_absolute_pattern: + # ensure that there are no more components in the symbol + try: + next(components_reversed) + return False + except StopIteration: + pass + return True + + +class LanguageServerSymbol(Symbol, ToStringMixin): + def __init__(self, symbol_root_from_ls: UnifiedSymbolInformation) -> None: + self.symbol_root = symbol_root_from_ls + + def _tostring_includes(self) -> list[str]: + return [] + + def _tostring_additional_entries(self) -> dict[str, Any]: + return dict(name=self.name, kind=self.symbol_kind_name, num_children=len(self.symbol_root["children"])) + + @property + def name(self) -> str: + return self.symbol_root["name"] + + @property + def symbol_kind_name(self) -> str: + """ + :return: string representation of the symbol kind (name attribute of the `SymbolKind` enum item) + """ + return SymbolKind(self.symbol_kind).name + + @property + def symbol_kind(self) -> SymbolKind: + return self.symbol_root["kind"] + + def is_low_level(self) -> bool: + """ + :return: whether the symbol is a low-level symbol (variable, constant, etc.), which typically represents data + rather than structure and therefore is not relevant in a high-level overview of the code. + """ + return self.symbol_kind >= SymbolKind.Variable.value + + @property + def overload_idx(self) -> int | None: + return self.symbol_root.get("overload_idx") + + def is_neighbouring_definition_separated_by_empty_line(self) -> bool: + return self.symbol_kind in (SymbolKind.Function, SymbolKind.Method, SymbolKind.Class, SymbolKind.Interface, SymbolKind.Struct) + + @property + def relative_path(self) -> str | None: + location = self.symbol_root.get("location") + if location: + return location.get("relativePath") + return None + + @property + def location(self) -> LanguageServerSymbolLocation: + """ + :return: the start location of the actual symbol identifier + """ + return LanguageServerSymbolLocation(relative_path=self.relative_path, line=self.line, column=self.column) + + @property + def body_start_position(self) -> Position | None: + location = self.symbol_root.get("location") + if location: + range_info = location.get("range") + if range_info: + start_pos = range_info.get("start") + if start_pos: + return start_pos + return None + + @property + def body_end_position(self) -> Position | None: + location = self.symbol_root.get("location") + if location: + range_info = location.get("range") + if range_info: + end_pos = range_info.get("end") + if end_pos: + return end_pos + return None + + def get_body_start_position(self) -> PositionInFile | None: + start_pos = self.body_start_position + if start_pos is None: + return None + return PositionInFile(line=start_pos["line"], col=start_pos["character"]) + + def get_body_end_position(self) -> PositionInFile | None: + end_pos = self.body_end_position + if end_pos is None: + return None + return PositionInFile(line=end_pos["line"], col=end_pos["character"]) + + def get_body_line_numbers(self) -> tuple[int | None, int | None]: + start_pos = self.body_start_position + end_pos = self.body_end_position + start_line = start_pos["line"] if start_pos else None + end_line = end_pos["line"] if end_pos else None + return start_line, end_line + + def get_body_line_numbers_or_raise(self) -> tuple[int, int]: + start_line, end_line = self.get_body_line_numbers() + if start_line is None or end_line is None: + raise ValueError(f"Body line numbers could not be determined for {self.get_name_path()}") + return start_line, end_line + + @property + def line(self) -> int | None: + """ + :return: the line in which the symbol identifier is defined. + """ + if "selectionRange" in self.symbol_root: + return self.symbol_root["selectionRange"]["start"]["line"] + else: + # line is expected to be undefined for some types of symbols (e.g. SymbolKind.File) + return None + + @property + def column(self) -> int | None: + if "selectionRange" in self.symbol_root: + return self.symbol_root["selectionRange"]["start"]["character"] + else: + # precise location is expected to be undefined for some types of symbols (e.g. SymbolKind.File) + return None + + @property + def body(self) -> str | None: + body = self.symbol_root.get("body") + if body is None: + return None + else: + return body.get_text() + + def get_name_path(self) -> str: + """ + Get the name path of the symbol, e.g. "class/method/inner_function" or + "class/method[1]" (overloaded method with identifying index). + """ + name_path = NAME_PATH_SEP.join(reversed([str(x) for x in self.iter_name_path_components_reversed()])) + return name_path + + def iter_name_path_components_reversed(self) -> Iterator[NamePathComponent]: + yield NamePathComponent(self.name, self.overload_idx) + for ancestor in self.iter_ancestors(up_to_symbol_kind=SymbolKind.File): + yield NamePathComponent(ancestor.name, ancestor.overload_idx) + + def iter_children(self) -> Iterator[Self]: + for c in self.symbol_root["children"]: + yield self.__class__(c) + + def iter_ancestors(self, up_to_symbol_kind: SymbolKind | None = None) -> Iterator[Self]: + """ + Iterate over all ancestors of the symbol, starting with the parent and going up to the root or + the given symbol kind. + + :param up_to_symbol_kind: if provided, iteration will stop *before* the first ancestor of the given kind. + A typical use case is to pass `SymbolKind.File` or `SymbolKind.Package`. + """ + parent = self.get_parent() + if parent is not None: + if up_to_symbol_kind is None or parent.symbol_kind != up_to_symbol_kind: + yield parent + yield from parent.iter_ancestors(up_to_symbol_kind=up_to_symbol_kind) + + def get_parent(self) -> Self | None: + parent_root = self.symbol_root.get("parent") + if parent_root is None: + return None + return self.__class__(parent_root) + + def find( + self, + name_path_pattern: str, + substring_matching: bool = False, + include_kinds: Sequence[SymbolKind] | None = None, + exclude_kinds: Sequence[SymbolKind] | None = None, + ) -> list[Self]: + """ + Find all symbols within the symbol's subtree that match the given name path pattern. + + :param name_path_pattern: the name path pattern to match against (see class :class:`NamePathMatcher` for details) + :param substring_matching: whether to use substring matching (as opposed to exact matching) + of the last segment of `name_path` against the symbol name. + :param include_kinds: an optional sequence of ints representing the LSP symbol kind. + If provided, only symbols of the given kinds will be included in the result. + :param exclude_kinds: If provided, symbols of the given kinds will be excluded from the result. + """ + result = [] + name_path_matcher = NamePathMatcher(name_path_pattern, substring_matching) + + def should_include(s: "LanguageServerSymbol") -> bool: + if include_kinds is not None and s.symbol_kind not in include_kinds: + return False + if exclude_kinds is not None and s.symbol_kind in exclude_kinds: + return False + return name_path_matcher.matches_ls_symbol(s) + + def traverse(s: "LanguageServerSymbol") -> None: + if should_include(s): + result.append(s) + for c in s.iter_children(): + traverse(c) + + traverse(self) + return result + + class OutputDict(TypedDict): + name_path: NotRequired[str] + name: NotRequired[str] + location: NotRequired[dict[str, Any]] + relative_path: NotRequired[str | None] + body_location: NotRequired[dict[str, Any]] + body: NotRequired[str | None] + kind: NotRequired[str] + """ + string representation of the symbol kind (name attribute of the `SymbolKind` enum item) + """ + children: NotRequired[list["LanguageServerSymbol.OutputDict"]] + content_around_reference: NotRequired[str] + """set by :class:`FindReferencingSymbolsTool` when including surrounding code lines""" + reference_line: NotRequired[int] + """line number of the reference, set by :class:`FindReferencingSymbolsTool`""" + + OutputDictKey = Literal[ + "name", + "name_path", + "relative_path", + "location", + "body_location", + "body", + "kind", + "children", + "content_around_reference", + "reference_line", + ] + + def to_dict( + self, + *, + name_path: bool = True, + name: bool = False, + kind: bool = False, + location: bool = False, + depth: int = 0, + body: bool = False, + body_location: bool = False, + children_body: bool = False, + children_name_path: bool | None = None, + children_name: bool | None = None, + relative_path: bool = False, + child_inclusion_predicate: Callable[[Self], bool] | None = None, + ) -> OutputDict: + """ + Converts the symbol to a dictionary. + + :param name_path: whether to include the name path of the symbol + :param name: whether to include the name of the symbol + :param kind: whether to include the kind of the symbol + :param location: whether to include the location of the symbol + :param depth: the depth up to which to include child symbols (0 = do not include children) + :param body: whether to include the body of the top-level symbol. + :param children_body: whether to also include the body of the children. + Note that the body of the children is part of the body of the parent symbol, + so there is usually no need to set this to True unless you want process the output + and pass the children without passing the parent body to the LM. + :param children_name_path: whether to include the name path of the children; if None, defaults to the value of `name_path` + :param children_name: whether to include the name of the children; if None, defaults to the value of `name` + :param relative_path: whether to include the relative path of the symbol. + If `location` is True, this defines whether to include the path in the location entry. + If `location` is False, this defines whether to include the relative path as a top-level entry. + Relative paths of the symbol's children are always excluded. + :param child_inclusion_predicate: an optional predicate that decides whether a child symbol + should be included. + :return: a dictionary representation of the symbol + """ + result: LanguageServerSymbol.OutputDict = {} + + if children_name_path is None: + children_name_path = name_path + if children_name is None: + children_name = name + + if name_path: + result["name_path"] = self.get_name_path() + if name: + result["name"] = self.name + + if kind: + result["kind"] = self.symbol_kind_name + + if location: + result["location"] = self.location.to_dict(include_relative_path=relative_path) + elif relative_path: + result["relative_path"] = self.relative_path + + if body_location: + body_start_line, body_end_line = self.get_body_line_numbers() + result["body_location"] = {"start_line": body_start_line, "end_line": body_end_line} + + if body: + result["body"] = self.body + + if child_inclusion_predicate is None: + child_inclusion_predicate = lambda s: True + + def included_children(s: Self) -> list[LanguageServerSymbol.OutputDict]: + children = [] + for c in s.iter_children(): + if not child_inclusion_predicate(c): + continue + children.append( + c.to_dict( + name_path=children_name_path, + name=children_name, + kind=kind, + location=location, + body_location=body_location, + depth=depth - 1, + child_inclusion_predicate=child_inclusion_predicate, + body=children_body, + children_body=children_body, + # all children have the same relative path as the parent + relative_path=False, + ) + ) + return children + + if depth > 0: + children = included_children(self) + if len(children) > 0: + result["children"] = children + + return result + + +@dataclass +class ReferenceInLanguageServerSymbol(ToStringMixin): + """ + Represents the location of a reference to another symbol within a symbol/file. + + The contained symbol is the symbol within which the reference is located, + not the symbol that is referenced. + """ + + symbol: LanguageServerSymbol + """ + the symbol within which the reference is located + """ + line: int + """ + the line number in which the reference is located (0-based) + """ + character: int + """ + the column number in which the reference is located (0-based) + """ + + @classmethod + def from_lsp_reference(cls, reference: LSPReferenceInSymbol) -> Self: + return cls(symbol=LanguageServerSymbol(reference.symbol), line=reference.line, character=reference.character) + + def get_relative_path(self) -> str | None: + return self.symbol.location.relative_path + + +class LanguageServerSymbolRetriever: + def __init__(self, project: Project) -> None: + """ + :param project: the project instance + """ + self._ls_manager: LanguageServerManager = project.get_language_server_manager_or_raise() + self.project = project + + def _request_info(self, relative_file_path: str, line: int, column: int, file_buffer: LSPFileBuffer | None = None) -> str | None: + """Retrieves information (in a sanitized format) about the symbol at the desired location, + typically containing the docstring and signature. + + Returns None if no information is available. + """ + lang_server = self.get_language_server(relative_file_path) + hover_info = lang_server.request_hover(relative_file_path=relative_file_path, line=line, column=column, file_buffer=file_buffer) + if hover_info is None: + return None + + contents = hover_info["contents"] + + # Handle various response formats + if isinstance(contents, list): + # Array format: extract all parts and join them + stripped_parts = [] + for part in contents: + if isinstance(part, str) and (stripped_part := part.strip()): + stripped_parts.append(stripped_part) + else: + # should be a dict with "value" key + stripped_parts.append(part["value"].strip()) # type: ignore + return "\n".join(stripped_parts) if stripped_parts else None + + if isinstance(contents, dict) and (stripped_contents := contents.get("value", "").strip()): + return stripped_contents + + if isinstance(contents, str) and (stripped_contents := contents.strip()): + return stripped_contents + + return None + + def request_info_for_symbol(self, symbol: LanguageServerSymbol) -> str | None: + if None in [symbol.relative_path, symbol.line, symbol.column]: + return None + return self._request_info(relative_file_path=symbol.relative_path, line=symbol.line, column=symbol.column) # type: ignore[arg-type] + + def _get_symbol_info_budget(self) -> float: + symbol_info_budget = self.project.serena_config.symbol_info_budget + project_symbol_info_budget = self.project.project_config.symbol_info_budget + if project_symbol_info_budget is not None: + symbol_info_budget = project_symbol_info_budget + return symbol_info_budget + + def request_info_for_symbol_batch( + self, + symbols: list[LanguageServerSymbol], + ) -> dict[LanguageServerSymbol, str | None]: + """Retrieves information for multiple symbols while staying within a time budget. + + The request_hover operation used here is potentially expensive, we optimize by grouping by file + and stop executing it (returning the info as None) after the symbol_info_budget is exceeded. + The hover budget is 5s by default + + Groups symbols by file path to minimize file switching overhead and uses a per-file + cache keyed by (line, col) to avoid duplicate hover lookups. + + The hover budget (symbol_info_budget) limits total time spent on hover + requests. If exceeded, remaining symbols get info=None (partial results). + + :param symbols: list of symbols to get info for + :return: a dict mapping each processable symbol to its info (or None if unavailable). Symbols with missing location attributes (relative_path/line/column is None) are skipped and omitted from the result. + """ + if not symbols: + return {} + + debug_enabled = log.isEnabledFor(logging.DEBUG) + t0_total = perf_counter() if debug_enabled else 0.0 + + info_by_symbol: dict[LanguageServerSymbol, str | None] = {} + skipped_symbols = 0 + + # Group symbols by file path, filtering invalid symbols. + symbols_by_file: dict[str, list[LanguageServerSymbol]] = {} + for sym in symbols: + file_path = sym.relative_path + line = sym.line + column = sym.column + if file_path is None or line is None or column is None: + skipped_symbols += 1 + continue + + symbols_by_file.setdefault(file_path, []).append(sym) + + hover_spent_seconds = 0.0 + symbol_info_budget_seconds = self._get_symbol_info_budget() + # the vars below are only for debug logging + per_file_stats: list[tuple[str, int, float]] = [] + total_hover_lookups = 0 + hover_cache_hits = 0 + skipped_due_to_budget = 0 + + for file_path, file_symbols in symbols_by_file.items(): + t0_file = perf_counter() if debug_enabled else 0.0 + file_hover_lookups = 0 + + ls = self.get_language_server(file_path) + with ls.open_file(file_path) as file_buffer: + for sym in file_symbols: + # Check budget before starting a new hover request + # symbol_info_budget_seconds=0 disables the budget mechanism (the first inequality) + if 0 < symbol_info_budget_seconds <= hover_spent_seconds: + skipped_due_to_budget += 1 + info = None + # log once when budget exceeded + if skipped_due_to_budget == 1: + log.debug("Skipping further hover operations due to budget exceeded") + else: + line = sym.line + column = sym.column + assert line is not None and column is not None # for mypy, we filtered invalid symbols above + t0_hover = perf_counter() + info = self._request_info(file_path, line, column, file_buffer=file_buffer) + hover_spent_seconds += perf_counter() - t0_hover + file_hover_lookups += 1 + total_hover_lookups += 1 + + info_by_symbol[sym] = info + + if debug_enabled: + file_elapsed_ms = (perf_counter() - t0_file) * 1000 + per_file_stats.append((file_path, file_hover_lookups, file_elapsed_ms)) + + if debug_enabled: + total_elapsed_ms = (perf_counter() - t0_total) * 1000 + total_symbols = len(symbols) + unique_files = len(symbols_by_file) + budget_exceeded = skipped_due_to_budget > 0 + + log.debug( + f"perf: request_info_for_symbols {total_elapsed_ms=:.2f} {total_symbols=} {skipped_symbols=} " + f"{total_hover_lookups=} {hover_cache_hits=} {unique_files=} " + f"{symbol_info_budget_seconds=:.1f} {hover_spent_seconds=:.2f} {budget_exceeded=} {skipped_due_to_budget=}" + ) + + for file_path, lookup_count, elapsed_ms in per_file_stats: + log.debug(f"perf: {file_path=} {lookup_count=} {elapsed_ms=:.2f}") + + return info_by_symbol + + def can_analyze_file(self, relative_file_path: str) -> bool: + return self._ls_manager.has_suitable_ls_for_file(relative_file_path) + + def get_language_server(self, relative_path: str) -> SolidLanguageServer: + """:param relative_path: relative path to a file""" + return self._ls_manager.get_language_server(relative_path) + + def find( + self, + name_path_pattern: str, + include_kinds: Sequence[SymbolKind] | None = None, + exclude_kinds: Sequence[SymbolKind] | None = None, + substring_matching: bool = False, + within_relative_path: str | None = None, + ) -> list[LanguageServerSymbol]: + """ + Finds all symbols that match the given name path pattern (see class :class:`NamePathMatcher` for details), + optionally limited to a specific file and filtered by kind. + """ + symbols: list[LanguageServerSymbol] = [] + if within_relative_path and os.path.isfile(os.path.join(self.project.project_root, within_relative_path)): + """ + For a specific file, use get_language_server to select the best LS for the file type + (consistent with get_symbol_overview). This ensures e.g. PHP files are served by the + PHP language server rather than being rejected by all LSes via is_ignored_path. + """ + lang_servers: Iterable[SolidLanguageServer] = [self._ls_manager.get_language_server(within_relative_path)] + else: + lang_servers = self._ls_manager.iter_language_servers() + for lang_server in lang_servers: + symbol_roots = lang_server.request_full_symbol_tree(within_relative_path=within_relative_path) + for root in symbol_roots: + symbols.extend( + LanguageServerSymbol(root).find( + name_path_pattern, include_kinds=include_kinds, exclude_kinds=exclude_kinds, substring_matching=substring_matching + ) + ) + return symbols + + def find_unique( + self, + name_path_pattern: str, + include_kinds: Sequence[SymbolKind] | None = None, + exclude_kinds: Sequence[SymbolKind] | None = None, + substring_matching: bool = False, + within_relative_path: str | None = None, + ) -> LanguageServerSymbol: + symbol_candidates = self.find( + name_path_pattern, + include_kinds=include_kinds, + exclude_kinds=exclude_kinds, + substring_matching=substring_matching, + within_relative_path=within_relative_path, + ) + if len(symbol_candidates) == 1: + return symbol_candidates[0] + elif len(symbol_candidates) == 0: + raise ValueError(f"No symbol matching '{name_path_pattern}' found") + else: + # There are multiple candidates. + # If only one of the candidates has the given pattern as its exact name path, return that one + exact_matches = [s for s in symbol_candidates if s.get_name_path() == name_path_pattern] + if len(exact_matches) == 1: + return exact_matches[0] + # otherwise, raise an error + include_rel_path = within_relative_path is not None + raise ValueError( + f"Found multiple {len(symbol_candidates)} symbols matching '{name_path_pattern}'. " + "They are: \n" + json.dumps([s.to_dict(kind=True, relative_path=include_rel_path) for s in symbol_candidates], indent=2) + ) + + def find_by_location(self, location: LanguageServerSymbolLocation) -> LanguageServerSymbol | None: + if location.relative_path is None: + return None + lang_server = self.get_language_server(location.relative_path) + document_symbols = lang_server.request_document_symbols(location.relative_path) + for symbol_dict in document_symbols.iter_symbols(): + symbol = LanguageServerSymbol(symbol_dict) + if symbol.location == location: + return symbol + return None + + def find_referencing_symbols( + self, + name_path: str, + relative_file_path: str, + include_body: bool = False, + include_kinds: Sequence[SymbolKind] | None = None, + exclude_kinds: Sequence[SymbolKind] | None = None, + ) -> list[ReferenceInLanguageServerSymbol]: + """ + Find all symbols that reference the specified symbol, which is assumed to be unique. + + :param name_path: the name path of the symbol to find. (While this can be a matching pattern, it should + usually be the full path to ensure uniqueness.) + :param relative_file_path: the relative path of the file in which the referenced symbol is defined. + :param include_body: whether to include the body of all symbols in the result. + Not recommended, as the referencing symbols will often be files, and thus the bodies will be very long. + :param include_kinds: which kinds of symbols to include in the result. + :param exclude_kinds: which kinds of symbols to exclude from the result. + """ + symbol = self.find_unique(name_path, substring_matching=False, within_relative_path=relative_file_path) + return self.find_referencing_symbols_by_location( + symbol.location, include_body=include_body, include_kinds=include_kinds, exclude_kinds=exclude_kinds + ) + + def find_referencing_symbols_by_location( + self, + symbol_location: LanguageServerSymbolLocation, + include_body: bool = False, + include_kinds: Sequence[SymbolKind] | None = None, + exclude_kinds: Sequence[SymbolKind] | None = None, + ) -> list[ReferenceInLanguageServerSymbol]: + """ + Find all symbols that reference the symbol at the given location. + + :param symbol_location: the location of the symbol for which to find references. + Does not need to include an end_line, as it is unused in the search. + :param include_body: whether to include the body of all symbols in the result. + Not recommended, as the referencing symbols will often be files, and thus the bodies will be very long. + Note: you can filter out the bodies of the children if you set include_children_body=False + in the to_dict method. + :param include_kinds: an optional sequence of ints representing the LSP symbol kind. + If provided, only symbols of the given kinds will be included in the result. + :param exclude_kinds: If provided, symbols of the given kinds will be excluded from the result. + Takes precedence over include_kinds. + :return: a list of symbols that reference the given symbol + """ + if not symbol_location.has_position_in_file(): + raise ValueError("Symbol location does not contain a valid position in a file") + assert symbol_location.relative_path is not None + assert symbol_location.line is not None + assert symbol_location.column is not None + lang_server = self.get_language_server(symbol_location.relative_path) + references = lang_server.request_referencing_symbols( + relative_file_path=symbol_location.relative_path, + line=symbol_location.line, + column=symbol_location.column, + include_imports=False, + include_self=False, + include_body=include_body, + include_file_symbols=True, + ) + + if include_kinds is not None: + references = [s for s in references if s.symbol["kind"] in include_kinds] + + if exclude_kinds is not None: + references = [s for s in references if s.symbol["kind"] not in exclude_kinds] + + return [ReferenceInLanguageServerSymbol.from_lsp_reference(r) for r in references] + + def find_implementing_symbols( + self, + name_path: str, + relative_file_path: str, + include_body: bool = False, + include_kinds: Sequence[SymbolKind] | None = None, + exclude_kinds: Sequence[SymbolKind] | None = None, + ) -> list[LanguageServerSymbol]: + """ + Find all symbols that implement the specified symbol, which is assumed to be unique. + + :param name_path: the name path of the symbol to find implementations for. While this can be a matching pattern, + it should usually be the full path to ensure uniqueness. + :param relative_file_path: the relative path of the file in which the implemented symbol is defined. + :param include_body: whether to include the body of all symbols in the result. + :param include_kinds: which kinds of symbols to include in the result. + :param exclude_kinds: which kinds of symbols to exclude from the result. + """ + symbol = self.find_unique(name_path, substring_matching=False, within_relative_path=relative_file_path) + return self.find_implementing_symbols_by_location( + symbol.location, + include_body=include_body, + include_kinds=include_kinds, + exclude_kinds=exclude_kinds, + ) + + def find_implementing_symbols_by_location( + self, + symbol_location: LanguageServerSymbolLocation, + include_body: bool = False, + include_kinds: Sequence[SymbolKind] | None = None, + exclude_kinds: Sequence[SymbolKind] | None = None, + ) -> list[LanguageServerSymbol]: + """ + Find all symbols that implement the symbol at the given location. + + :param symbol_location: the location of the symbol for which to find implementations. + Does not need to include an end_line, as it is unused in the search. + :param include_body: whether to include the body of all symbols in the result. + :param include_kinds: an optional sequence of ints representing the LSP symbol kind. + If provided, only symbols of the given kinds will be included in the result. + :param exclude_kinds: If provided, symbols of the given kinds will be excluded from the result. + Takes precedence over include_kinds. + :return: a list of symbols that implement the given symbol + """ + if not symbol_location.has_position_in_file(): + raise ValueError("Symbol location does not contain a valid position in a file") + assert symbol_location.relative_path is not None + assert symbol_location.line is not None + assert symbol_location.column is not None + lang_server = self.get_language_server(symbol_location.relative_path) + implementing_symbols = lang_server.request_implementing_symbols( + relative_file_path=symbol_location.relative_path, + line=symbol_location.line, + column=symbol_location.column, + include_body=include_body, + ) + + if include_kinds is not None: + implementing_symbols = [s for s in implementing_symbols if s["kind"] in include_kinds] + + if exclude_kinds is not None: + implementing_symbols = [s for s in implementing_symbols if s["kind"] not in exclude_kinds] + + return [LanguageServerSymbol(s) for s in implementing_symbols] + + def find_declaration( + self, + relative_file_path: str, + line: int, + column: int, + include_body: bool = False, + ) -> LanguageServerSymbol | None: + """ + Find the declaration/definition of the symbol at the given file position. + + :param relative_file_path: the relative path to the file in which the symbol usage occurs. + :param line: the 0-based line number of the symbol usage. + :param column: the 0-based column number of the symbol usage. + :param include_body: whether to include the body of the defining symbol in the result. + :return: the defining symbol, or None if no definition could be resolved. + """ + lang_server = self.get_language_server(relative_file_path) + defining_symbol = lang_server.request_defining_symbol( + relative_file_path=relative_file_path, + line=line, + column=column, + include_body=include_body, + ) + if defining_symbol is None: + return None + return LanguageServerSymbol(defining_symbol) + + def get_file_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + """ + Get diagnostics for a file, optionally restricted to a line range and minimum severity. + + :param relative_file_path: the relative path to the file. + :param start_line: the first 0-based line to include. + :param end_line: the last 0-based line to include. `-1` means until end of file. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + :return: the diagnostics matching the requested constraints. + """ + lang_server = self.get_language_server(relative_file_path) + return lang_server.request_text_document_diagnostics( + relative_file_path=relative_file_path, + start_line=start_line, + end_line=end_line, + min_severity=min_severity, + ) + + @staticmethod + def _symbol_identity(symbol: LanguageServerSymbol) -> tuple[str | None, int | None, int | None, str]: + return (symbol.relative_path, symbol.line, symbol.column, symbol.get_name_path()) + + @staticmethod + def _normalize_symbol_for_diagnostics(symbol: LanguageServerSymbol) -> LanguageServerSymbol: + current_symbol = symbol + while current_symbol.is_low_level(): + parent_symbol = current_symbol.get_parent() + if parent_symbol is None: + break + current_symbol = parent_symbol + return current_symbol + + def find_diagnostic_owner_symbol(self, relative_file_path: str, line: int, column: int) -> LanguageServerSymbol | None: + """ + Find the symbol that should own a diagnostic at the given position. + + This prefers the structural container of the diagnostic over low-level symbols such as + local variables, because diagnostics are typically more meaningful when grouped by the + surrounding function, method, class, or analogous construct. + + :param relative_file_path: the relative path to the file containing the diagnostic. + :param line: the 0-based line of the diagnostic. + :param column: the 0-based column of the diagnostic. + :return: the owning symbol, or None if no symbol could be resolved. + """ + lang_server = self.get_language_server(relative_file_path) + symbol_dict = lang_server.request_symbol_at_location( + relative_file_path=relative_file_path, + line=line, + column=column, + ) + if symbol_dict is None: + return None + return self._normalize_symbol_for_diagnostics(LanguageServerSymbol(symbol_dict)) + + def _get_diagnostics_for_symbol(self, symbol: LanguageServerSymbol, min_severity: int) -> list[ls_types.Diagnostic]: + relative_path = symbol.relative_path + if relative_path is None: + return [] + + start_line, end_line = symbol.get_body_line_numbers() + if start_line is None: + if symbol.line is None: + return [] + start_line = symbol.line + if end_line is None: + end_line = start_line + + return self.get_file_diagnostics( + relative_file_path=relative_path, + start_line=start_line, + end_line=end_line, + min_severity=min_severity, + ) + + def get_symbol_diagnostics( + self, + name_path: str, + reference_file: str | None = None, + check_symbol_references: bool = False, + min_severity: int = 4, + ) -> dict[LanguageServerSymbol, list[ls_types.Diagnostic]]: + """ + Get diagnostics for the specified symbol and, optionally, for all symbols that reference it. + + :param name_path: the name path of the symbol to find. It should usually be unique. + :param reference_file: optional file path used to disambiguate the symbol search. + :param check_symbol_references: whether to additionally collect diagnostics for referencing symbols. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + :return: a mapping from symbols to the diagnostics that overlap their body ranges. + """ + symbol = self.find_unique(name_path, substring_matching=False, within_relative_path=reference_file or None) + return self.get_symbol_diagnostics_by_location( + symbol.location, + check_symbol_references=check_symbol_references, + min_severity=min_severity, + ) + + def get_symbol_diagnostics_by_location( + self, + symbol_location: LanguageServerSymbolLocation, + check_symbol_references: bool = False, + min_severity: int = 4, + ) -> dict[LanguageServerSymbol, list[ls_types.Diagnostic]]: + """ + Get diagnostics for the symbol at the given location and, optionally, for all referencing symbols. + + :param symbol_location: location of the symbol to inspect. + :param check_symbol_references: whether to additionally collect diagnostics for referencing symbols. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + :return: an ordered mapping from symbols to the diagnostics that overlap their body ranges. + """ + if not symbol_location.has_position_in_file(): + raise ValueError("Symbol location does not contain a valid position in a file") + + symbol = self.find_by_location(symbol_location) + if symbol is None: + assert symbol_location.relative_path is not None + assert symbol_location.line is not None + assert symbol_location.column is not None + lang_server = self.get_language_server(symbol_location.relative_path) + symbol_dict = lang_server.request_symbol_at_location( + relative_file_path=symbol_location.relative_path, + line=symbol_location.line, + column=symbol_location.column, + ) + if symbol_dict is None: + return {} + symbol = LanguageServerSymbol(symbol_dict) + + symbols_to_check: "OrderedDict[tuple[str | None, int | None, int | None, str], LanguageServerSymbol]" = OrderedDict() + symbols_to_check[self._symbol_identity(symbol)] = symbol + + if check_symbol_references: + reference_symbols = self.find_referencing_symbols_by_location( + symbol.location, + include_body=False, + exclude_kinds=[SymbolKind.File, SymbolKind.Module, SymbolKind.Package, SymbolKind.Namespace], + ) + for reference in reference_symbols: + reference_relative_path = reference.get_relative_path() + normalized_reference_symbol = None + if reference_relative_path is not None: + normalized_reference_symbol = self.find_diagnostic_owner_symbol( + relative_file_path=reference_relative_path, + line=reference.line, + column=reference.character, + ) + if normalized_reference_symbol is None: + normalized_reference_symbol = self._normalize_symbol_for_diagnostics(reference.symbol) + symbols_to_check.setdefault(self._symbol_identity(normalized_reference_symbol), normalized_reference_symbol) + + result: dict[LanguageServerSymbol, list[ls_types.Diagnostic]] = {} + for current_symbol in symbols_to_check.values(): + diagnostics = self._get_diagnostics_for_symbol(current_symbol, min_severity=min_severity) + if diagnostics: + result[current_symbol] = diagnostics + return result + + def get_symbol_overview(self, relative_path: str) -> dict[str, list[LanguageServerSymbol]]: + """ + :param relative_path: the path of the file for which to get the symbol overview + :return: a mapping from file paths to lists of symbols. + For the case where a file is passed, the mapping will contain a single entry. + """ + lang_server = self.get_language_server(relative_path) + path_to_unified_symbols = lang_server.request_overview(relative_path) + return {k: [LanguageServerSymbol(us) for us in v] for k, v in path_to_unified_symbols.items()} + + +class JetBrainsSymbol(Symbol): + def __init__(self, symbol_dict: jb.SymbolDTO, project: Project) -> None: + """ + :param symbol_dict: dictionary as returned by the JetBrains plugin client. + """ + self._project = project + self._dict = symbol_dict + self._cached_file_content: str | None = None + self._cached_body_start_position: PositionInFile | None = None + self._cached_body_end_position: PositionInFile | None = None + self._cached_body = symbol_dict.get("body") + + def _tostring_includes(self) -> list[str]: + return [] + + def _tostring_additional_entries(self) -> dict[str, Any]: + return dict(name_path=self.get_name_path(), relative_path=self.get_relative_path(), type=self._dict["type"]) + + def get_name_path(self) -> str: + return self._dict["name_path"] + + def get_relative_path(self) -> str: + return self._dict["relative_path"] + + def get_file_content(self) -> str: + if self._cached_file_content is None: + path = os.path.join(self._project.project_root, self.get_relative_path()) + with open(path, encoding=self._project.project_config.encoding) as f: + self._cached_file_content = f.read() + return self._cached_file_content + + def is_position_in_file_available(self) -> bool: + return "text_range" in self._dict + + def get_body_start_position(self) -> PositionInFile | None: + if not self.is_position_in_file_available(): + return None + if self._cached_body_start_position is None: + pos = self._dict["text_range"]["start_pos"] + line, col = pos["line"], pos["col"] + self._cached_body_start_position = PositionInFile(line=line, col=col) + return self._cached_body_start_position + + def get_body_end_position(self) -> PositionInFile | None: + if not self.is_position_in_file_available(): + return None + if self._cached_body_end_position is None: + pos = self._dict["text_range"]["end_pos"] + line, col = pos["line"], pos["col"] + self._cached_body_end_position = PositionInFile(line=line, col=col) + return self._cached_body_end_position + + @property + def body(self) -> str | None: + if self._cached_body is not None: + return self._cached_body + start_position = self.get_body_start_position() + if start_position is None: + return None + end_position = self.get_body_end_position() + assert end_position is not None, "If start position is available, end position should also be available. Symbol: {self}" + file_content = self.get_file_content() + self._cached_body = TextUtils.get_text_in_range( + file_content, start_position.line, start_position.col, end_position.line, end_position.col + ) + return self._cached_body + + @property + def name(self) -> str: + return self._dict["name_path"].split("/")[-1] + + def is_neighbouring_definition_separated_by_empty_line(self) -> bool: + # NOTE: Symbol types cannot really be differentiated, because types are not handled in a language-agnostic way. + return False + + +TSymbolDict = TypeVar("TSymbolDict") +GroupedSymbolDict = dict[str, list[dict] | dict[str, dict]] + + +class SymbolDictGrouper(Generic[TSymbolDict], ABC): + """ + A utility class for grouping a list of symbol dictionaries by one or more specified keys. + + If an instance is statically initialised (upon module import), then this establishes a guarantee + that the specified keys are defined in the symbol dictionary type, ensuring at least basic type safety. + The respective ValueError will immediately be apparent. + """ + + def __init__( + self, + symbol_dict_type: type[TSymbolDict], + children_key: Any, + group_keys: list[Any], + group_children_keys: list[Any], + collapse_singleton: bool, + ) -> None: + """ + :param symbol_dict_type: the TypedDict type that represents the type of the symbol dictionaries to be grouped + :param children_key: the key in the symbol dictionaries that contains the list of child symbols (for recursive grouping). + :param group_keys: keys by which to group the symbol dictionaries. Must be a subset of the keys of `symbol_dict_type`. + :param group_children_keys: keys by which to group the child symbol dictionaries. Must be a subset of the keys of `symbol_dict_type`. + :param collapse_singleton: whether to collapse dictionaries containing a single entry after regrouping to just the entry's value + """ + # check whether the type contains all the keys specified in `keys` and raise an error if not. + if not hasattr(symbol_dict_type, "__annotations__"): + raise ValueError(f"symbol_dict_type must be a TypedDict type, got {symbol_dict_type}") + symbol_dict_keys = set(symbol_dict_type.__annotations__.keys()) + for key in group_keys + [children_key] + group_children_keys: + if key not in symbol_dict_keys: + raise ValueError(f"symbol_dict_type {symbol_dict_type} does not contain key '{key}'") + + self._children_key = children_key + self._group_keys = group_keys + self._group_children_keys = group_children_keys + self._collapse_singleton = collapse_singleton + + def _group_by(self, l: list[dict], keys: list[str], children_keys: list[str], is_children: bool) -> dict[str, Any] | list[Any]: + """ + :param l: the list of symbol dictionaries to group + :param keys: the keys to group by + :param children_keys: the keys to group the children by + :param is_children: whether this is a children grouping operation + :return: the (grouped) symbols + """ + if len(keys) > 0: + # group by the first key + grouped: dict[str, Any] = {} + for item in l: + key_value = item.pop(keys[0], "unknown") + if key_value not in grouped: + grouped[key_value] = [] + grouped[key_value].append(item) + # continue grouping by the remaining keys + for k, group in grouped.items(): + grouped[k] = self._group_by(group, keys[1:], children_keys, is_children=is_children) + return grouped + else: + # grouping is complete; now group the children if necessary + for item in l: + if self._children_key in item: + children = item[self._children_key] + item[self._children_key] = self._group_by(children, children_keys, children_keys, is_children=True) + # post-process final items + return [self._transform_item(item, is_children) for item in l] + + def _transform_item(self, item: dict, is_child: bool) -> dict: + """ + Post-processes a final group item (which has been regrouped, i.e. some keys may have been removed), + collapsing singleton items (and items containing only a single non-children key) + + :param item: the item to post-process + :param is_child: whether the item is a child item + """ + if self._collapse_singleton: + if len(item) == 1: + # {"name": "foo"} -> "foo" + # if there is only a single entry, collapse the dictionary to just the value of that entry + return next(iter(item.values())) + elif len(item) == 2 and self._children_key in item: + # {"name": "foo", "children": {...}} -> {"foo": {...}} + # if there are exactly two entries and one of them is the children key, + # convert to {other_value: children} + other_key = next(k for k in item.keys() if k != self._children_key) + new_item = {item[other_key]: item[self._children_key]} + return new_item + return item + + def group(self, symbols: list[TSymbolDict]) -> GroupedSymbolDict | list: + """ + :param symbols: the symbols to group + :return: dictionary with the symbols grouped as defined at construction if at least one key was used for grouping, + otherwise the list of symbols (potentially transformed) + """ + # avoid side effects by working on a deep-copy + symbols_copy = copy.deepcopy(symbols) + return self._group_by(symbols_copy, self._group_keys, self._group_children_keys, is_children=False) + + +class LanguageServerSymbolDictGrouper(SymbolDictGrouper[LanguageServerSymbol.OutputDict]): + def __init__( + self, + group_keys: list[LanguageServerSymbol.OutputDictKey], + group_children_keys: list[LanguageServerSymbol.OutputDictKey], + collapse_singleton: bool = False, + ) -> None: + super().__init__(LanguageServerSymbol.OutputDict, "children", group_keys, group_children_keys, collapse_singleton) + + +class JetBrainsSymbolDictGrouper(SymbolDictGrouper[jb.SymbolDTO]): + def __init__( + self, + group_keys: list[jb.SymbolDTOKey], + group_children_keys: list[jb.SymbolDTOKey], + collapse_singleton: bool = False, + map_name_path_to_name: bool = False, + ) -> None: + """ + :param group_keys: keys to group main symbols by + :param group_children_keys: keys to group child symbols by + :param collapse_singleton: whether to collapse singleton symbol dictionaries + :param map_name_path_to_name: whether to transform the "name_path" key of child symbols to the bare "name" + """ + super().__init__(jb.SymbolDTO, "children", group_keys, group_children_keys, collapse_singleton) + self._map_name_path_to_name = map_name_path_to_name + + def _transform_item(self, item: dict, is_child: bool) -> dict: + if self._map_name_path_to_name and is_child: + # {"name_path: "Class/myMethod"} -> {"name: "myMethod"} + new_item = dict(item) + if "name_path" in item: + name_path = new_item.pop("name_path") + new_item["name"] = name_path.split("/")[-1] + return super()._transform_item(new_item, is_child) + else: + return super()._transform_item(item, is_child) diff --git a/src/serena/task_executor.py b/src/serena/task_executor.py new file mode 100644 index 0000000..6e56014 --- /dev/null +++ b/src/serena/task_executor.py @@ -0,0 +1,231 @@ +import concurrent.futures +import threading +import time +from collections.abc import Callable +from concurrent.futures import Future +from dataclasses import dataclass +from threading import Thread +from typing import Generic, TypeVar + +from sensai.util import logging +from sensai.util.logging import LogTime +from sensai.util.string import ToStringMixin + +log = logging.getLogger(__name__) +T = TypeVar("T") + + +class TaskExecutor: + def __init__(self, name: str, task_completion_callback: Callable[[], None] | None = None): + """ + :param name: the name of the task executor, used for logging purposes + :param task_completion_callback: an optional callback function that will be called after each task completion + (regardless of success, failure, or cancellation) + """ + self._task_executor_lock = threading.Lock() + self._task_executor_queue: list[TaskExecutor.Task] = [] + self._task_executor_thread = Thread(target=self._process_task_queue, name=name, daemon=True) + self._task_executor_thread.start() + self._task_executor_task_index = 1 + self._task_executor_current_task: TaskExecutor.Task | None = None + self._task_executor_last_executed_task_info: TaskExecutor.TaskInfo | None = None + self._task_completion_callback = task_completion_callback + + class Task(ToStringMixin, Generic[T]): + def __init__(self, function: Callable[[], T], name: str, logged: bool = True, timeout: float | None = None): + """ + :param function: the function representing the task to execute + :param name: the name of the task + :param logged: whether to log management of the task; if False, only errors will be logged + :param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely + """ + self.name = name + self.future: concurrent.futures.Future = concurrent.futures.Future() + self.logged = logged + self.timeout = timeout + self._function = function + + def _tostring_includes(self) -> list[str]: + return ["name"] + + def start(self) -> None: + """ + Executes the task in a separate thread, setting the result or exception on the future. + """ + + def run_task() -> None: + try: + if self.future.done(): + if self.logged: + log.info(f"Task {self.name} was already completed/cancelled; skipping execution") + return + with LogTime(self.name, logger=log, enabled=self.logged): + result = self._function() + if not self.future.done(): + self.future.set_result(result) + except Exception as e: + if not self.future.done(): + log.error(f"Error during execution of {self.name}: {e}", exc_info=e) + self.future.set_exception(e) + + thread = Thread(target=run_task, name=self.name) + thread.start() + + def is_done(self) -> bool: + """ + :return: whether the task has completed (either successfully, with failure, or via cancellation) + """ + return self.future.done() + + def result(self, timeout: float | None = None) -> T: + """ + Blocks until the task is done or the timeout is reached, and returns the result. + If an exception occurred during task execution, it is raised here. + If the timeout is reached, a TimeoutError is raised (but the task is not cancelled). + If the task is cancelled, a CancelledError is raised. + + :param timeout: the maximum time to wait in seconds; if None, use the task's own timeout + (which may be None to wait indefinitely) + :return: the result of the task + """ + return self.future.result(timeout=timeout) + + def cancel(self) -> None: + """ + Cancels the task. If it has not yet started, it will not be executed. + If it has already started, its future will be marked as cancelled and will raise a CancelledError + when its result is requested. + """ + self.future.cancel() + + def wait_until_done(self, timeout: float | None = None) -> None: + """ + Waits until the task is done or the timeout is reached. + The task is done if it either completed successfully, failed with an exception, or was cancelled. + + :param timeout: the maximum time to wait in seconds; if None, use the task's own timeout + (which may be None to wait indefinitely) + """ + try: + self.future.result(timeout=timeout) + except: + pass + + def _process_task_queue(self) -> None: + while True: + # obtain task from the queue + task: TaskExecutor.Task | None = None + with self._task_executor_lock: + if len(self._task_executor_queue) > 0: + task = self._task_executor_queue.pop(0) + if task is None: + time.sleep(0.1) + continue + + # start task execution asynchronously + with self._task_executor_lock: + self._task_executor_current_task = task + if task.logged: + log.info("Starting execution of %s", task.name) + task.start() + + # wait for task completion + task.wait_until_done(timeout=task.timeout) + with self._task_executor_lock: + self._task_executor_current_task = None + if task.logged: + self._task_executor_last_executed_task_info = self.TaskInfo.from_task(task, is_running=False) + + # call the task completion callback if provided + if self._task_completion_callback is not None: + try: + self._task_completion_callback() + except Exception as e: + log.error(f"Error in task completion callback after executing {task.name}: {e}", exc_info=e) + + @dataclass + class TaskInfo: + name: str + is_running: bool + future: Future + """ + future for accessing the task's result + """ + task_id: int + """ + unique identifier of the task + """ + logged: bool + + def finished_successfully(self) -> bool: + return self.future.done() and not self.future.cancelled() and self.future.exception() is None + + @staticmethod + def from_task(task: "TaskExecutor.Task", is_running: bool) -> "TaskExecutor.TaskInfo": + return TaskExecutor.TaskInfo(name=task.name, is_running=is_running, future=task.future, task_id=id(task), logged=task.logged) + + def cancel(self) -> None: + self.future.cancel() + + def get_current_tasks(self) -> list[TaskInfo]: + """ + Gets the list of tasks currently running or queued for execution. + The function returns a list of thread-safe TaskInfo objects (specifically created for the caller). + + :return: the list of tasks in the execution order (running task first) + """ + tasks = [] + with self._task_executor_lock: + if self._task_executor_current_task is not None: + tasks.append(self.TaskInfo.from_task(self._task_executor_current_task, True)) + for task in self._task_executor_queue: + if not task.is_done(): + tasks.append(self.TaskInfo.from_task(task, False)) + return tasks + + def issue_task(self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None) -> Task[T]: + """ + Issue a task to the executor for asynchronous execution. + It is ensured that tasks are executed in the order they are issued, one after another. + + :param task: the task to execute + :param name: the name of the task for logging purposes; if None, use the task function's name + :param logged: whether to log management of the task; if False, only errors will be logged + :param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely + :return: the task object, through which the task's future result can be accessed + """ + with self._task_executor_lock: + if logged: + task_prefix_name = f"Task-{self._task_executor_task_index}" + self._task_executor_task_index += 1 + else: + task_prefix_name = "BackgroundTask" + task_name = f"{task_prefix_name}:{name or getattr(task, '__name__', 'task')}" + if logged: + log.info(f"Scheduling {task_name}") + task_obj = self.Task(function=task, name=task_name, logged=logged, timeout=timeout) + self._task_executor_queue.append(task_obj) + return task_obj + + def execute_task(self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None) -> T: + """ + Executes the given task synchronously via the agent's task executor. + This is useful for tasks that need to be executed immediately and whose results are needed right away. + + :param task: the task to execute + :param name: the name of the task for logging purposes; if None, use the task function's name + :param logged: whether to log management of the task; if False, only errors will be logged + :param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely + :return: the result of the task execution + """ + task_obj = self.issue_task(task, name=name, logged=logged, timeout=timeout) + return task_obj.result() + + def get_last_executed_task(self) -> TaskInfo | None: + """ + Gets information about the last executed task. + + :return: TaskInfo of the last executed task, or None if no task has been executed yet. + """ + with self._task_executor_lock: + return self._task_executor_last_executed_task_info diff --git a/src/serena/tools/__init__.py b/src/serena/tools/__init__.py new file mode 100644 index 0000000..08bb433 --- /dev/null +++ b/src/serena/tools/__init__.py @@ -0,0 +1,10 @@ +# ruff: noqa +from .tools_base import * +from .file_tools import * +from .symbol_tools import * +from .memory_tools import * +from .cmd_tools import * +from .config_tools import * +from .workflow_tools import * +from .jetbrains_tools import * +from .query_project_tools import * diff --git a/src/serena/tools/cmd_tools.py b/src/serena/tools/cmd_tools.py new file mode 100644 index 0000000..52afa95 --- /dev/null +++ b/src/serena/tools/cmd_tools.py @@ -0,0 +1,52 @@ +""" +Tools supporting the execution of (external) commands +""" + +import os.path + +from serena.tools import Tool, ToolMarkerCanEdit +from serena.util.shell import execute_shell_command + + +class ExecuteShellCommandTool(Tool, ToolMarkerCanEdit): + """ + Executes a shell command. + """ + + def apply( + self, + command: str, + cwd: str | None = None, + capture_stderr: bool = True, + max_answer_chars: int = -1, + ) -> str: + """ + Execute a shell command and return its output. If there is a memory about suggested commands, read that first. + Never execute unsafe shell commands! + IMPORTANT: Do not use this tool to start + * long-running processes (e.g. servers) that are not intended to terminate quickly, + * processes that require user interaction. + + :param command: the shell command to execute + :param cwd: the working directory to execute the command in. If None, the project root will be used. + :param capture_stderr: whether to capture and return stderr output + :param max_answer_chars: if the output is longer than this number of characters, + no content will be returned. -1 means using the default value, don't adjust unless there is no other way to get the content + required for the task. + :return: a JSON object containing the command's stdout and optionally stderr output + """ + if cwd is None: + _cwd = self.get_project_root() + else: + if os.path.isabs(cwd): + _cwd = cwd + else: + _cwd = os.path.join(self.get_project_root(), cwd) + if not os.path.isdir(_cwd): + raise FileNotFoundError( + f"Specified a relative working directory ({cwd}), but the resulting path is not a directory: {_cwd}" + ) + + result = execute_shell_command(command, cwd=_cwd, capture_stderr=capture_stderr) + result = result.model_dump_json() + return self._limit_length(result, max_answer_chars) diff --git a/src/serena/tools/config_tools.py b/src/serena/tools/config_tools.py new file mode 100644 index 0000000..12e4baf --- /dev/null +++ b/src/serena/tools/config_tools.py @@ -0,0 +1,66 @@ +from sensai.util.helper import mark_used + +from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional + + +class OpenDashboardTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveProject): + """ + Opens the Serena web dashboard in the default web browser. + The dashboard provides logs, session information, and tool usage statistics. + """ + + def apply(self) -> str: + """ + Opens the Serena web dashboard in the default web browser. + """ + if self.agent.open_dashboard(): + return f"Serena web dashboard has been opened in the user's default web browser: {self.agent.get_dashboard_url()}" + else: + return f"Serena web dashboard could not be opened automatically; tell the user to open it via {self.agent.get_dashboard_url()}" + + +class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): + """ + Activates a project based on the project name or path. + """ + + # noinspection PyIncorrectDocstring + # (session_id is injected via apply_ex) + def apply(self, project: str, session_id: str) -> str: + """ + Activates the project with the given name or path. + + :param project: the name of a registered project to activate or a path to a project directory + """ + is_new_activation = self.agent.activate_project_from_path_or_name(project) + mark_used(is_new_activation) + result = self.agent.get_project_activation_message(session_id) + result += "\nIMPORTANT: If you have not yet read the 'Serena Instructions Manual', do it now before continuing!" + return result + + +class RemoveProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional): + """ + Removes a project from the Serena configuration. + """ + + def apply(self, project_name: str) -> str: + """ + Removes a project from the Serena configuration. + + :param project_name: Name of the project to remove + """ + self.agent.serena_config.remove_project(project_name) + return f"Successfully removed project '{project_name}' from configuration." + + +class GetCurrentConfigTool(Tool): + """ + Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. + """ + + def apply(self) -> str: + """ + Print the current configuration of the agent, including the active and available projects, tools, contexts, and modes. + """ + return self.agent.get_current_config_overview() diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py new file mode 100644 index 0000000..8a3a3e5 --- /dev/null +++ b/src/serena/tools/file_tools.py @@ -0,0 +1,639 @@ +""" +File and file system-related tools, specifically for + * listing directory contents + * reading files + * creating files + * editing at the file level +""" + +import os +from collections import defaultdict +from fnmatch import fnmatch +from pathlib import Path +from typing import Literal + +from serena.tools import SUCCESS_RESULT, EditedFileContext, EditingToolWithDiagnostics, Tool, ToolMarkerOptional +from serena.util.file_system import scan_directory +from serena.util.text_utils import ( + ContentReplacer, + MultiFileContentReplacer, + ReplacementOccurrence, + expand_braces, + glob_match, +) + + +class ReadFileTool(Tool): + """ + Reads a file within the project directory. + """ + + def apply(self, relative_path: str, start_line: int = 0, end_line: int | None = None, max_answer_chars: int = -1) -> str: + """ + Reads the given file or a chunk of it. + + :param relative_path: the relative path to the file to read + :param start_line: the 0-based index of the first line to be retrieved. + :param end_line: the 0-based index of the last line to be retrieved (inclusive). If None, read until the end of the file. + :param max_answer_chars: if the file (chunk) is longer than this number of characters, + no content will be returned. Don't adjust unless there is really no other way to get the content + required for the task. + :return: the full text of the file at the given relative path + """ + self.project.validate_relative_path(relative_path, require_not_ignored=True) + + result = self.project.read_file(relative_path) + result_lines = result.splitlines() + if end_line is None: + result_lines = result_lines[start_line:] + else: + result_lines = result_lines[start_line : end_line + 1] + result = "\n".join(result_lines) + + return self._limit_length(result, max_answer_chars) + + +class CreateTextFileTool(EditingToolWithDiagnostics): + """ + Creates/overwrites a file in the project directory. + """ + + def apply(self, relative_path: str, content: str) -> str: + """ + Write a new file or overwrite an existing file. + + :param relative_path: the relative path to the file to create + :param content: the (appropriately encoded) content to write to the file + :return: a message indicating success or failure + """ + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + # validating the destination path + project_root = self.get_project_root() + abs_path = (Path(project_root) / relative_path).resolve() + will_overwrite_existing = abs_path.exists() + + if will_overwrite_existing: + self.project.validate_relative_path(relative_path, require_not_ignored=True) + else: + assert abs_path.is_relative_to(self.get_project_root()), ( + f"Cannot create file outside of the project directory, got {relative_path=}" + ) + + # writing the file + abs_path.parent.mkdir(parents=True, exist_ok=True) + abs_path.write_text(content, encoding=self.project.project_config.encoding, newline=self.project.line_ending.newline_str) + answer = f"File created: {relative_path}." + if will_overwrite_existing: + answer += " Overwrote existing file." + + return diagnostics_context.format_result(answer) + + +class ListDirTool(Tool): + """ + Lists files and directories in the given directory (optionally with recursion). + """ + + def apply(self, relative_path: str, recursive: bool, skip_ignored_files: bool = False, max_answer_chars: int = -1) -> str: + """ + Lists files and directories in the given directory (optionally with recursion). + + :param relative_path: the relative path to the directory to list; pass "." to scan the project root + :param recursive: whether to scan subdirectories recursively + :param skip_ignored_files: whether to skip files and directories that are ignored + :param max_answer_chars: if the output is longer than this number of characters, + no content will be returned. -1 means the default value from the config will be used. + Don't adjust unless there is really no other way to get the content required for the task. + :return: a JSON object with the names of directories and files within the given directory + """ + # Check if the directory exists before validation + if not self.project.relative_path_exists(relative_path): + error_info = { + "error": f"Directory not found: {relative_path}", + "project_root": self.get_project_root(), + "hint": "Check if the path is correct relative to the project root", + } + return self._to_json(error_info) + + self.project.validate_relative_path(relative_path, require_not_ignored=skip_ignored_files) + + dirs, files = scan_directory( + os.path.join(self.get_project_root(), relative_path), + relative_to=self.get_project_root(), + recursive=recursive, + is_ignored_dir=self.project.is_ignored_path if skip_ignored_files else None, + is_ignored_file=self.project.is_ignored_path if skip_ignored_files else None, + ) + + result = self._to_json({"dirs": dirs, "files": files}) + return self._limit_length(result, max_answer_chars) + + +class FindFileTool(Tool): + """ + Finds files in the given relative paths + """ + + def apply(self, file_mask: str, relative_path: str) -> str: + """ + Finds non-gitignored files matching the given file mask within the given relative path + + :param file_mask: the filename or file mask (using the wildcards * or ?) to search for + :param relative_path: the relative path to the directory to search in; pass "." to scan the project root + :return: a JSON object with the list of matching files + """ + self.project.validate_relative_path(relative_path, require_not_ignored=True) + + dir_to_scan = os.path.join(self.get_project_root(), relative_path) + + # find the files by ignoring everything that doesn't match + def is_ignored_file(abs_path: str) -> bool: + if self.project.is_ignored_path(abs_path): + return True + filename = os.path.basename(abs_path) + return not fnmatch(filename, file_mask) + + _dirs, files = scan_directory( + path=dir_to_scan, + recursive=True, + is_ignored_dir=self.project.is_ignored_path, + is_ignored_file=is_ignored_file, + relative_to=self.get_project_root(), + ) + + result = self._to_json({"files": files}) + return result + + +class ReplaceContentTool(EditingToolWithDiagnostics): + """ + Replaces content in a file (optionally using regular expressions). + """ + + def apply( + self, + relative_path: str, + needle: str, + repl: str, + mode: Literal["literal", "regex"], + allow_multiple_occurrences: bool = False, + ) -> str: + r""" + Replaces one or more occurrences of a given pattern in a file with new content. + + This is the preferred way to replace content in a file whenever the symbol-level + tools are not appropriate. + + VERY IMPORTANT: The "regex" mode allows very large sections of code to be replaced without fully quoting them! + Use a regex of the form "beginning.*?end-of-text-to-be-replaced" to be faster and more economical! + ALWAYS try to use wildcards to avoid specifying the exact content to be replaced, + especially if it spans several lines. Note that you cannot make mistakes, because if the regex should match + multiple occurrences while you disabled `allow_multiple_occurrences`, an error will be returned, and you can retry + with a revised regex. + Therefore, using regex mode with suitable wildcards is usually the best choice! + + :param relative_path: the relative path to the file + :param needle: the string or regex pattern to search for. + If `mode` is "literal", this string will be matched exactly. + If `mode` is "regex", this string will be treated as a regular expression (syntax of Python's `re` module, + with flags DOTALL and MULTILINE enabled). + :param repl: the replacement string (verbatim). + If mode is "regex", the string can contain backreferences to matched groups in the needle regex, + specified using the syntax $!1, $!2, etc. for groups 1, 2, etc. + :param mode: either "literal" or "regex", specifying how the `needle` parameter is to be interpreted. + :param allow_multiple_occurrences: whether to allow matching and replacing multiple occurrences. + If false and multiple occurrences are found, an error will be returned + """ + return self.replace_content( + relative_path, needle, repl, mode=mode, allow_multiple_occurrences=allow_multiple_occurrences, require_not_ignored=True + ) + + def replace_content( + self, + relative_path: str, + needle: str, + repl: str, + mode: Literal["literal", "regex"], + allow_multiple_occurrences: bool = False, + require_not_ignored: bool = True, + ) -> str: + """ + Performs the replacement, with additional options not exposed in the tool. + This function can be used internally by other tools. + """ + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + self.project.validate_relative_path(relative_path, require_not_ignored=require_not_ignored) + with EditedFileContext(relative_path, self.create_code_editor()) as context: + original_content = context.get_original_content() + replacer = ContentReplacer(mode=mode, allow_multiple_occurrences=allow_multiple_occurrences) + updated_content = replacer.replace(original_content, needle, repl) + context.set_updated_content(updated_content) + return diagnostics_context.format_result(SUCCESS_RESULT) + + +class ReplaceInFilesTool(EditingToolWithDiagnostics): + """ + Replaces occurrences of a pattern across multiple files, with dry-run preview and per-occurrence selection. + """ + + def apply( + self, + needle: str, + repl: str, + mode: Literal["literal", "regex"], + relative_path: str = "", + paths_include_glob: str = "", + paths_exclude_glob: str = "", + dry_run: bool = False, + occurrence_ids: list[str] | None = None, + expected_count: int = -1, + max_answer_chars: int = -1, + ) -> str: + r""" + Replaces occurrences of a pattern across multiple files in ONE call. + + This is the preferred tool for repeated small edits (renames, import swaps, annotation changes, + path prefixes) spanning several files or many places in one file: one call with a SHORT pattern + replaces many single-file replacements with long disambiguating needles. + + Recommended protocol whenever there is ANY risk of unintended replacements: + 1. Call with dry_run=True: every prospective change is returned as a minimal line diff with an + occurrence id; nothing is modified. + 2. Call again with dry_run=False, passing the ids you want in occurrence_ids (omit it to apply + all). You pick the desired replacements from the list - no counting, no needle-crafting. + + For clearly unambiguous bulk replacements you may skip the dry run; pass expected_count as a + guard. If the actual number of matches differs, NOTHING is changed and the diff list is + returned, so a failed guard costs one call and gives you the dry-run output to select from. + + :param needle: the string (mode "literal") or regular expression (mode "regex"; Python `re` + syntax with DOTALL and MULTILINE) to search for + :param repl: the replacement string. In regex mode, backreferences to matched groups can be + specified as $!1, $!2, etc. + :param mode: either "literal" or "regex", specifying how `needle` is to be interpreted + :param relative_path: only consider this file or directory (default: the whole project) + :param paths_include_glob: optional glob (relative to the project root, e.g. "src/**/*.java") + restricting which files are considered + :param paths_exclude_glob: optional glob of files to exclude; takes precedence over the include glob + :param dry_run: if True, do not modify anything; return the prospective changes as a list of + diffs with occurrence ids + :param occurrence_ids: optional list of occurrence ids (obtained from a dry run) to which the + replacement is restricted; if any id is unknown or stale, NOTHING is changed. If omitted, + all occurrences are replaced. + :param expected_count: optional guard for calls without occurrence_ids: the number of + occurrences you expect to be replaced. If the actual count differs, nothing is changed and + the list of prospective changes is returned. -1 disables the guard. + :param max_answer_chars: if the output exceeds this many characters, a shortened version is + returned. -1 uses the configured default. + :return: in a dry run, the prospective changes; otherwise a summary of the applied replacements + """ + replacer = MultiFileContentReplacer(mode=mode) + files = self._collect_files(relative_path, paths_include_glob, paths_exclude_glob) + occurrences = replacer.find_occurrences(files, needle, repl) + contents = dict(files) + + if dry_run: + return self._render_listing(replacer, occurrences, contents, max_answer_chars, dry_run=True) + + if occurrence_ids is not None: + selected, problems = self._resolve_occurrence_ids(occurrence_ids, occurrences) + if problems: + problem_lines = "\n".join(f" {p}" for p in problems) + raise ValueError( + f"{len(problems)} of the given occurrence_ids could not be resolved - NO changes were applied:\n" + f"{problem_lines}\n" + "Re-run with dry_run=True to obtain current occurrence ids." + ) + if not selected: + raise ValueError("occurrence_ids is empty - pass at least one id from a dry run, or omit the parameter to replace all.") + return self._apply_occurrences(replacer, selected, contents, needle, repl) + + # blind apply (no ids) + if not occurrences: + raise ValueError( + "No occurrences of the pattern were found - NO changes were applied. " + "Check the mode (a literal needle containing regex metacharacters must use mode 'literal'; " + "wildcards require mode 'regex') and the path/glob restrictions, " + "or locate the content with search_for_pattern first." + ) + if expected_count >= 0 and len(occurrences) != expected_count: + listing = self._render_listing(replacer, occurrences, contents, max_answer_chars, dry_run=False) + raise ValueError( + f"expected_count={expected_count}, but the pattern matches {len(occurrences)} occurrence(s) - " + f"NO changes were applied. Review the prospective changes below; re-issue with the corrected " + f"expectation, a refined pattern, or occurrence_ids selecting the intended subset.\n{listing}" + ) + ambiguous = [o for o in occurrences if o.is_ambiguous] + if ambiguous: + listing = self._render_listing(replacer, occurrences, contents, max_answer_chars, dry_run=False) + raise ValueError( + f"{len(ambiguous)} occurrence(s) are ambiguous (the pattern matches again inside the matched text, " + f"indicating possible over-matching) - NO changes were applied. Review the prospective changes below " + f"and either refine the pattern or explicitly select occurrences via occurrence_ids.\n{listing}" + ) + return self._apply_occurrences(replacer, occurrences, contents, needle, repl) + + def _collect_files(self, relative_path: str, paths_include_glob: str, paths_exclude_glob: str) -> list[tuple[str, str]]: + """Collects (relative_path, content) pairs of the non-ignored files in scope, in sorted path order.""" + relative_path = relative_path.strip() + if relative_path: + self.project.validate_relative_path(relative_path, require_not_ignored=True) + abs_path = os.path.join(self.get_project_root(), relative_path) + if not os.path.exists(abs_path): + raise FileNotFoundError(f"Relative path {relative_path} does not exist.") + if os.path.isfile(abs_path): + rel_paths = [relative_path] + else: + _dirs, rel_paths = scan_directory( + path=abs_path, + recursive=True, + is_ignored_dir=self.project.is_ignored_path, + is_ignored_file=self.project.is_ignored_path, + relative_to=self.get_project_root(), + ) + include_patterns = expand_braces(paths_include_glob.strip()) if paths_include_glob.strip() else None + exclude_patterns = expand_braces(paths_exclude_glob.strip()) if paths_exclude_glob.strip() else None + files: list[tuple[str, str]] = [] + for path in sorted(rel_paths): + if include_patterns and not any(glob_match(p, path) for p in include_patterns): + continue + if exclude_patterns and any(glob_match(p, path) for p in exclude_patterns): + continue + try: + files.append((path, self.project.read_file(path))) + except Exception: + continue # skip unreadable (e.g. binary) files + return files + + def _render_listing( + self, + replacer: MultiFileContentReplacer, + occurrences: list[ReplacementOccurrence], + contents: dict[str, str], + max_answer_chars: int, + dry_run: bool, + ) -> str: + affected_files = sorted({o.relative_path for o in occurrences}) + header = f"Found {len(occurrences)} occurrence(s) in {len(affected_files)} file(s)." + if dry_run: + header += ( + " DRY RUN - no changes were applied.\n" + "Re-issue with dry_run=False to replace all of them, or additionally pass occurrence_ids " + "with the ids of the occurrences to replace." + ) + parts = [header] + for path in affected_files: + file_occurrences = [o for o in occurrences if o.relative_path == path] + parts.append(f"\n{path} ({len(file_occurrences)} occurrence(s)):") + for occ in file_occurrences: + parts.append(replacer.render_occurrence_diff(occ, contents[path])) + result = "\n".join(parts) + + def make_locations_only() -> str: + lines = [header] + [f" [{o.occurrence_id}] line {o.start_line}" for o in occurrences] + return "\n".join(lines) + + def make_per_file_counts() -> str: + counts = {path: sum(1 for o in occurrences if o.relative_path == path) for path in affected_files} + return f"{header}\nOccurrence counts per file:\n{self._to_json(counts)}" + + def make_summary() -> str: + return header + + return self._limit_length( + result, max_answer_chars, shortened_result_factories=[make_locations_only, make_per_file_counts, make_summary] + ) + + @staticmethod + def _resolve_occurrence_ids( + occurrence_ids: list[str], occurrences: list[ReplacementOccurrence] + ) -> tuple[list[ReplacementOccurrence], list[str]]: + """Resolves the requested ids against the current occurrences, diagnosing each failure.""" + occurrences_by_id = {o.occurrence_id: o for o in occurrences} + indices_by_path: dict[str, set[int]] = {} + for o in occurrences: + indices_by_path.setdefault(o.relative_path, set()).add(o.index_in_file) + selected: dict[str, ReplacementOccurrence] = {} + problems: list[str] = [] + for oid in occurrence_ids: + occurrence = occurrences_by_id.get(oid) + if occurrence is not None: + selected[oid] = occurrence + continue + id_match = MultiFileContentReplacer.OCCURRENCE_ID_REGEX.match(oid) + if id_match is None: + problems.append(f"{oid}: malformed id (expected ':@' as returned by a dry run)") + elif id_match.group("path") not in indices_by_path: + problems.append(f"{oid}: the pattern currently has no matches in this file") + elif int(id_match.group("index")) not in indices_by_path[id_match.group("path")]: + problems.append(f"{oid}: the file now has fewer matches than at dry-run time (content changed)") + else: + problems.append(f"{oid}: the matched text changed since the dry run (content changed)") + return list(selected.values()), problems + + def _apply_occurrences( + self, + replacer: MultiFileContentReplacer, + occurrences: list[ReplacementOccurrence], + contents: dict[str, str], + needle: str, + repl: str, + ) -> str: + occurrences_by_file: dict[str, list[ReplacementOccurrence]] = {} + for occ in occurrences: + occurrences_by_file.setdefault(occ.relative_path, []).append(occ) + with self.DiagnosticsContext(self, *occurrences_by_file.keys()) as diagnostics_context: + code_editor = self.create_code_editor() + for path, file_occurrences in occurrences_by_file.items(): + with EditedFileContext(path, code_editor) as context: + original_content = context.get_original_content() + if original_content != contents[path]: + # the editor's view differs from what was scanned (e.g. line-ending normalization); + # re-derive the occurrences from the authoritative content and re-validate by id + fresh_by_id = {o.occurrence_id: o for o in replacer.find_occurrences([(path, original_content)], needle, repl)} + try: + file_occurrences = [fresh_by_id[o.occurrence_id] for o in file_occurrences] + except KeyError as e: + raise ValueError( + f"The content of {path} changed while replacing (occurrence {e} no longer resolves); " + f"the file was NOT modified. Re-run with dry_run=True for current ids." + ) from e + context.set_updated_content(replacer.apply_to_content(original_content, file_occurrences)) + per_file = "\n".join(f" {path}: {len(occs)}" for path, occs in occurrences_by_file.items()) + summary = f"Replaced {len(occurrences)} occurrence(s) in {len(occurrences_by_file)} file(s):\n{per_file}" + return diagnostics_context.format_result(summary) + + +class DeleteLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): + """ + Deletes a range of lines within a file. + """ + + def apply( + self, + relative_path: str, + start_line: int, + end_line: int, + ) -> str: + """ + Deletes the given lines in the file. + Requires that the same range of lines was previously read using the `read_file` tool to verify correctness + of the operation. + + :param relative_path: the relative path to the file + :param start_line: the 0-based index of the first line to be deleted + :param end_line: the 0-based index of the last line to be deleted + """ + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.delete_lines(relative_path, start_line, end_line) + return diagnostics_context.format_result(SUCCESS_RESULT) + + +class ReplaceLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): + """ + Replaces a range of lines within a file with new content. + """ + + def apply( + self, + relative_path: str, + start_line: int, + end_line: int, + content: str, + ) -> str: + """ + Replaces the given range of lines in the given file. + Requires that the same range of lines was previously read using the `read_file` tool to verify correctness + of the operation. + + :param relative_path: the relative path to the file + :param start_line: the 0-based index of the first line to be deleted + :param end_line: the 0-based index of the last line to be deleted + :param content: the content to insert + """ + # normalizing the replacement content + if not content.endswith("\n"): + content += "\n" + + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.delete_lines(relative_path, start_line, end_line) + code_editor.insert_at_line(relative_path, start_line, content) + + return diagnostics_context.format_result(SUCCESS_RESULT) + + +class InsertAtLineTool(EditingToolWithDiagnostics, ToolMarkerOptional): + """ + Inserts content at a given line in a file. + """ + + def apply( + self, + relative_path: str, + line: int, + content: str, + ) -> str: + """ + Inserts the given content at the given line in the file, pushing existing content of the line down. + In general, symbolic insert operations like insert_after_symbol or insert_before_symbol should be preferred if you know which + symbol you are looking for. + However, this can also be useful for small targeted edits of the body of a longer symbol (without replacing the entire body). + + :param relative_path: the relative path to the file + :param line: the 0-based index of the line to insert content at + :param content: the content to be inserted + """ + # normalizing the inserted content + if not content.endswith("\n"): + content += "\n" + + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.insert_at_line(relative_path, line, content) + + return diagnostics_context.format_result(SUCCESS_RESULT) + + +class SearchForPatternTool(Tool): + """ + Performs a search for a pattern in the project. + """ + + def apply( + self, + substring_pattern: str, + context_lines_before: int = 0, + context_lines_after: int = 0, + paths_include_glob: str = "", + paths_exclude_glob: str = "", + relative_path: str = "", + restrict_search_to_code_files: bool = False, + multiline: bool = True, + max_answer_chars: int = -1, + ) -> str: + """ + Searches for a regex pattern across project files, returning whole matched lines (plus optional context). + Prefer symbolic operations if you know which symbols you are looking for! + + :param substring_pattern: regular expression to search for. + :param context_lines_before: number of context lines to include before each match. + :param context_lines_after: number of context lines to include after each match. + :param paths_include_glob: optional glob (relative to project root, e.g. ``"src/**/*.ts"``) restricting which files are searched. + :param paths_exclude_glob: optional glob to exclude files; takes precedence over `paths_include_glob`. + :param relative_path: restricts the search to this file or subdirectory of the project root + :param restrict_search_to_code_files: whether to search only files containing analyzable code symbols + (useful when looking for class/method definitions); otherwise also search non-code files. + :param multiline: whether to apply multi-line matching (default: True), enabling the flags re.DOTALL and re.MULTILINE + :param max_answer_chars: if the output exceeds this many characters, a progressively shortened summary is returned instead. + ``-1`` uses the configured default. + :return: A mapping from file paths to matched consecutive lines (0-based line numbers). + """ + relative_path = relative_path.strip() + if relative_path: + self.project.validate_relative_path(relative_path, require_not_ignored=True) + + abs_path = os.path.join(self.get_project_root(), relative_path) + if not os.path.exists(abs_path): + raise FileNotFoundError(f"Relative path {relative_path} does not exist.") + + matches = self.project.search_project_files_for_pattern( + pattern=substring_pattern, + relative_path=relative_path, + context_lines_before=context_lines_before, + context_lines_after=context_lines_after, + paths_include_glob=paths_include_glob.strip(), + paths_exclude_glob=paths_exclude_glob.strip(), + multiline=multiline, + code_files_only=restrict_search_to_code_files, + ) + + # group matches by file + file_to_matches: dict[str, list[str]] = defaultdict(list) + for match in matches: + assert match.source_file_path is not None + file_to_matches[match.source_file_path].append(match.to_display_string()) + + # capture lightweight match data for shortening before serialization + match_lines_by_file: dict[str, list[int]] = defaultdict(list) + for match in matches: + assert match.source_file_path is not None + match_lines_by_file[match.source_file_path].append(match.matched_lines[0].line_number) + + # shortened result closures, from least to most aggressive shortening + def make_lines_only() -> str: + """Match locations without surrounding context""" + return f"Match lines per file:\n{self._to_json(match_lines_by_file)}" + + def make_per_file_counts() -> str: + counts = {path: len(lines) for path, lines in match_lines_by_file.items()} + return f"Match counts per file:\n{self._to_json(counts)}" + + def make_summary() -> str: + return f"Found {len(matches)} matches in {len(match_lines_by_file)} files." + + result = self._to_json(file_to_matches) + return self._limit_length( + result, max_answer_chars, shortened_result_factories=[make_lines_only, make_per_file_counts, make_summary] + ) diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py new file mode 100644 index 0000000..c29d58c --- /dev/null +++ b/src/serena/tools/jetbrains_tools.py @@ -0,0 +1,681 @@ +import logging +from collections import Counter +from typing import Any, Literal + +import serena.jetbrains.jetbrains_types as jb +from serena.code_editor import JetBrainsCodeEditor +from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient +from serena.jetbrains.jetbrains_types import SymbolDTO, SymbolDTOUtil +from serena.symbol import JetBrainsSymbolDictGrouper +from serena.tools import Tool, ToolMarkerBeta, ToolMarkerOptional, ToolMarkerSymbolicEdit, ToolMarkerSymbolicRead +from serena.util.text_utils import find_text_coordinates + +log = logging.getLogger(__name__) + + +class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): + """ + Performs a global (or local) search for symbols using the JetBrains backend + """ + + # groups top-level symbols only; children are grouped separately by _group_children_by_type + symbol_dict_grouper = JetBrainsSymbolDictGrouper( + ["relative_path", "type"], ["type"], collapse_singleton=True, map_name_path_to_name=True + ) + + def apply( + self, + name_path_pattern: str, + depth: int = 0, + relative_path: str | None = None, + include_body: bool = False, + include_info: bool = False, + search_deps: bool = False, + max_matches: int = -1, + max_answer_chars: int = -1, + ) -> str: + """ + Retrieves information on all symbols/code entities (classes, methods, etc.) based on the given name path pattern. + The returned symbol information can be used for edits or further queries. + Specify `depth > 0` to retrieve children (e.g., methods of a class). + Important: through `search_deps=True` dependencies can be searched, which + should be preferred to web search or other less sophisticated approaches to analyzing dependencies. + + A name path is a path in the symbol tree *within a source file*. + For example, the method `my_method` defined in class `MyClass` would have the name path `MyClass/my_method`. + If a symbol is overloaded (e.g., in Java), a 0-based index is appended (e.g. "MyClass/my_method[0]") to + uniquely identify it. + + To search for a symbol, you provide a name path pattern that is used to match against name paths. + It can be + * a simple name (e.g. "method"), which will match any symbol with that name + * a relative path like "class/method", which will match any symbol with that name path suffix + * an absolute name path "/class/method" (absolute name path), which requires an exact match of the full name path within the source file. + Append an index `[i]` to match a specific overload only, e.g. "MyClass/my_method[1]". + + :param name_path_pattern: the name path matching pattern (see above) + :param depth: depth up to which descendants shall be retrieved (e.g. use 1 to also retrieve immediate children; + for the case where the symbol is a class, this will return its methods). + Ignored if `include_body=True`. Default 0. + :param relative_path: Optional. Restrict search to this file or directory. If not specified, searches entire codebase. + Note: for external dependencies, this must be an identifier starting with ` str: + """Shortened results containing symbol types and identifiers (path + name_path) only, without children""" + dicts: list[SymbolDTO] = [ + {"name_path": s["name_path"], "type": s["type"], "relative_path": s["relative_path"]} for s in symbols + ] + grouped = self.symbol_dict_grouper.group(dicts) + return f"Names with paths:\n{self._to_json(grouped)}" + + n_matches = len(symbols) + if 0 < max_matches < n_matches: + return f"Matched {n_matches}>{max_matches=} symbols.\n" + create_shortened_result() + + grouped_symbols = self.symbol_dict_grouper.group(symbols) + result = self._to_json(grouped_symbols) + return self._limit_length(result, max_answer_chars, shortened_result_factories=[create_shortened_result]) + + @classmethod + def get_param_aliases(cls) -> dict[str, str]: + return {"name_path": "name_path_pattern"} + + +class JetBrainsMoveTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional, ToolMarkerBeta): + """ + Moves a symbol, file or directory to a new location using the JetBrains backend, updating all references + """ + + def apply( + self, + relative_path: str, + name_path: str | None = None, + target_relative_path: str | None = None, + target_parent_name_path: str | None = None, + ) -> str: + """ + Moves a symbol, file or directory to a different location and automatically update all references to affected symbols. + **Important**: this tool should always be preferred to naive moving (e.g. via file system operations or edits) + as it is much more reliable and efficient. It is always safe to use this tool. For some symbols, moving may not be applicable, + and will result in no edits and a suitable error message. + The target location is the new parent of the symbol, + i.e. the moved entity is never renamed by the operation, only moved. + + + Valid moves: + - Symbol: + * (relative_path, name_path) -> new parent symbol (target_relative_path, target_parent_name_path) + * (relative_path, name_path) -> top level of target file or directory (target_relative_path) + Always consider the concrete language-specific semantics! + - target is a file: valid for languages like Python, where files are modules + - target is a directory: valid for languages like Java, where directories are packages and can contain classes + - File or directory: + * relative_path -> new parent directory (target_relative_path) + + :param relative_path: the relative path to the file containing the symbol to move. + :param name_path: the name path of the symbol to move (empty for moving file or dir). + :param target_relative_path: the relative path of the target directory or file. + :param target_parent_name_path: the name path of the target parent symbol. + """ + name_path = name_path or None + target_relative_path = target_relative_path or None + target_parent_name_path = target_parent_name_path or None + relative_path = self._sanitize_input_param(relative_path) + with JetBrainsPluginClient.from_project(self.project) as client: + response_dict = client.move( + name_path=name_path, + relative_path=relative_path, + target_parent_name_path=target_parent_name_path, + target_relative_path=target_relative_path, + ) + return self._to_json(response_dict) + + +class JetBrainsSafeDeleteTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional, ToolMarkerBeta): + """ + Safely deletes a symbol using the JetBrains backend, checking for remaining usages first + """ + + def apply( + self, + relative_path: str, + name_path: str | None = None, + delete_even_if_used: bool = False, + propagate: bool = False, + ) -> str: + """ + Safely deletes a symbol, file, or directory, checking for usages first and propagating deletion, if desired. + Propagation means it is possible to request deleting of usages and cleaning up of unused code. + Propagation is powerful for cleaning up code but should be used with care, and only when you are sure that + **Important**: this tool should always be preferred to naive deleting (e.g. via file system operations or edits). + When using it, you don't have to search for usages first, as the tool will do it for you. + + :param relative_path: the relative path to the file containing the symbol to delete. + :param name_path: the name path of the symbol to delete. + A name path identifies a symbol within a source file, e.g. "MyClass/my_method". + Omit for deleting a file or directory. + :param delete_even_if_used: whether to force deletion even if the symbol still has usages. + Default is False (safe mode: will report usages instead of deleting). + :param propagate: whether to propagate the deletion to usages of the symbol and also + remove symbols that become unused after the deletion. Default is False. + """ + relative_path = self._sanitize_input_param(relative_path) + name_path = name_path or None + with JetBrainsPluginClient.from_project(self.project) as client: + response_dict = client.safe_delete( + name_path=name_path, + relative_path=relative_path, + delete_even_if_used=delete_even_if_used, + propagate=propagate, + ) + return self._to_json(response_dict) + + +class JetBrainsInlineSymbol(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional, ToolMarkerBeta): + """ + Inlines a symbol using the JetBrains backend, replacing all call sites with the symbol's body + """ + + def apply( + self, + name_path: str, + relative_path: str, + keep_definition: bool = False, + ) -> str: + """ + Inlines a symbol (usually a method/function, but also classes may be amenable to inlining, + which turns invocation into anonymous class creation), + replacing all call sites with the symbol's body. + **Important**: this tool should always be preferred to naive inlining (e.g. via searching for references and + editing them). + + :param name_path: the name path of the symbol to inline. + :param relative_path: the relative path to the file containing the symbol to inline. + :param keep_definition: whether to keep the original method definition after inlining all call sites. + May be ignored in some cases (e.g. when inlining a class). + """ + relative_path = self._sanitize_input_param(relative_path) + with JetBrainsPluginClient.from_project(self.project) as client: + response_dict = client.inline_symbol( + name_path=name_path, + relative_path=relative_path, + keep_definition=keep_definition, + ) + return self._to_json(response_dict) + + +class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): + """ + Finds symbols that reference the given symbol using the JetBrains backend + """ + + symbol_dict_grouper = JetBrainsSymbolDictGrouper(["relative_path", "type"], ["type"], collapse_singleton=True) + + # TODO: (maybe) - add content snippets showing the references like in LS based version? + def apply( + self, + name_path: str, + relative_path: str, + max_answer_chars: int = -1, + ) -> str: + """ + Finds symbols that reference the symbol at the specified symbol, i.e. returns symbols whose definitions (e.g. a function body) contain a reference to the given symbol. + The result will contain metadata about the referencing symbols. + + :param name_path: name path of the symbol for which to find references + :param relative_path: the relative path to the file containing the symbol (must be a file, not a directory) + :param max_answer_chars: max characters for the result (-1 for default). If exceeded, no content/a shortened result is returned. + """ + relative_path = self._sanitize_input_param(relative_path) + with JetBrainsPluginClient.from_project(self.project) as client: + response_dict = client.find_references( + name_path=name_path, + relative_path=relative_path, + include_quick_info=False, + ) + symbol_dicts = response_dict["symbols"] + + # replace reference line number (if present) by actual line/context + for symbol_dict in symbol_dicts: + if "reference_line_no" in symbol_dict: + ref_line = symbol_dict["reference_line_no"] + ref_relative_path = symbol_dict["relative_path"] + if not SymbolDTOUtil.is_external_symbol(symbol_dict) and ref_line is not None and ref_line >= 0: + content_around_ref = self.project.retrieve_content_around_line( + relative_file_path=ref_relative_path, line=ref_line, context_lines_before=1, context_lines_after=1 + ) + symbol_dict["context"] = content_around_ref.to_display_string() + del symbol_dict["reference_line_no"] + + # capture file paths before grouping + ref_paths = [s.get("relative_path", "unknown") for s in symbol_dicts] + + result = self.symbol_dict_grouper.group(symbol_dicts) + + def create_shortened_result_counts_per_file() -> str: + return f"Reference counts per file:\n{self._to_json(Counter(ref_paths))}" + + def create_shortened_result_num_results() -> str: + return f"Found {len(ref_paths)} references." + + result_json = self._to_json(result) + return self._limit_length( + result_json, + max_answer_chars, + shortened_result_factories=[create_shortened_result_counts_per_file, create_shortened_result_num_results], + ) + + +class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): + """ + Retrieves an overview of the top-level symbols within a specified file using the JetBrains backend + """ + + USE_COMPACT_FORMAT = True + symbol_dict_grouper = JetBrainsSymbolDictGrouper(["type"], ["type"], collapse_singleton=True, map_name_path_to_name=True) + + def apply( + self, + relative_path: str, + depth: int = -1, + max_answer_chars: int = -1, + include_file_documentation: bool = False, + ) -> str: + """ + Gets an overview of the top-level symbols in the given file. + Calling this is often a good idea before more targeted reading, searching or editing operations on the code symbols. + Before requesting a symbol overview, it is usually a good idea to narrow down the scope of the overview + by first understanding the basic directory structure of the repository that you can get from memories + or by using the `list_dir` and `find_file` tools (or similar). + + :param relative_path: the relative path to the file to get the overview of + :param depth: depth up to which descendants shall be retrieved. + Default (-1) results in a language specific choice: 1 for java and kotlin and 0 for other languages + :param max_answer_chars: max characters for the result (-1 for default). If exceeded, no content/a shortened result is returned. + :param include_file_documentation: whether to include the file's docstring. Default False. + """ + if depth == -1: + if relative_path.endswith((".java", ".kt")): + depth = 1 + else: + depth = 0 + + relative_path = self._sanitize_input_param(relative_path) + with JetBrainsPluginClient.from_project(self.project) as client: + symbol_overview = client.get_symbols_overview( + relative_path=relative_path, depth=depth, include_file_documentation=include_file_documentation + ) + + if self.USE_COMPACT_FORMAT: + symbols = symbol_overview["symbols"] + + grouped_symbols = self.symbol_dict_grouper.group(symbols) + + shortened_result_factories = [] + + # create full result + result: dict[str, Any] = {"symbols": grouped_symbols} + documentation = symbol_overview.pop("documentation", None) + if documentation: + result["docstring"] = documentation + shortened_result_factories.append(lambda: self._to_json(grouped_symbols)) # shortened result without docstring + json_result = self._to_json(result) + + if depth > 0: + + def create_short_result_depth_0() -> str: + depth_0_symbols = [d.copy() for d in symbols] + for d in depth_0_symbols: + d.pop("children", None) + compact_depth_0_result = self.symbol_dict_grouper.group(depth_0_symbols) + return "Depth 0 overview:\n" + self._to_json(compact_depth_0_result) + + shortened_result_factories.append(create_short_result_depth_0) + + def create_short_result_type_counts() -> str: + type_names = [d.get("type", "unknown") for d in symbols] + return f"Symbol counts by type:\n{self._to_json(Counter(type_names))}" + + shortened_result_factories.append(create_short_result_type_counts) + else: + # this path is currently abandoned, consider introducing shortened results if ever needed + shortened_result_factories = None + json_result = self._to_json(symbol_overview) + + return self._limit_length(json_result, max_answer_chars, shortened_result_factories=shortened_result_factories) + + +class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): + """ + Retrieves the type hierarchy (supertypes and/or subtypes) of a symbol using the JetBrains backend + """ + + @staticmethod + def _transform_hierarchy_nodes(nodes: list[jb.TypeHierarchyNodeDTO] | None) -> dict[str, list]: + """ + Transform a list of TypeHierarchyNode into a file-grouped compact format. + + Returns a dict where keys are relative_paths and values are lists of either: + - "SymbolNamePath" (leaf node) + - {"SymbolNamePath": {nested_file_grouped_children}} (node with children) + """ + if not nodes: + return {} + + result: dict[str, list] = {} + + for node in nodes: + symbol = node["symbol"] + name_path = symbol["name_path"] + rel_path = symbol["relative_path"] + children = node.get("children", []) + + if rel_path not in result: + result[rel_path] = [] + + if children: + # Node with children - recurse + nested = JetBrainsTypeHierarchyTool._transform_hierarchy_nodes(children) + result[rel_path].append({name_path: nested}) + else: + # Leaf node + result[rel_path].append(name_path) + + return result + + def apply( + self, + name_path: str, + relative_path: str, + hierarchy_type: Literal["super", "sub", "both"] = "both", + depth: int | None = 1, + max_answer_chars: int = -1, + ) -> str: + """ + Gets the type hierarchy of a symbol (supertypes, subtypes, or both). + + :param name_path: name path of the symbol for which to get the type hierarchy. + :param relative_path: the relative path to the file containing the symbol. + :param hierarchy_type: which hierarchy to retrieve: "super" for parent classes/interfaces, + "sub" for subclasses/implementations, or "both" for both directions. Default is "sub". + :param depth: depth limit for hierarchy traversal (None or 0 for unlimited). Default is 1. + :param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned. + -1 means the default value from the config will be used. + :return: Compact JSON with file-grouped hierarchy. Error string if not applicable. + """ + relative_path = self._sanitize_input_param(relative_path) + with JetBrainsPluginClient.from_project(self.project) as client: + subtypes = None + supertypes = None + levels_not_included = {} + + if hierarchy_type in ("super", "both"): + supertypes_response = client.get_supertypes( + name_path=name_path, + relative_path=relative_path, + depth=depth, + ) + if "num_levels_not_included" in supertypes_response: + levels_not_included["supertypes"] = supertypes_response["num_levels_not_included"] + supertypes = self._transform_hierarchy_nodes(supertypes_response.get("hierarchy")) + + if hierarchy_type in ("sub", "both"): + subtypes_response = client.get_subtypes( + name_path=name_path, + relative_path=relative_path, + depth=depth, + ) + if "num_levels_not_included" in subtypes_response: + levels_not_included["subtypes"] = subtypes_response["num_levels_not_included"] + subtypes = self._transform_hierarchy_nodes(subtypes_response.get("hierarchy")) + + result_dict: dict[str, dict | list] = {} + if supertypes is not None: + result_dict["supertypes"] = supertypes + if subtypes is not None: + result_dict["subtypes"] = subtypes + if levels_not_included: + result_dict["levels_not_included"] = levels_not_included + + result = self._to_json(result_dict) + return self._limit_length(result, max_answer_chars) + + +class JetBrainsFindDeclarationTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): + """ + Finds the declaration of a symbol using the JetBrains backend + """ + + def apply(self, relative_path: str, regex: str, include_body: bool = False) -> str: + r""" + Finds the declaration of a symbol. + + :param relative_path: the relative path to the source file containing the symbol for which to find the declaration. + :param regex: a regular expression with one group, where the group matches the symbol for which to perform the lookup. + For example, to find the declaration of the `process` method in a call like `obj.process()`, + pass an expression like "obj\.(process)\(process_input_arg=37\)". + Prefer regexes with sufficiently large context around the group to render the match unambiguous. + Uses Python syntax with MULTILINE and DOTALL flags enabled. + :param include_body: whether to include the symbol's body in the result. Default False. + """ + relative_path = self._sanitize_input_param(relative_path) + regex = self._sanitize_input_param(regex) + + editor = self.create_code_editor() + content = editor.read_file(relative_path) + coords = find_text_coordinates(content, regex, require_unique=True) + assert coords is not None + with JetBrainsPluginClient.from_project(self.project) as client: + symbol_collection = client.find_declaration( + relative_path=relative_path, line=coords.line, col=coords.col, include_quick_info=False, include_body=include_body + ) + result = self._to_json(symbol_collection) + return result + + +class JetBrainsFindImplementationsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): + """ + Finds the implementations of a symbol using the JetBrains backend + """ + + def apply(self, relative_path: str, name_path: str) -> str: + """ + Finds the implementations of a symbol. + + :param relative_path: the relative path to the source file containing the symbol for which to find implementations. + :param name_path: name path of the symbol for which to find implementations + """ + with JetBrainsPluginClient.from_project(self.project) as client: + symbol_collection = client.find_implementations( + relative_path=relative_path, + name_path=name_path, + include_quick_info=False, + ) + result = self._to_json(symbol_collection) + return result + + +class JetBrainsRenameTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional): + """ + Renames a symbol, file or directory throughout the codebase using the JetBrains backend. + """ + + def apply( + self, + relative_path: str, + new_name: str, + name_path: str | None = None, + rename_in_comments: bool = False, + rename_in_text_occurrences: bool = False, + ) -> str: + """ + Renames a symbol, file or directory throughout the codebase. + Note: renaming in comments/text is on a best-effort basis by the IDE; if the symbol name is non-unique, further + verification is recommended. + + :param relative_path: if `name_path` is passed, the relative path of the file containing the symbol. + Otherwise, the path to the directory or file to rename. + :param new_name: the new name + :param name_path: the name path of the symbol to rename or None if renaming a file or directory. + :param rename_in_comments: whether to also rename occurrences in comments. Default True. + :param rename_in_text_occurrences: whether to also rename occurrences in text. Default True. + :return: a status message + """ + code_editor = JetBrainsCodeEditor(self.project) + result = code_editor.rename_symbol( + name_path=name_path, + relative_path=relative_path, + new_name=new_name, + rename_in_comments=rename_in_comments, + rename_in_text_occurrences=rename_in_text_occurrences, + ) + return self._to_json(result) + + +class JetBrainsDebugTool(Tool, ToolMarkerOptional, ToolMarkerBeta): + """ + Provides debugging functionality (run configs, breakpoints, stepping, inspection, and evaluation) + via a persistent debug REPL connected to the JetBrains IDE. + """ + + def apply( + self, + expression: str, + repl_key: str = "default", + ) -> str: + """ + Debug code interactively by evaluating Groovy expressions in a persistent REPL. + Important: Debugging should only be applied if the user has requested it! + + Use the `serena_info` tool with topic `jet_brains_debug_repl` for usage information. + + :param expression: a Groovy/Java expression/statement to evaluate in the REPL. + If empty/null, closes the REPL with the given key. + :param repl_key: identifier for the REPL instance. State persists across calls with the same key. + :return: string representation of the result + """ + with JetBrainsPluginClient.from_project(self.project) as client: + if expression: + response = client.debug_eval(repl_key=repl_key, expression=expression) + else: + response = client.debug_close(repl_key=repl_key) + return response.get("result", str(response)) + + +class JetBrainsRunInspectionsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): + """ + Runs JetBrains IDE inspections on a file and returns the results. + """ + + def apply( + self, + relative_path: str, + min_severity: str | None = None, + inspection_names: list[str] | None = None, + start_line: int | None = None, + end_line: int | None = None, + max_answer_chars: int = -1, + ) -> str: + """ + Runs IDE inspections (code analysis) on the given file and returns the problems found. + This leverages the full power of JetBrains' static analysis engine, including language-specific + inspections, type checking, potential bugs, code style issues, and more. + + :param relative_path: the relative path to the file to inspect. + :param min_severity: minimum severity level to include in results (e.g. "ERROR", "WARNING", "WEAK_WARNING", "INFO"). + If not specified, all severities are returned. + :param inspection_names: optional list of specific inspection names to run (e.g. ["UnusedImport", "TypeMismatch"]). + If not specified, all applicable inspections are run. + :param start_line: optional 1-based start line to restrict the inspection range. + :param end_line: optional 1-based end line to restrict the inspection range. + :param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned. + -1 means the default value from the config will be used. + :return: JSON string with inspection results including severity, message, and location. + """ + with JetBrainsPluginClient.from_project(self.project) as client: + response_dict = client.run_inspections( + relative_path=relative_path, + min_severity=min_severity, + inspection_names=inspection_names, + start_line=start_line, + end_line=end_line, + ) + result = self._to_json(response_dict) + return self._limit_length(result, max_answer_chars) + + +class JetBrainsListInspectionsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): + """ + Lists available JetBrains IDE inspections, optionally filtered by language or group. + """ + + def apply( + self, + language: str | None = None, + group_path_contains: str | None = None, + max_answer_chars: int = -1, + ) -> str: + """ + Lists the available IDE inspections. Use this to discover which inspections can be passed + to the run_inspections tool's `inspection_names` parameter. + + :param language: optional language to filter by (e.g. "Java", "Python", "Kotlin"). + :param group_path_contains: optional substring to match against the inspection group path + (e.g. "probable bugs", "code style"). + :param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned. + -1 means the default value from the config will be used. + :return: JSON string with the list of available inspections including name, group path, and language. + """ + with JetBrainsPluginClient.from_project(self.project) as client: + response_dict = client.list_inspections( + language=language, + group_path_contains=group_path_contains, + ) + result = self._to_json(response_dict) + return self._limit_length(result, max_answer_chars) diff --git a/src/serena/tools/memory_tools.py b/src/serena/tools/memory_tools.py new file mode 100644 index 0000000..6ebffd9 --- /dev/null +++ b/src/serena/tools/memory_tools.py @@ -0,0 +1,122 @@ +import logging +from typing import Literal + +from serena.tools import Tool, ToolMarkerCanEdit + +log = logging.getLogger(__name__) + + +class WriteMemoryTool(Tool, ToolMarkerCanEdit): + """ + Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format. + The memory name should be meaningful. + """ + + def apply(self, memory_name: str, content: str, max_chars: int = -1) -> str: + """ + Write information about this project that can be useful for future tasks in md format. + The name should be meaningful and can include "/" to organize into topics. + If explicitly instructed, use the "global/" prefix for writing a memory that is shared across projects. + References to other memories should be inside backticks and prefixed with mem:, + e.g., `mem:auth`. + + :param memory_name: memory name + :param content: memory content, utf8-encoded + :param max_chars: see other tools + """ + # NOTE: utf-8 encoding is configured in the MemoriesManager + if max_chars == -1: + max_chars = self.agent.serena_config.default_max_tool_answer_chars + if len(content) > max_chars: + raise ValueError( + f"Content for {memory_name} is too long. Max length is {max_chars} characters. " + "Please make the content shorter." + ) + + return self.memory_manager.save_memory(memory_name, content, is_tool_context=True) + + +class ReadMemoryTool(Tool): + """ + Reads the content of a memory file. + """ + + def apply(self, memory_name: str) -> str: + """ + Use to read a memory that is likely to be relevant to the current task, inferring relevance e.g. from the name. + """ + return self.memory_manager.load_memory(memory_name) + + +class ListMemoriesTool(Tool): + """ + Lists available memories. + """ + + def apply(self, topic: str = "") -> str: + """ + Lists available memories, optionally filtered by topic. + """ + return self._to_json(self.memory_manager.list_memories(topic).to_dict()) + + +class DeleteMemoryTool(Tool, ToolMarkerCanEdit): + """ + Delete a memory file. + """ + + def apply(self, memory_name: str) -> str: + """ + Delete a memory, only call if instructed explicitly or permission was granted by the user. + """ + return self.memory_manager.delete_memory(memory_name, is_tool_context=True) + + +class RenameMemoryTool(Tool, ToolMarkerCanEdit): + """ + Renames or moves a memory, updating references that are marked with the `mem:` prefix. + """ + + def apply(self, old_name: str, new_name: str) -> str: + """ + Rename or move a memory, use "/" in the name to organize into topics. + The "global" topic should only be used if explicitly instructed. + References to other memories that are marked with the `mem:` prefix will be updated accordingly. + References in read-only memories are not affected. + """ + renaming_message, n_references_updated = self.memory_manager.rename_memory_and_propagate_references( + old_name, new_name, is_tool_context=True + ) + if n_references_updated > 0: + log.info(f"Updated {n_references_updated} references to memory {old_name} to {new_name}") + return renaming_message + + +class EditMemoryTool(Tool, ToolMarkerCanEdit): + """ + Replaces content matching a regular expression in a memory. + """ + + def apply( + self, + memory_name: str, + needle: str, + repl: str, + mode: Literal["literal", "regex"], + allow_multiple_occurrences: bool = False, + ) -> str: + r""" + Replace content matching a regular expression in a memory. + + :param memory_name: the name of the memory + :param needle: the string or regex pattern to search for. In regex mode, be careful to not replace too much! + If `mode` is "literal", this string will be matched exactly. + If `mode` is "regex", this string will be treated as a regular expression (syntax of Python's `re` module, + with the MULTILINE and DOTALL flags enabled). + :param repl: the replacement string (verbatim). + :param mode: either "literal" or "regex", specifying how the `needle` parameter is to be interpreted. + :param allow_multiple_occurrences: whether to allow matching and replacing multiple occurrences. + If false and multiple occurrences are found, an error will be returned. + """ + return self.memory_manager.edit_memory( + memory_name, needle, repl, mode, allow_multiple_occurrences, is_tool_context=True, regex_multiline=True + ) diff --git a/src/serena/tools/query_project_tools.py b/src/serena/tools/query_project_tools.py new file mode 100644 index 0000000..2340b53 --- /dev/null +++ b/src/serena/tools/query_project_tools.py @@ -0,0 +1,77 @@ +import json + +from serena.config.serena_config import LanguageBackend +from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClientManager +from serena.project_server import ProjectServerClient +from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional + + +class ListQueryableProjectsTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveProject): + """ + Tool for listing all projects that can be queried by the QueryProjectTool. + """ + + def apply(self, symbol_access: bool = True) -> str: + """ + Lists available projects that can be queried with `query_project_tool`. + + :param symbol_access: whether to return only projects for which symbol access is available. Default: true + :return: project names and roots + """ + # determine relevant projects + registered_projects = self.agent.serena_config.projects + if symbol_access: + backend = self.agent.get_language_backend() + if backend.is_jetbrains(): + # projects with open IDE instances can be queried + matched_clients = JetBrainsPluginClientManager().match_clients(registered_projects) + relevant_projects = [mc.registered_project for mc in matched_clients] + else: + # all projects can be queried via ProjectServer (which instantiates projects dynamically) + relevant_projects = registered_projects + else: + relevant_projects = registered_projects + + # return project names and roots + result = {p.project_name: str(p.project_root) for p in relevant_projects} + return self._to_json(result) + + +class QueryProjectTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveProject): + """ + Tool for querying external project information (i.e. information from projects other than the current one), + by executing a read-only tool. + """ + + def apply(self, project_name: str, tool_name: str, tool_params_json: str) -> str: + """ + Queries a project by executing a read-only Serena tool. The tool will be executed in the context of the project. + Use this to query information from projects other than the activated project. + + :param project_name: the name of the project to query (or root path) + :param tool_name: the name of the tool to execute in the other project. The tool must be read-only. + :param tool_params_json: the parameters to pass to the tool, encoded as a JSON string + """ + tool = self.agent.get_tool_by_name(tool_name) + assert tool.is_readonly(), f"Tool {tool_name} is not read-only and cannot be executed in another project." + if self._is_project_server_required(tool): + client = ProjectServerClient() + return client.query_project(project_name, tool_name, tool_params_json) + else: + registered_project = self.agent.serena_config.get_registered_project(project_name) + assert registered_project is not None, f"Project {project_name} is not registered and cannot be queried." + project = registered_project.get_project_instance(self.agent.serena_config) + with tool.agent.active_project_context(project): + return tool.apply(**json.loads(tool_params_json)) + + def _is_project_server_required(self, tool: Tool) -> bool: + match self.agent.get_language_backend(): + case LanguageBackend.JETBRAINS: + return False + case LanguageBackend.LSP: + # Note: As long as only read-only tools are considered, only symbolic tools require the project server. + # But if we were to allow non-read-only tools, then tools using a CodeEditor also indirectly require language servers. + assert tool.is_readonly() + return tool.is_symbolic() + case _: + raise NotImplementedError diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py new file mode 100644 index 0000000..a0e5fc1 --- /dev/null +++ b/src/serena/tools/symbol_tools.py @@ -0,0 +1,721 @@ +""" +Language server-related tools +""" + +import copy +import os +from collections import Counter, defaultdict +from collections.abc import Sequence +from typing import Any + +from serena.symbol import LanguageServerSymbol, LanguageServerSymbolDictGrouper +from serena.tools import ( + SUCCESS_RESULT, + EditingToolWithDiagnostics, + Tool, + ToolMarkerSymbolicEdit, + ToolMarkerSymbolicRead, +) +from serena.tools.tools_base import ToolMarkerOptional +from serena.util.ls_diagnostics import GroupedDiagnostics +from serena.util.text_utils import find_text_coordinates +from solidlsp.ls_types import SymbolKind + + +class RestartLanguageServerTool(Tool, ToolMarkerOptional): + """Restarts the language server(s).""" + + def apply(self) -> str: + """Use this tool only on explicit user request or after confirmation. + It may be necessary to restart the language server if it hangs. + """ + self.agent.reset_language_server_manager() + return SUCCESS_RESULT + + +class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead): + """ + Gets an overview of the top-level symbols defined in a given file. + """ + + symbol_dict_grouper = LanguageServerSymbolDictGrouper(["kind"], ["kind"], collapse_singleton=True) + + def apply(self, relative_path: str, depth: int = -1, max_answer_chars: int = -1) -> str: + """ + Use this tool to get a high-level understanding of the code symbols in a file. + This should be the first tool to call when you want to understand a new file, unless you already know + what you are looking for. + + :param relative_path: the relative path to the file to get the overview of + :param depth: depth up to which descendants shall be retrieved. + Default (-1) results in a language specific choice: 1 for java and kotlin and 0 for other languages + :param max_answer_chars: if the overview is longer than this number of characters, + no content will be returned. -1 means the default value from the config will be used. + Don't adjust unless there is really no other way to get the content required for the task. + :return: a JSON object containing symbols grouped by kind in a compact format. + """ + if depth == -1: + if relative_path.endswith((".java", ".kt")): + depth = 1 + else: + depth = 0 + + result = self.get_symbol_overview(relative_path, depth=depth) + + # capture kind names and depth-0 snapshots before grouping, which mutates the dicts + kind_names = [d.get("kind", "unknown") for d in result] + if depth > 0: + depth_0_result = [d.copy() for d in result] + for d in depth_0_result: + d.pop("children", None) + + compact_result = self.symbol_dict_grouper.group(result) + result_json_str = self._to_json(compact_result) + + # shortened result closures + def make_kind_counts() -> str: + return f"Symbol counts by kind:\n{self._to_json(Counter(kind_names))}" + + if depth == 0: + shortened_results = [make_kind_counts] + else: + + def make_depth_0_result() -> str: + compact_depth_0_result = self.symbol_dict_grouper.group(depth_0_result) + return "Depth 0 overview:\n" + self._to_json(compact_depth_0_result) + + shortened_results = [make_depth_0_result, make_kind_counts] + + return self._limit_length(result_json_str, max_answer_chars, shortened_result_factories=shortened_results) + + def get_symbol_overview(self, relative_path: str, depth: int = 0) -> list[LanguageServerSymbol.OutputDict]: + """ + :param relative_path: relative path to a source file + :param depth: the depth up to which descendants shall be retrieved + :return: a list of symbol dictionaries representing the symbol overview of the file + """ + symbol_retriever = self.create_language_server_symbol_retriever() + + # The symbol overview is capable of working with both files and directories, + # but we want to ensure that the user provides a file path. + file_path = os.path.join(self.project.project_root, relative_path) + if not os.path.exists(file_path): + raise FileNotFoundError(f"File or directory {relative_path} does not exist in the project.") + if os.path.isdir(file_path): + raise ValueError(f"Expected a file path, but got a directory path: {relative_path}. ") + if not symbol_retriever.can_analyze_file(relative_path): + raise ValueError( + f"Cannot extract symbols from file {relative_path}. Active languages: {[l.value for l in self.agent.get_active_lsp_languages()]}" + ) + + symbols = symbol_retriever.get_symbol_overview(relative_path)[relative_path] + + def child_inclusion_predicate(s: LanguageServerSymbol) -> bool: + return not s.is_low_level() + + symbol_dicts = [] + for symbol in symbols: + symbol_dicts.append( + symbol.to_dict( + name_path=False, + name=True, + depth=depth, + kind=True, + relative_path=False, + location=False, + child_inclusion_predicate=child_inclusion_predicate, + ) + ) + return symbol_dicts + + +class FindSymbolTool(Tool, ToolMarkerSymbolicRead): + """ + Performs a global (or local) search using the language server backend. + """ + + # group children by kind, keeping just the name (the parent's name_path makes it unambiguous); + # we don't group the top-level result list because many tests rely on it being a flat list of symbol dicts + symbol_dict_grouper = LanguageServerSymbolDictGrouper([], ["kind"], collapse_singleton=True) + + # noinspection PyDefaultArgument + def apply( + self, + name_path_pattern: str, + depth: int = 0, + relative_path: str = "", + include_body: bool = False, + include_info: bool = False, + include_kinds: list[int] = [], # noqa: B006 + exclude_kinds: list[int] = [], # noqa: B006 + substring_matching: bool = False, + max_matches: int = -1, + max_answer_chars: int = -1, + ) -> str: + """ + Retrieves information on all symbols/code entities (classes, methods, etc.) based on the given name path pattern. + The returned symbol information can be used for edits or further queries. + Specify `depth > 0` to also retrieve children/descendants (e.g., methods of a class). + + A name path is a path in the symbol tree *within a source file*. + For example, the method `my_method` defined in class `MyClass` would have the name path `MyClass/my_method`. + If a symbol is overloaded (e.g., in Java), a 0-based index is appended (e.g. "MyClass/my_method[0]") to + uniquely identify it. + + To search for a symbol, you provide a name path pattern that is used to match against name paths. + It can be + * a simple name (e.g. "method"), which will match any symbol with that name + * a relative path like "class/method", which will match any symbol with that name path suffix + * an absolute name path "/class/method" (absolute name path), which requires an exact match of the full name path within the source file. + Append an index `[i]` to match a specific overload only, e.g. "MyClass/my_method[1]". + + :param name_path_pattern: the name path matching pattern (see above) + :param depth: depth up to which descendants shall be retrieved (e.g. use 1 to also retrieve immediate children; + for the case where the symbol is a class, this will return its methods). + Ignored if `include_body=True`. Default 0. + :param relative_path: (optional) restrict search to this file or directory. If None, searches entire codebase. + If a directory is passed, the search will be restricted to the files in that directory. + If a file is passed, the search will be restricted to that file. + :param include_body: whether to include the symbol's source code. Use judiciously. + :param include_info: whether to include additional info (hover-like, typically including docstring and signature), + about the symbol (ignored if include_body is True). Info is never included for child symbols. + Note: Depending on the language, this can be slow (e.g., C/C++). + :param include_kinds: (optional) limits results to the given LSP symbol kinds (integers) + :param exclude_kinds: (optional) list of LSP symbol kinds (integers) to exclude. + :param substring_matching: If True, use substring matching for the last element of the pattern, such that + "Foo/get" would match "Foo/getValue" and "Foo/getData". + :param max_matches: maximum number of permitted matches. If exceeded, a shortened result is returned + which allows refining the search. -1 (default) means no limit. Set to 1 if you search for a single symbol. + :param max_answer_chars: max result length; -1 for default + :return: symbols (with locations) matching the name. + """ + if include_body: + depth = 0 # ignore user-specified depth if include_body is True + assert max_matches != 0, "max_matches must be > 0 or equal to -1." + parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None + parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None + symbol_retriever = self.create_language_server_symbol_retriever() + symbols = symbol_retriever.find( + name_path_pattern, + include_kinds=parsed_include_kinds, + exclude_kinds=parsed_exclude_kinds, + substring_matching=substring_matching, + within_relative_path=relative_path, + ) + n_matches = len(symbols) + + def create_short_result_relative_path_to_name_paths() -> str: + relative_path_to_name_paths: defaultdict[str, list[str]] = defaultdict(list) + for s in symbols: + relative_path_to_name_paths[s.location.relative_path or "unknown"].append(s.get_name_path()) + return f"Shortened result:\n{self._to_json(relative_path_to_name_paths)}" + + if 0 < max_matches < n_matches: + return f"Matched {n_matches}>{max_matches=} symbols.\n" + create_short_result_relative_path_to_name_paths() + + symbol_dicts = [ + s.to_dict( + kind=True, + name_path=True, + name=False, + relative_path=True, + body_location=True, + depth=depth, + body=include_body, + children_name=True, + children_name_path=False, + ) + for s in symbols + ] + if not include_body and include_info: + info_by_symbol = symbol_retriever.request_info_for_symbol_batch(symbols) + for s, s_dict in zip(symbols, symbol_dicts, strict=True): + if symbol_info := info_by_symbol.get(s): + # In python 3.15 we could specify extra_items=True in the TypedDict definition, + # https://peps.python.org/pep-0728/ + # If we ever upgrade to 3.15, we can remove the type: ignore[typeddict-unknown-key] + s_dict["info"] = symbol_info + + grouped_symbol_dicts = self.symbol_dict_grouper.group(symbol_dicts) + result = self._to_json(grouped_symbol_dicts) + return self._limit_length(result, max_answer_chars, shortened_result_factories=[create_short_result_relative_path_to_name_paths]) + + @classmethod + def get_param_aliases(cls) -> dict[str, str]: + return {"name_path": "name_path_pattern"} + + +class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): + """ + Finds symbols that reference the given symbol using the language server backend + """ + + symbol_dict_grouper = LanguageServerSymbolDictGrouper(["relative_path", "kind"], ["kind"], collapse_singleton=True) + + # noinspection PyDefaultArgument + def apply( + self, + name_path: str, + relative_path: str, + include_kinds: list[int] = [], # noqa: B006 + exclude_kinds: list[int] = [], # noqa: B006 + max_answer_chars: int = -1, + ) -> str: + """ + Finds references to the symbol at the given `name_path`. The result will contain metadata about the referencing symbols + as well as a short code snippet around the reference. + + :param name_path: name path of the symbol + :param relative_path: the relative path to the file containing the symbol for which to find references. + Note: for external dependencies, this must be an identifier starting with ` str: + """References with name_path and reference line, without surrounding code lines""" + grouped = self.symbol_dict_grouper.group(copy.deepcopy(ref_summaries)) + return f"References without surrounding lines:\n{self._to_json(grouped)}" + + def make_per_file_counts() -> str: + counts = Counter(str(r["relative_path"]) for r in ref_summaries) + return f"Reference counts per file:\n{self._to_json(counts)}" + + def make_summary() -> str: + return f"Found {len(ref_summaries)} references." + + shortened_results = [make_refs_without_context, make_per_file_counts, make_summary] + + result_json = self._to_json(result) + return self._limit_length(result_json, max_answer_chars, shortened_result_factories=shortened_results) + + +class FindImplementationsTool(Tool, ToolMarkerSymbolicRead): + """ + Finds symbols that implement the given symbol using the language server backend. + """ + + # noinspection PyDefaultArgument + def apply( + self, + name_path: str, + relative_path: str, + include_info: bool = False, + include_kinds: list[int] = [], # noqa: B006 + exclude_kinds: list[int] = [], # noqa: B006 + max_answer_chars: int = -1, + ) -> str: + """ + Finds implementations of the symbol at the given `name_path`. + + :param name_path: the symbol's name path + :param relative_path: the relative path to the file containing the symbol for which to find implementations. + Note that here you can't pass a directory but must pass a file. + :param include_info: whether to include additional info (hover-like, typically including docstring and signature), + about the implementing symbols. + :param include_kinds: (optional) limits results to the given LSP symbol kinds (integers) + :param exclude_kinds: (optional) list of LSP symbol kinds (integers) to exclude. + :param max_answer_chars: max result length; -1 for default + :return: a list of JSON objects with the symbols implementing the requested symbol + """ + include_body = False + parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None + parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None + symbol_retriever = self.create_language_server_symbol_retriever() + + implementing_symbols = symbol_retriever.find_implementing_symbols( + name_path, + relative_file_path=relative_path, + include_body=include_body, + include_kinds=parsed_include_kinds, + exclude_kinds=parsed_exclude_kinds, + ) + + symbol_dicts = [ + dict(s.to_dict(kind=True, relative_path=True, depth=0, body=include_body, body_location=True)) for s in implementing_symbols + ] + if include_info: + info_by_symbol = symbol_retriever.request_info_for_symbol_batch(implementing_symbols) + for s, s_dict in zip(implementing_symbols, symbol_dicts, strict=True): + if symbol_info := info_by_symbol.get(s): + s_dict["info"] = symbol_info + s_dict.pop("name", None) # name is included in the info + + result = self._to_json(symbol_dicts) + return self._limit_length(result, max_answer_chars) + + +class FindDeclarationTool(Tool, ToolMarkerSymbolicRead): + """ + Finds the declaration/definition of a symbol + """ + + def apply( + self, + relative_path: str, + regex: str, + containing_symbol_name_path: str | None = None, + include_body: bool = False, + include_info: bool = False, + ) -> str: + r""" + Finds the declaration of a symbol. + + :param relative_path: the relative path to the source file containing the symbol for which to find the declaration. + :param regex: a regular expression with one group, where the group matches the symbol for which to perform the lookup. + For example, to find the declaration of the `process` method in a call like `obj.process()`, + pass an expression like "obj\.(process)\(process_input_arg=37\)". + Prefer regexes with sufficiently large context around the group to render the match unambiguous. + Uses Python syntax with MULTILINE and DOTALL flags enabled. + :param containing_symbol_name_path: optional name path of a containing symbol whose body shall be searched instead of the full file. + :param include_body: whether to include the symbol's body in the result. Default False. + :param include_info: whether to include additional info (hover-like). Default False. + """ + symbol_retriever = self.create_language_server_symbol_retriever() + relative_path = self._sanitize_input_param(relative_path) + regex = self._sanitize_input_param(regex) + + # find relevant location for lookup + editor = self.create_code_editor() + if not containing_symbol_name_path: + content = editor.read_file(relative_path) + coords = find_text_coordinates(content, regex, require_unique=True) + assert coords is not None + else: + symbol = symbol_retriever.find_unique(name_path_pattern=containing_symbol_name_path, within_relative_path=relative_path) + body_line_numers = symbol.get_body_line_numbers_or_raise() + content = editor.read_file(relative_path, lines=body_line_numers) + coords = find_text_coordinates(content, regex, require_unique=True) + assert coords is not None + coords.line += body_line_numers[0] + + # retrieve declaration + defining_symbol = symbol_retriever.find_declaration( + relative_file_path=relative_path, + line=coords.line, + column=coords.col, + include_body=include_body, + ) + if defining_symbol is None: + raise ValueError( + f"No symbol declaration found at the location of the regex match. Location: {relative_path}:{coords.line}:{coords.col}." + ) + + # create output + symbol_dict = self._defining_symbol_to_result_dict( + symbol_retriever, + defining_symbol, + include_body, + include_info, + ) + result = self._to_json(symbol_dict) + return result + + @staticmethod + def _defining_symbol_to_result_dict( + symbol_retriever: Any, + defining_symbol: LanguageServerSymbol, + include_body: bool, + include_info: bool, + ) -> dict[str, Any]: + symbol_dict = dict(defining_symbol.to_dict(kind=True, relative_path=True, depth=0, body=include_body, body_location=True)) + if not include_body and include_info: + if symbol_info := symbol_retriever.request_info_for_symbol(defining_symbol): + symbol_dict["info"] = symbol_info + symbol_dict.pop("name", None) + return symbol_dict + + +class GetDiagnosticsForFileTool(Tool, ToolMarkerSymbolicRead): + """ + Gets diagnostics for a file, optionally restricted to a line range, grouped by file, severity, and containing symbol. + """ + + FILE_LEVEL_DIAGNOSTIC_BUCKET = "" + + def apply( + self, + relative_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + max_answer_chars: int = -1, + ) -> str: + """ + Gets diagnostics for a file. Diagnostics are grouped as `relative_path -> severity -> name_path -> diagnostics_results`. + If a diagnostic cannot be mapped to a symbol, it is grouped under the special name path ``. + + :param relative_path: the relative path to the file to inspect. + :param start_line: the first 0-based line to include. Defaults to 0. + :param end_line: the last 0-based line to include. Defaults to -1, which means until the end of the file. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + Diagnostics with lower-or-equal numeric severity are returned. + :param max_answer_chars: max result length; -1 for default + :return: grouped diagnostics for the requested file. + """ + symbol_retriever = self.create_language_server_symbol_retriever() + diagnostics = symbol_retriever.get_file_diagnostics( + relative_file_path=relative_path, + start_line=start_line, + end_line=end_line, + min_severity=min_severity, + ) + + grouped_diagnostics = GroupedDiagnostics() + for diagnostic in diagnostics: + diag_range = diagnostic["range"]["start"] + name_path = self.FILE_LEVEL_DIAGNOSTIC_BUCKET + owner_symbol = symbol_retriever.find_diagnostic_owner_symbol( + relative_file_path=relative_path, + line=diag_range["line"], + column=diag_range["character"], + ) + if owner_symbol is not None: + name_path = owner_symbol.get_name_path() + grouped_diagnostics.add(relative_path, name_path, diagnostic) + + result = self._to_json(grouped_diagnostics.get_dict()) + return self._limit_length(result, max_answer_chars) + + +class GetDiagnosticsForSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): + """ + Gets diagnostics for a symbol and, optionally, for symbols that reference it. + """ + + def apply( + self, + name_path: str, + reference_file: str = "", + check_symbol_references: bool = False, + min_severity: int = 4, + max_answer_chars: int = -1, + ) -> str: + """ + Gets diagnostics for the specified symbol. When `check_symbol_references` is true, diagnostics for all + referencing symbols are also included. The result is grouped as + `relative_path -> severity -> name_path -> diagnostics_results`. + + :param name_path: the name path of the symbol to inspect. + :param reference_file: optional file path used to disambiguate the symbol search. + :param check_symbol_references: whether to additionally collect diagnostics for symbols that reference the symbol. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + Diagnostics with lower-or-equal numeric severity are returned. + :param max_answer_chars: max result length; -1 for default + :return: grouped diagnostics for the requested symbol and, optionally, its referencing symbols. + """ + symbol_retriever = self.create_language_server_symbol_retriever() + diagnostics_by_symbol = symbol_retriever.get_symbol_diagnostics( + name_path=name_path, + reference_file=reference_file or None, + check_symbol_references=check_symbol_references, + min_severity=min_severity, + ) + + grouped_diagnostics = GroupedDiagnostics() + for symbol, diagnostics in diagnostics_by_symbol.items(): + relative_path = symbol.relative_path + if relative_path is None: + continue + symbol_name_path = symbol.get_name_path() + for diagnostic in diagnostics: + grouped_diagnostics.add(relative_path, symbol_name_path, diagnostic) + + result = self._to_json(grouped_diagnostics.get_dict()) + return self._limit_length(result, max_answer_chars) + + +class ReplaceSymbolBodyTool(EditingToolWithDiagnostics): + """ + Replaces the full definition of a symbol using the language server backend. + """ + + def apply( + self, + name_path: str, + relative_path: str, + body: str, + ) -> str: + r""" + Replaces the body of the given symbol. + + IMPORTANT: Only replace symbol bodies if you have previously made a retrieval with include_body=True and thus know what + constitutes the body! + + :param name_path: name path of the symbol whose body to replace + :param relative_path: the relative path to the file containing the symbol + :param body: the new symbol body. The symbol body is the definition of a symbol + in the programming language, including e.g. the signature line for functions. + Depending on the language, it may or may not include a preceding docstring or other preceding annotations. + """ + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.replace_body( + name_path, + relative_file_path=relative_path, + body=body, + ) + return diagnostics_context.format_result(SUCCESS_RESULT) + + +class InsertAfterSymbolTool(EditingToolWithDiagnostics): + """ + Inserts content after the end of the definition of a given symbol. + """ + + def apply( + self, + name_path: str, + relative_path: str, + body: str, + ) -> str: + """ + Use this to insert code after a class/method/function definition. + Don't use to insert after assignments (constants, fields). + + :param name_path: name path of the symbol after which to insert content + :param relative_path: the relative path to the file containing the symbol + :param body: the body/content to be inserted. The inserted code shall begin with the next line after + the symbol. + """ + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.insert_after_symbol(name_path, relative_file_path=relative_path, body=body) + return diagnostics_context.format_result(SUCCESS_RESULT) + + +class InsertBeforeSymbolTool(EditingToolWithDiagnostics): + """ + Inserts content before the beginning of the definition of a given symbol. + """ + + def apply( + self, + name_path: str, + relative_path: str, + body: str, + ) -> str: + """ + Inserts the given content before the beginning of the definition of the given symbol (via the symbol's location). + A typical use case is to insert a new class, function, method, field or variable assignment; or + a new import statement before the first symbol in the file. + + :param name_path: name path of the symbol before which to insert content + :param relative_path: the relative path to the file containing the symbol + :param body: the body/content to be inserted before the line in which the referenced symbol is defined + """ + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.insert_before_symbol(name_path, relative_file_path=relative_path, body=body) + return diagnostics_context.format_result(SUCCESS_RESULT) + + +class RenameSymbolTool(Tool, ToolMarkerSymbolicEdit): + """ + Renames a symbol throughout the codebase using language server refactoring capabilities. + For JB, we use a separate tool. + """ + + def apply( + self, + name_path: str, + relative_path: str, + new_name: str, + ) -> str: + """ + Renames the symbol with the given `name_path` to `new_name` throughout the entire codebase. + Note: for languages with method overloading, like Java, name_path may have to include a method's + signature to uniquely identify a method. + + :param name_path: name path of the symbol to rename + :param relative_path: the relative path to the file containing the symbol to rename + :param new_name: the new name for the symbol + :return: result summary indicating success or failure + """ + code_editor = self.create_ls_code_editor() + status_message = code_editor.rename_symbol(name_path, relative_path=relative_path, new_name=new_name) + return status_message + + +class SafeDeleteSymbol(Tool, ToolMarkerSymbolicEdit): + def apply( + self, + name_path_pattern: str, + relative_path: str, + ) -> str: + """ + Deletes the symbol if it is safe to do so (i.e., if there are no references to it) + or returns a list of references to it. + + :param name_path_pattern: name path of the symbol to delete + :param relative_path: the relative path to the file containing the symbol to delete + """ + ls_symbol_retriever = self.create_language_server_symbol_retriever() + symbol = ls_symbol_retriever.find_unique(name_path_pattern, substring_matching=False, within_relative_path=relative_path) + symbol_rel_path = symbol.relative_path + assert symbol_rel_path is not None, f"Symbol {name_path_pattern} has no relative path, this is likely a bug." + assert symbol_rel_path == relative_path, f"Symbol {name_path_pattern} is not in the expected relative path {relative_path}." + symbol_name_path = symbol.get_name_path() + + symbol_line = symbol.line + symbol_col = symbol.column + assert symbol_line is not None and symbol_col is not None, ( + f"Symbol {name_path_pattern} has no identifier position, this is likely a bug." + ) + lang_server = ls_symbol_retriever.get_language_server(symbol_rel_path) + references_locations = lang_server.request_references(symbol_rel_path, symbol_line, symbol_col) + file_to_lines: dict[str, list[int]] = defaultdict(list) + if references_locations: + for ref_loc in references_locations: + ref_relative_path = ref_loc.get("relativePath") + if ref_relative_path is None: + continue + file_to_lines[ref_relative_path].append(ref_loc["range"]["start"]["line"]) + if file_to_lines: + return f"Cannot delete, the symbol {symbol_name_path} is referenced in: {self._to_json(file_to_lines)}" + code_editor = self.create_ls_code_editor() + code_editor.delete_symbol(symbol_name_path, relative_file_path=symbol_rel_path) + return SUCCESS_RESULT diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py new file mode 100644 index 0000000..0f09296 --- /dev/null +++ b/src/serena/tools/tools_base.py @@ -0,0 +1,669 @@ +import inspect +import json +from abc import ABC +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from functools import cached_property +from types import TracebackType +from typing import TYPE_CHECKING, Any, Optional, Protocol, Self, TypeVar, cast + +from mcp import Implementation +from mcp.server.fastmcp import Context +from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata +from sensai.util import logging +from sensai.util.string import dict_string + +from serena.config.serena_config import LanguageBackend +from serena.memories.memory_manager import MemoryManager +from serena.project import Project +from serena.prompt_factory import PromptFactory +from serena.util.class_decorators import singleton +from serena.util.inspection import iter_subclasses +from serena.util.ls_diagnostics import DiagnosticsDiff, EditedFilePath, PublishedDiagnosticsSnapshot +from solidlsp.ls_exceptions import SolidLSPException + +if TYPE_CHECKING: + from serena.agent import SerenaAgent + from serena.code_editor import CodeEditor, LanguageServerCodeEditor + from serena.symbol import LanguageServerSymbolRetriever + +log = logging.getLogger(__name__) +T = TypeVar("T") +SUCCESS_RESULT = "OK" + + +class Component(ABC): + def __init__(self, agent: "SerenaAgent"): + self.agent = agent + + def get_project_root(self) -> str: + """ + :return: the root directory of the active project, raises a ValueError if no active project configuration is set + """ + return self.project.project_root + + @property + def prompt_factory(self) -> PromptFactory: + return self.agent.prompt_factory + + @property + def memory_manager(self) -> "MemoryManager": + return self.project.memory_manager + + def create_language_server_symbol_retriever(self) -> "LanguageServerSymbolRetriever": + from serena.symbol import LanguageServerSymbolRetriever + + assert self.agent.get_language_backend().is_lsp(), "Language server symbol retriever can only be created for LSP language backend" + return LanguageServerSymbolRetriever(self.project) + + @property + def project(self) -> Project: + return self.agent.get_active_project_or_raise() + + def create_code_editor(self) -> "CodeEditor": + from ..code_editor import JetBrainsCodeEditor + + match self.agent.get_language_backend(): + case LanguageBackend.LSP: + return self.create_ls_code_editor() + case LanguageBackend.JETBRAINS: + return JetBrainsCodeEditor(project=self.project) + case _: + raise ValueError + + def create_ls_code_editor(self) -> "LanguageServerCodeEditor": + from ..code_editor import LanguageServerCodeEditor + + if not self.agent.is_using_language_server(): + raise Exception("Cannot create LanguageServerCodeEditor; agent is not in language server mode.") + return LanguageServerCodeEditor(self.create_language_server_symbol_retriever()) + + +class ToolMarker: + """ + Base class for tool markers. + """ + + +class ToolMarkerCanEdit(ToolMarker): + """ + Marker class for all tools that can perform editing operations on files. + """ + + +class ToolMarkerDoesNotRequireActiveProject(ToolMarker): + pass + + +class ToolMarkerOptional(ToolMarker): + """ + Marker class for optional tools that are disabled by default. + """ + + +class ToolMarkerSymbolicRead(ToolMarker): + """ + Marker class for tools that perform symbol read operations. + """ + + +class ToolMarkerSymbolicEdit(ToolMarkerCanEdit): + """ + Marker class for tools that perform symbolic edit operations. + """ + + +class ToolMarkerBeta(ToolMarker): + """ + Marker for tools that are considered beta features (may not be fully robust) + """ + + +class ApplyMethodProtocol(Protocol): + """Callable protocol for the apply method of a tool.""" + + def __call__(self, *args: Any, **kwargs: Any) -> str: + pass + + +class ToolCallError(Exception): + """ + Represents an error raised during a tool call execution + """ + + def __init__(self, error_message: str): + super().__init__(error_message) + self._error_message = error_message + + def get_error_message(self) -> str: + return self._error_message + + +class Tool(Component): + # NOTE: each tool should implement the apply method, which is then used in + # the central method of the Tool class `apply_ex`. + # Failure to do so will result in a RuntimeError at tool execution time. + # The apply method is not declared as part of the base Tool interface since we cannot + # know the signature of the (input parameters of the) method in advance. + # + # The docstring and types of the apply method are used to generate the tool description + # (which is use by the LLM, so a good description is important) + # and to validate the tool call arguments. + + SESSION_ID_PARAM_NAME = "session_id" + """ + parameter name to use in apply method for the client session ID. + This parameter will be ignored by the MCP interface but will be populated with the session ID of the current client session + when the tool is called, allowing tools to be session-aware if needed. + """ + + _last_tool_call_client_str: str | None = None + """We can only get the client info from within a tool call. Each tool call will update this variable.""" + + def __init__(self, agent: "SerenaAgent"): + super().__init__(agent) + + @cached_property + def _is_session_aware(self) -> bool: + """ + :return: whether the tool is session-aware, i.e. whether the apply method expects a session_id (str) parameter. + """ + # check apply method for session_id arg + apply_fn = self.get_apply_fn() + sig = inspect.signature(apply_fn) + for param in sig.parameters.values(): + if param.name == self.SESSION_ID_PARAM_NAME: + return True + return False + + @staticmethod + def _sanitize_input_param(raw_param: str) -> str: + # some clients replace < and > with their escaped html versions, we need to counteract this + return raw_param.replace("<", "<").replace(">", ">") + + @classmethod + def set_last_tool_call_client_str(cls, client_str: str | None) -> None: + cls._last_tool_call_client_str = client_str + + @classmethod + def get_last_tool_call_client_str(cls) -> str | None: + return cls._last_tool_call_client_str + + @classmethod + def get_name_from_cls(cls) -> str: + name = cls.__name__ + if name.endswith("Tool"): + name = name[:-4] + # convert to snake_case + name = "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_") + return name + + def get_name(self) -> str: + return self.get_name_from_cls() + + def get_apply_fn(self) -> ApplyMethodProtocol: + apply_fn = getattr(self, "apply") + if apply_fn is None: + raise RuntimeError(f"apply not defined in {self}. Did you forget to implement it?") + return apply_fn + + @classmethod + def can_edit(cls) -> bool: + """ + Returns whether this tool can perform editing operations on code. + + :return: True if the tool can edit code, False otherwise + """ + return issubclass(cls, ToolMarkerCanEdit) + + @classmethod + def get_tool_description(cls) -> str: + docstring = cls.__doc__ + if docstring is None: + return "" + return docstring.strip() + + @classmethod + def get_apply_docstring_from_cls(cls) -> str: + """Get the docstring for the apply method from the class (static metadata). + Needed for creating MCP tools in a separate process without running into serialization issues. + """ + # First try to get from __dict__ to handle dynamic docstring changes + if "apply" in cls.__dict__: + apply_fn = cls.__dict__["apply"] + else: + # Fall back to getattr for inherited methods + apply_fn = getattr(cls, "apply", None) + if apply_fn is None: + raise AttributeError(f"apply method not defined in {cls}. Did you forget to implement it?") + + docstring = apply_fn.__doc__ + if not docstring: + raise AttributeError(f"apply method has no (or empty) docstring in {cls}. Did you forget to implement it?") + return docstring.strip() + + def get_apply_docstring(self) -> str: + """Gets the docstring for the tool application, used by the MCP server.""" + return self.get_apply_docstring_from_cls() + + def get_apply_fn_metadata(self, structured_output: bool | None = None) -> FuncMetadata: + """Gets the metadata for the tool application function, used by the MCP server.""" + return self.get_apply_fn_metadata_from_cls(structured_output=structured_output) + + @classmethod + def get_apply_fn_metadata_from_cls(cls, structured_output: bool | None = None) -> FuncMetadata: + """Get the metadata for the apply method from the class (static metadata). + Needed for creating MCP tools in a separate process without running into serialization issues. + """ + # First try to get from __dict__ to handle dynamic docstring changes + if "apply" in cls.__dict__: + apply_fn = cls.__dict__["apply"] + else: + # Fall back to getattr for inherited methods + apply_fn = getattr(cls, "apply", None) + if apply_fn is None: + raise AttributeError(f"apply method not defined in {cls}. Did you forget to implement it?") + + return func_metadata(apply_fn, skip_names=["self", "cls", cls.SESSION_ID_PARAM_NAME], structured_output=structured_output) + + def _log_tool_application(self, frame: Any, session_id: str) -> None: + params = {} + ignored_params = {"self", "log_call", "catch_exceptions", "args", "apply_fn"} + for param, value in frame.f_locals.items(): + if param in ignored_params: + continue + if param == "kwargs": + params.update(value) + else: + params[param] = value + log.info(f"{self.get_name_from_cls()}: {dict_string(params)}; session_id: {session_id}") + + def _limit_length( + self, + result: str, + max_answer_chars: int, + shortened_result_factories: list[Callable[[], str]] | None = None, + ) -> str: + """Limit the length of the result string, optionally trying progressively shorter versions. + + :param result: the full result string + :param max_answer_chars: maximum allowed characters. -1 means use the default from config. + :param shortened_result_factories: optional list of closures, each producing a progressively shorter + version of the result. They are tried in order until one fits within ``max_answer_chars``. + :return: the result string, potentially replaced by a shortened version + """ + if max_answer_chars == -1: + max_answer_chars = self.agent.serena_config.default_max_tool_answer_chars + if max_answer_chars <= 0: + raise ValueError(f"Must be positive or the default (-1), got: {max_answer_chars=}") + if (n_chars := len(result)) > max_answer_chars: + too_long_msg = ( + f"The answer is too long ({n_chars} characters). " + "You can adjust your query or raise the max_answer_chars parameter." + ) + if shortened_result_factories is not None: + # try each shortening closure in order; + for make_shorter in shortened_result_factories: + shortened = make_shorter() + candidate = f"{too_long_msg}\n{shortened}" + if len(candidate) <= max_answer_chars: + return candidate + result = too_long_msg + return result + + def is_active(self) -> bool: + return self.agent.tool_is_active(self.get_name()) + + def is_readonly(self) -> bool: + return not self.can_edit() + + def is_symbolic(self) -> bool: + return issubclass(self.__class__, ToolMarkerSymbolicRead) or issubclass(self.__class__, ToolMarkerSymbolicEdit) + + @classmethod + def get_param_aliases(cls) -> dict[str, str]: + """ + :return: a mapping of parameter aliases for the apply method, where the key is the alias and the value is the actual parameter name. + This can be used to define alternative parameter names for the same parameter. + """ + return {} + + def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, mcp_ctx: Context | None = None, **kwargs) -> str: + """ + Applies the tool with logging and exception handling, using the given keyword arguments. + This method either returns a string result or raises a ToolCallError in case of an error during tool application + (but if `catch_exception is enabled, it will return the error message as a string instead of raising the exception). + + :param log_call: whether to log the tool call and its result + :param catch_exceptions: whether to catch exceptions and return their messages as strings, instead of raising a ToolCallError + """ + # obtain session ID and client info + session_id = "global" + if mcp_ctx is not None: + try: + session_id = "%x" % id(mcp_ctx.session) + client_params = mcp_ctx.session.client_params + if client_params is not None: + client_info = cast(Implementation, client_params.clientInfo) + client_str = client_info.title if client_info.title else client_info.name + " " + client_info.version + if client_str != self.get_last_tool_call_client_str(): + log.debug(f"Updating client info: {client_info}") + self.set_last_tool_call_client_str(client_str) + except Exception as e: + log.info(f"Failed to get client info: {e}.") + + def task() -> str: + apply_fn = self.get_apply_fn() + + try: + if not self.is_active(): + raise ToolCallError( + f"Tool '{self.get_name_from_cls()}' is not active. Active tools: {self.agent.get_active_tool_names()}" + ) + + if log_call: + self._log_tool_application(inspect.currentframe(), session_id) + + # check whether the tool requires an active project and language server + if not isinstance(self, ToolMarkerDoesNotRequireActiveProject): + if self.agent.get_active_project() is None: + raise ToolCallError( + "No active project. Ask the user to provide the project path or to select a project from this list of known projects: " + + f"{self.agent.serena_config.project_names}" + ) + + # construct apply kwargs, adding session_id if the tool is session-aware + apply_kwargs = dict(kwargs) + if self._is_session_aware: + apply_kwargs["session_id"] = session_id + + # apply the actual tool + try: + result = apply_fn(**apply_kwargs) + except SolidLSPException as e: + if e.is_language_server_terminated(): + affected_language = e.get_affected_language() + if affected_language is not None: + log.error( + f"Language server terminated while executing tool ({e}). Restarting the language server and retrying ..." + ) + self.agent.get_language_server_manager_or_raise().restart_language_server(affected_language) + result = apply_fn(**apply_kwargs) + else: + log.error( + f"Language server terminated while executing tool ({e}), but affected language is unknown. Not retrying." + ) + raise + else: + raise + + # record tool usage + self.agent.record_tool_usage(apply_kwargs, result, self) + + except ToolCallError: + raise + except Exception as e: + msg = f"{e.__class__.__name__}: {e}" + log.error(msg, exc_info=e) + raise ToolCallError(msg) + + if log_call: + log.info(f"Result: {result}") + + try: + ls_manager = self.agent.get_language_server_manager() + if ls_manager is not None: + ls_manager.save_all_caches() + except Exception as e: + log.error(f"Error saving language server cache: {e}") + + return result + + # execute the tool in the agent's task executor, with timeout + tool_call_error: ToolCallError + try: + task_exec = self.agent.issue_task(task, name=self.__class__.__name__) + return task_exec.result(timeout=self.agent.serena_config.tool_timeout) + except ToolCallError as e: + tool_call_error = e + except Exception as e: # typically TimeoutError (other exceptions caught and forwarded as ToolCallError in the task) + msg = f"{e.__class__.__name__}: {e}" + log.error(msg) + tool_call_error = ToolCallError(msg) + if catch_exceptions: + return tool_call_error.get_error_message() + else: + raise tool_call_error + + @staticmethod + def _to_json(x: Any) -> str: + return json.dumps(x, ensure_ascii=False) + + +class EditingToolWithDiagnostics(Tool, ToolMarkerCanEdit): + """ + Base class for editing tools that want to capture and report changes in LSP diagnostics before and after the edit. + """ + + ENABLE_DIAGNOSTICS: bool = False + """ + Global flag to enable/disable diagnostics for LSP-based editing tools derived from this class. + The feature is currently disabled, because per-edit diagnostics are a questionable feature, since individual + edits often intentionally introduce diagnostics (e.g. function signature mismatches or even syntax errors) that + are then resolved in subsequent edits. + """ + + DIAGNOSTICS_KEY = "diagnostics[warning-or-higher]" + + class DiagnosticsContext: + def __init__(self, tool: "EditingToolWithDiagnostics", *edited_relative_paths: str) -> None: + self._tool = tool + self._is_diagnostics_enabled = tool.ENABLE_DIAGNOSTICS and tool.agent.is_using_language_server() + self._edited_files = [EditedFilePath(path, path) for path in edited_relative_paths] + self._before_edit_diagnostics_snapshot: PublishedDiagnosticsSnapshot | None = None + self._symbol_retriever: Optional["LanguageServerSymbolRetriever"] | None = None + if self._is_diagnostics_enabled: + self._symbol_retriever = tool.create_language_server_symbol_retriever() + self._before_edit_diagnostics_snapshot = PublishedDiagnosticsSnapshot(self._edited_files, self._symbol_retriever) + + def __enter__(self) -> Self: + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + pass + + def format_result( + self, + base_result: str, + ) -> str: + if not self._is_diagnostics_enabled: + return base_result + + if self._before_edit_diagnostics_snapshot is None: + return base_result + + assert self._symbol_retriever is not None + diagnostics_diff = DiagnosticsDiff(self._before_edit_diagnostics_snapshot, self._edited_files, self._symbol_retriever) + grouped_diagnostics = diagnostics_diff.get_grouped_diagnostics().get_dict() + + if not grouped_diagnostics: + return base_result + else: + result_dict = { + "result": base_result, + EditingToolWithDiagnostics.DIAGNOSTICS_KEY: grouped_diagnostics, + } + return self._tool._to_json(result_dict) + + +class EditedFileContext: + """ + Context manager for file editing. + + Create the context, then use `set_updated_content` to set the new content, the original content + being provided in `original_content`. + When exiting the context without an exception, the updated content will be written back to the file. + """ + + def __init__(self, relative_path: str, code_editor: "CodeEditor"): + self._relative_path = relative_path + self._code_editor = code_editor + self._edited_file: CodeEditor.EditedFile | None = None + self._edited_file_context: Any = None + + def __enter__(self) -> Self: + self._edited_file_context = self._code_editor.edited_file_context(self._relative_path) + self._edited_file = self._edited_file_context.__enter__() + return self + + def get_original_content(self) -> str: + """ + :return: the original content of the file before any modifications. + """ + assert self._edited_file is not None + return self._edited_file.get_contents() + + def set_updated_content(self, content: str) -> None: + """ + Sets the updated content of the file, which will be written back to the file + when the context is exited without an exception. + + :param content: the updated content of the file + """ + assert self._edited_file is not None + self._edited_file.set_contents(content) + + def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None) -> None: + assert self._edited_file_context is not None + self._edited_file_context.__exit__(exc_type, exc_value, traceback) + + +@dataclass(kw_only=True) +class RegisteredTool: + tool_class: type[Tool] + is_optional: bool + is_beta: bool + tool_name: str + + @property + def class_docstring(self) -> str: + """ + :return: the tool description (high-level class docstring) + """ + return self.tool_class.get_tool_description() + + +tool_packages = ["serena.tools"] + + +@singleton +class ToolRegistry: + _deleted_tools: list[str] = [ + "think_about_collected_information", + "prepare_for_new_conversation", + "summarize_changes", + "think_about_whether_you_are_done", + "switch_modes", + "check_onboarding_performed", + ] + + def __init__(self) -> None: + self._tool_dict: dict[str, RegisteredTool] = {} + inclusion_predicate = lambda c: "apply" in c.__dict__ # include only concrete tool classes that implement apply + for cls in iter_subclasses(Tool, inclusion_predicate=inclusion_predicate): + if not any(cls.__module__.startswith(pkg) for pkg in tool_packages): + continue + is_optional = issubclass(cls, ToolMarkerOptional) + is_beta = issubclass(cls, ToolMarkerBeta) + name = cls.get_name_from_cls() + if name in self._tool_dict: + raise ValueError(f"Duplicate tool name found: {name}. Tool classes must have unique names.") + self._tool_dict[name] = RegisteredTool(tool_class=cls, is_optional=is_optional, tool_name=name, is_beta=is_beta) + + def get_registered_tools_by_module(self) -> dict[str, list[RegisteredTool]]: + """ + :return: the registered tools grouped by their module (ordered alphabetically by module and tool name) + """ + module_dict: dict[str, list[RegisteredTool]] = {} + for tool in self._tool_dict.values(): + module = tool.tool_class.__module__ + if module not in module_dict: + module_dict[module] = [] + module_dict[module].append(tool) + sorted_module_dict = {} + for module in sorted(module_dict.keys()): + sorted_module_dict[module] = sorted(module_dict[module], key=lambda t: t.tool_name) + return sorted_module_dict + + def get_tool_class_by_name(self, tool_name: str) -> type[Tool]: + if tool_name not in self._tool_dict: + raise ValueError(f"Tool named '{tool_name}' not found.") + return self._tool_dict[tool_name].tool_class + + def get_all_tool_classes(self) -> list[type[Tool]]: + return list(t.tool_class for t in self._tool_dict.values()) + + def get_tool_classes_default_enabled(self) -> list[type[Tool]]: + """ + :return: the list of tool classes that are enabled by default (i.e. non-optional tools). + """ + return [t.tool_class for t in self._tool_dict.values() if not t.is_optional] + + def get_tool_classes_optional(self) -> list[type[Tool]]: + """ + :return: the list of tool classes that are optional (i.e. disabled by default). + """ + return [t.tool_class for t in self._tool_dict.values() if t.is_optional] + + def get_tool_names_default_enabled(self) -> list[str]: + """ + :return: the list of tool names that are enabled by default (i.e. non-optional tools). + """ + return [t.tool_name for t in self._tool_dict.values() if not t.is_optional] + + def get_tool_names_optional(self) -> list[str]: + """ + :return: the list of tool names that are optional (i.e. disabled by default). + """ + return [t.tool_name for t in self._tool_dict.values() if t.is_optional] + + def get_tool_names(self) -> list[str]: + """ + :return: the list of all tool names. + """ + return list(self._tool_dict.keys()) + + def print_tool_overview( + self, tools: Iterable[type[Tool] | Tool] | None = None, include_optional: bool = False, only_optional: bool = False + ) -> None: + """ + Print a summary of the tools. If no tools are passed, a summary of the selection of tools (all, default or only optional) is printed. + """ + if tools is None: + if only_optional: + tools = self.get_tool_classes_optional() + elif include_optional: + tools = self.get_all_tool_classes() + else: + tools = self.get_tool_classes_default_enabled() + + tool_dict: dict[str, type[Tool] | Tool] = {} + for tool_class in tools: + tool_dict[tool_class.get_name_from_cls()] = tool_class + for tool_name in sorted(tool_dict.keys()): + tool_class = tool_dict[tool_name] + print(f" * `{tool_name}`: {tool_class.get_tool_description().strip()}") + + def is_valid_tool_name(self, tool_name: str) -> bool: + return tool_name in self._tool_dict + + def check_valid_tool_name(self, tool_name: str, caller_context_for_logging: str = "") -> bool: + """Returns True if the tool name is valid, False if it is deleted, and raises ValueError if it is invalid.""" + if self.is_deleted_tool_name(tool_name): + log.warning(f"Tool name is deleted: {tool_name}{caller_context_for_logging}") + return False + if not self.is_valid_tool_name(tool_name): + raise ValueError(f"Invalid tool name: {tool_name}{caller_context_for_logging}") + return True + + def is_deleted_tool_name(self, tool_name: str) -> bool: + return tool_name in self._deleted_tools diff --git a/src/serena/tools/workflow_tools.py b/src/serena/tools/workflow_tools.py new file mode 100644 index 0000000..6a0f51d --- /dev/null +++ b/src/serena/tools/workflow_tools.py @@ -0,0 +1,63 @@ +""" +Tools supporting the general workflow of the agent +""" + +import platform + +from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional, WriteMemoryTool + + +class OnboardingTool(Tool): + """ + Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). + """ + + def apply(self) -> str: + """ + Call this tool if onboarding was not performed yet. + You will call this tool at most once per conversation. + + :return: instructions on how to create the onboarding information + """ + write_memory_tool_available = self.agent.tool_is_exposed(WriteMemoryTool.get_name_from_cls()) + if not write_memory_tool_available: + return "Memory writing tool not activated, skipping onboarding." + system = platform.system() + # seed the project-local memory-maintenance memory (or detect a global override) so + # the prompt can point the agent at the conventions before it writes anything + memory_maintenance_name = self.memory_manager.ensure_memory_maintenance_memory() + return self.prompt_factory.create_onboarding_prompt(system=system, memory_maintenance_name=memory_maintenance_name) + + +class InitialInstructionsTool(Tool, ToolMarkerDoesNotRequireActiveProject): + """ + Provides instructions Serena usage (i.e. the 'Serena Instructions Manual') + for clients that do not read the initial instructions when the MCP server is connected. + """ + + # noinspection PyIncorrectDocstring + # (session_id is injected via apply_ex) + def apply(self, session_id: str) -> str: + """ + Provides the 'Serena Instructions Manual', which contains essential information on how to use the Serena toolbox. + IMPORTANT: If you have not yet read the manual, call this tool immediately after you are given your task by the user, + as it will critically inform you! + """ + return self.agent.create_system_prompt(session_id=session_id) + + +class SerenaInfoTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveProject): + """ + Provides information about an advanced topic on demand, facilitating context-efficiency. + """ + + def apply(self, topic: str) -> str: + """ + Retrieves Serena-specific information + :param topic: the topic, which you must have been given explicitly + """ + match topic: + case "jet_brains_debug_repl": + return self.agent.prompt_factory.create_info_jet_brains_debug_repl() + case _: + raise ValueError("Invalid topic: " + topic) diff --git a/src/serena/util/class_decorators.py b/src/serena/util/class_decorators.py new file mode 100644 index 0000000..6beadbd --- /dev/null +++ b/src/serena/util/class_decorators.py @@ -0,0 +1,15 @@ +from typing import Any + + +# duplicate of interprompt.class_decorators +# We don't want to depend on interprompt for this in serena, so we duplicate it here +def singleton(cls: type[Any]) -> Any: + instance = None + + def get_instance(*args: Any, **kwargs: Any) -> Any: + nonlocal instance + if instance is None: + instance = cls(*args, **kwargs) + return instance + + return get_instance diff --git a/src/serena/util/cli_util.py b/src/serena/util/cli_util.py new file mode 100644 index 0000000..807ae7c --- /dev/null +++ b/src/serena/util/cli_util.py @@ -0,0 +1,36 @@ +import click + + +def ask_yes_no(question: str, default: bool | None = None) -> bool: + default_prompt = "Y/n" if default else "y/N" + + while True: + answer = input(f"{question} [{default_prompt}] ").strip().lower() + if answer == "" and default is not None: + return default + if answer in ("y", "yes"): + return True + if answer in ("n", "no"): + return False + print("Please answer yes/y or no/n.") + + +class AutoRegisteringGroup(click.Group): + """ + A click.Group subclass that automatically registers any click.Command + attributes defined on the class into the group. + + After initialization, it inspects its own class for attributes that are + instances of click.Command (typically created via @click.command) and + calls self.add_command(cmd) on each. This lets you define your commands + as static methods on the subclass for IDE-friendly organization without + manual registration. + """ + + def __init__(self, name: str, help: str): + super().__init__(name=name, help=help) + # Scan class attributes for click.Command instances and register them. + for attr in dir(self.__class__): + cmd = getattr(self.__class__, attr) + if isinstance(cmd, click.Command): + self.add_command(cmd) diff --git a/src/serena/util/dataclass.py b/src/serena/util/dataclass.py new file mode 100644 index 0000000..c5518bf --- /dev/null +++ b/src/serena/util/dataclass.py @@ -0,0 +1,21 @@ +from dataclasses import MISSING, Field +from typing import Any, cast + + +def get_dataclass_default(cls: type, field_name: str) -> Any: + """ + Gets the default value of a dataclass field. + + :param cls: The dataclass type. + :param field_name: The name of the field. + :return: The default value of the field (either from default or default_factory). + """ + field = cast(Field, cls.__dataclass_fields__[field_name]) # type: ignore[attr-defined] + + if field.default is not MISSING: + return field.default + + if field.default_factory is not MISSING: # default_factory is a function + return field.default_factory() + + raise AttributeError(f"{field_name} has no default") diff --git a/src/serena/util/dotnet.py b/src/serena/util/dotnet.py new file mode 100644 index 0000000..99b3452 --- /dev/null +++ b/src/serena/util/dotnet.py @@ -0,0 +1,156 @@ +import logging +import platform +import re +import shutil +import subprocess +import urllib +from pathlib import Path + +from serena.util.version import Version +from solidlsp.ls_exceptions import SolidLSPException + +log = logging.getLogger(__name__) + + +class DotNETUtil: + def __init__(self, required_version: str, allow_higher_version: bool = True): + """ + :param required_version: the required .NET runtime version specified as a string (e.g. "10.0" for .NET 10.0) + :param allow_higher_version: whether to allow higher versions than the required version + """ + self._system_dotnet = shutil.which("dotnet") + self._required_version_str = required_version + self._required_version_components = [int(c) for c in required_version.split(".")] + self._allow_higher_version = allow_higher_version + self._installed_versions = self._determine_installed_versions() + + def _determine_installed_versions(self) -> list[Version]: + if self._system_dotnet: + try: + result = subprocess.run([self._system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True) + version_strings = re.findall(r"Microsoft.NETCore.App\s+([^\s]+)", result.stdout) + log.info("Installed .NET runtime versions: %s", version_strings) + return [Version(v) for v in version_strings] + except: + log.warning("Failed to run 'dotnet --list-runtimes' to check .NET version; assuming no installed .NET versions") + return [] + else: + log.info("Found no `dotnet` on system PATH; assuming no installed .NET versions") + return [] + + def is_required_version_available(self) -> bool: + """ + Checks whether the required .NET runtime version is installed and raises an exception if not. + + :param required_version_components: the required .NET runtime version specified as a list of integers representing the version components (e.g., [6, 1] for .NET 6.1) + :param allow_higher_version: whether to allow higher versions than the required version (e.g., if True, .NET 7.0 would satisfy a requirement of .NET 6.1) + """ + required_version_str = ".".join(str(c) for c in self._required_version_components) + for v in self._installed_versions: + if self._allow_higher_version: + if v.is_at_least(*self._required_version_components): + log.info(f"Found installed .NET runtime version {v} which satisfies requirement of {required_version_str} or higher") + return True + else: + if v.is_equal(*self._required_version_components): + log.info(f"Found installed .NET runtime version {v} which satisfies requirement of {required_version_str}") + return True + return False + + def get_dotnet_path_or_raise(self) -> str: + """ + Returns the path to the dotnet executable if the required .NET runtime version is available, otherwise raises an exception. + """ + if not self.is_required_version_available(): + raise SolidLSPException( + f"Required .NET runtime version {self._required_version_str} not found " + f"(installed versions: {self._installed_versions}). " + "Please install the required .NET runtime version from https://dotnet.microsoft.com/en-us/download/dotnet " + "and ensure that `dotnet` is on the system PATH." + ) + assert self._system_dotnet is not None + return self._system_dotnet + + @staticmethod + def install_dotnet_with_script(version: str, base_path: str) -> str: + """ + Install .NET runtime using Microsoft's official installation script. + + NOTE: This method is unreliable and therefore currently unused. It is kept for reference. + + :version: the version to install as a string (e.g. "10.0") + :return: the path to the dotnet executable. + """ + dotnet_dir = Path(base_path) / f"dotnet-runtime-{version}" + + # Determine binary name based on platform + is_windows = platform.system().lower() == "windows" + dotnet_exe = dotnet_dir / ("dotnet.exe" if is_windows else "dotnet") + + if dotnet_exe.exists(): + log.info(f"Using cached .NET {version} runtime from {dotnet_exe}") + return str(dotnet_exe) + + # Download and run install script + log.info(f"Installing .NET {version} runtime using official Microsoft install script...") + dotnet_dir.mkdir(parents=True, exist_ok=True) + + try: + if is_windows: + # PowerShell script for Windows + script_url = "https://dot.net/v1/dotnet-install.ps1" + script_path = dotnet_dir / "dotnet-install.ps1" + urllib.request.urlretrieve(script_url, script_path) + + cmd = [ + "pwsh", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(script_path), + "-Version", + version, + "-InstallDir", + str(dotnet_dir), + "-Runtime", + "dotnet", + "-NoPath", + ] + else: + # Bash script for Linux/macOS + script_url = "https://dot.net/v1/dotnet-install.sh" + script_path = dotnet_dir / "dotnet-install.sh" + urllib.request.urlretrieve(script_url, script_path) + script_path.chmod(0o755) + + cmd = [ + "bash", + str(script_path), + "--version", + version, + "--install-dir", + str(dotnet_dir), + "--runtime", + "dotnet", + "--no-path", + ] + + # Run the install script + log.info("Running .NET install script: %s", cmd) + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + log.debug(f"Install script output: {result.stdout}") + + if not dotnet_exe.exists(): + raise SolidLSPException(f"dotnet executable not found at {dotnet_exe} after installation") + + log.info(f"Successfully installed .NET {version} runtime to {dotnet_exe}") + return str(dotnet_exe) + + except subprocess.CalledProcessError as e: + raise SolidLSPException(f"Failed to install .NET {version} runtime using install script: {e.stderr if e.stderr else e}") from e + except Exception as e: + message = f"Failed to install .NET {version} runtime: {e}" + if is_windows and isinstance(e, FileNotFoundError): + message += "; pwsh, i.e. PowerShell 7+, is required to install .NET runtime. Make sure pwsh is available on your system." + raise SolidLSPException(message) from e diff --git a/src/serena/util/exception.py b/src/serena/util/exception.py new file mode 100644 index 0000000..8ab2f00 --- /dev/null +++ b/src/serena/util/exception.py @@ -0,0 +1,65 @@ +import os +import sys + +from serena.agent import log + + +def is_headless_environment() -> bool: + """ + Detect if we're running in a headless environment where GUI operations would fail. + + Returns True if: + - No DISPLAY variable on Linux/Unix + - Running in SSH session + - Running in WSL without X server + - Running in Docker container + """ + # Check if we're on Windows - GUI usually works there + if sys.platform == "win32": + return False + + # Check for DISPLAY variable (required for X11) + if not os.environ.get("DISPLAY"): + return True + + # Check for SSH session + if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_CLIENT"): + return True + + # Check for common CI/container environments + if os.environ.get("CI") or os.environ.get("CONTAINER") or os.path.exists("/.dockerenv"): + return True + + # Check for WSL (only on Unix-like systems where os.uname exists) + if hasattr(os, "uname"): + if "microsoft" in os.uname().release.lower(): + # In WSL, even with DISPLAY set, X server might not be running + # This is a simplified check - could be improved + return True + + return False + + +def show_fatal_exception_safe(e: Exception) -> None: + """ + Shows the given exception in the GUI log viewer on the main thread and ensures that the exception is logged or at + least printed to stderr. + """ + # Log the error and print it to stderr + log.error(f"Fatal exception: {e}", exc_info=e) + print(f"Fatal exception: {e}", file=sys.stderr) + + # Don't attempt GUI in headless environments + if is_headless_environment(): + log.debug("Skipping GUI error display in headless environment") + return + + # attempt to show the error in the GUI + try: + # NOTE: The import can fail on macOS if Tk is not available (depends on Python interpreter installation, which uv + # used as a base); while tkinter as such is always available, its dependencies can be unavailable on macOS. + from serena.gui_log_viewer import show_fatal_exception + + show_fatal_exception(e) + except Exception as gui_error: + log.debug(f"Failed to show GUI error dialog: {gui_error}") diff --git a/src/serena/util/file_proxy.py b/src/serena/util/file_proxy.py new file mode 100644 index 0000000..53685dc --- /dev/null +++ b/src/serena/util/file_proxy.py @@ -0,0 +1,91 @@ +import logging +import os +from abc import ABC, abstractmethod +from collections.abc import Iterator +from typing import TYPE_CHECKING, Self + +if TYPE_CHECKING: + from serena.project import Project + +log = logging.getLogger(__name__) + + +class FileProxy(ABC): + @abstractmethod + def get_contents(self) -> str: + """:return: the contents of the file as a string.""" + + @abstractmethod + def get_relative_path(self) -> str: + """:return: the relative path reported by Serena (actual relative path or encoded external path)""" + + @abstractmethod + def is_glob_supported(self): + """ + :return: whether the proxy supports glob filtering based on its relative path + """ + + +class LocalProjectFileProxy(FileProxy): + def __init__(self, relative_path: str, project: "Project"): + self._relative_path = relative_path + self._project = project + + def get_contents(self) -> str: + abs_path = os.path.join(self._project.project_root, self._relative_path) + with open(abs_path, encoding=self._project.project_config.encoding) as f: + return f.read() + + def get_relative_path(self) -> str: + return self._relative_path + + def is_glob_supported(self): + return True + + +class FileCollection: + def __init__(self, file_proxies: list[FileProxy]): + self._file_proxies = file_proxies + + def __len__(self) -> int: + return len(self._file_proxies) + + def __iter__(self) -> Iterator[FileProxy]: + return iter(self._file_proxies) + + @classmethod + def from_local_project_paths(cls, relative_paths: list[str], project: "Project") -> Self: + return cls([LocalProjectFileProxy(path, project) for path in relative_paths]) + + def filter_glob(self, paths_include_glob: str | None = None, paths_exclude_glob: str | None = None) -> "FileCollection": + """ + Filters the collection based on the given patterns. + Note: Filtering is applied only to local project files. Other files are always retained. + + :param paths_include_glob: optional glob pattern to include files from the list + :param paths_exclude_glob: optional glob pattern to exclude files from the list + :return: the filtered collection + """ + from serena.util.text_utils import GlobMatcher + + if paths_include_glob is None and paths_exclude_glob is None: + return self + + include_glob_matcher = GlobMatcher(paths_include_glob) if paths_include_glob else None + exclude_glob_matcher = GlobMatcher(paths_exclude_glob) if paths_exclude_glob else None + + filtered_files = [] + for f in self._file_proxies: + if f.is_glob_supported(): + path = f.get_relative_path() + if include_glob_matcher: + if not include_glob_matcher.matches(path): + log.debug(f"Skipping {path}: does not match include pattern {paths_include_glob}") + continue + if exclude_glob_matcher: + if exclude_glob_matcher.matches(path): + log.debug(f"Skipping {path}: matches exclude pattern {paths_exclude_glob}") + continue + filtered_files.append(f) + + return FileCollection(filtered_files) diff --git a/src/serena/util/file_system.py b/src/serena/util/file_system.py new file mode 100644 index 0000000..06cedc8 --- /dev/null +++ b/src/serena/util/file_system.py @@ -0,0 +1,374 @@ +import logging +import os +from collections.abc import Callable, Iterator +from dataclasses import dataclass, field +from pathlib import Path +from typing import NamedTuple + +import pathspec +from pathspec import PathSpec +from sensai.util.logging import LogTime + +log = logging.getLogger(__name__) + + +class ScanResult(NamedTuple): + """Result of scanning a directory.""" + + directories: list[str] + files: list[str] + + +def scan_directory( + path: str, + recursive: bool = False, + relative_to: str | None = None, + is_ignored_dir: Callable[[str], bool] | None = None, + is_ignored_file: Callable[[str], bool] | None = None, +) -> ScanResult: + """ + :param path: the path to scan + :param recursive: whether to recursively scan subdirectories + :param relative_to: the path to which the results should be relative to; if None, provide absolute paths + :param is_ignored_dir: a function with which to determine whether the given directory (abs. path) shall be ignored + :param is_ignored_file: a function with which to determine whether the given file (abs. path) shall be ignored + :return: the list of directories and files + """ + if is_ignored_file is None: + is_ignored_file = lambda x: False + if is_ignored_dir is None: + is_ignored_dir = lambda x: False + + files = [] + directories = [] + + abs_path = os.path.abspath(path) + rel_base = os.path.abspath(relative_to) if relative_to else None + + try: + with os.scandir(abs_path) as entries: + for entry in entries: + try: + entry_path = entry.path + + if rel_base: + try: + result_path = os.path.relpath(entry_path, rel_base) + except: + log.debug(f"Skipping entry due to relative path conversion error: {entry.path}") + continue + else: + result_path = entry_path + + if entry.is_file(): + if not is_ignored_file(entry_path): + files.append(result_path) + elif entry.is_dir(): + if not is_ignored_dir(entry_path): + directories.append(result_path) + if recursive: + sub_result = scan_directory( + entry_path, + recursive=True, + relative_to=relative_to, + is_ignored_dir=is_ignored_dir, + is_ignored_file=is_ignored_file, + ) + files.extend(sub_result.files) + directories.extend(sub_result.directories) + except PermissionError as ex: + # Skip files/directories that cannot be accessed due to permission issues + log.debug(f"Skipping entry due to permission error: {entry.path}", exc_info=ex) + continue + except PermissionError as ex: + # Skip the entire directory if it cannot be accessed + log.debug(f"Skipping directory due to permission error: {abs_path}", exc_info=ex) + return ScanResult([], []) + + return ScanResult(directories, files) + + +def find_all_non_ignored_files(repo_root: str) -> list[str]: + """ + Find all non-ignored files in the repository, respecting all gitignore files in the repository. + + :param repo_root: The root directory of the repository + :return: A list of all non-ignored files in the repository + """ + gitignore_parser = GitignoreParser(repo_root) + _, files = scan_directory( + repo_root, recursive=True, is_ignored_dir=gitignore_parser.should_ignore, is_ignored_file=gitignore_parser.should_ignore + ) + return files + + +@dataclass +class GitignoreSpec: + file_path: str + """Path to the gitignore file.""" + patterns: list[str] = field(default_factory=list) + """List of patterns from the gitignore file. + The patterns are adjusted based on the gitignore file location. + """ + pathspec: PathSpec = field(init=False) + """Compiled PathSpec object for pattern matching.""" + + def __post_init__(self) -> None: + """Initialize the PathSpec from patterns.""" + self.pathspec = PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, self.patterns) + + def matches(self, relative_path: str) -> bool: + """ + Check if the given path matches any pattern in this gitignore spec. + + :param relative_path: Path to check (should be relative to repo root) + :return: True if path matches any pattern + """ + return match_path(relative_path, self.pathspec, root_path=os.path.dirname(self.file_path)) + + +class GitignoreParser: + """ + Parser for gitignore files in a repository. + + This class handles parsing multiple gitignore files throughout a repository + and provides methods to check if paths should be ignored. + """ + + def __init__(self, repo_root: str) -> None: + """ + Initialize the parser for a repository. + + :param repo_root: Root directory of the repository + """ + self.repo_root = os.path.abspath(repo_root) + self.ignore_specs: list[GitignoreSpec] = [] + self._load_gitignore_files() + + def _load_gitignore_files(self) -> None: + """Load all gitignore files from the repository.""" + with LogTime("Loading of .gitignore files", logger=log): + for gitignore_path in self._iter_gitignore_files(): + log.info("Processing .gitignore file: %s", gitignore_path) + spec = self._create_ignore_spec(gitignore_path) + if spec.patterns: # Only add non-empty specs + self.ignore_specs.append(spec) + + def _iter_gitignore_files(self, follow_symlinks: bool = False) -> Iterator[str]: + """ + Iteratively discover .gitignore files in a top-down fashion, starting from the repository root. + Directory paths are skipped if they match any already loaded ignore patterns. + + :return: an iterator yielding paths to .gitignore files (top-down) + """ + queue: list[str] = [self.repo_root] + + def scan(abs_path: str | None) -> Iterator[str]: + try: + entries = os.scandir(abs_path) + except PermissionError as ex: + log.debug(f"Skipping unreadable directory {abs_path}: {ex}") + return + for entry in entries: + try: + if entry.is_dir(follow_symlinks=follow_symlinks): + queue.append(entry.path) + elif entry.is_file(follow_symlinks=follow_symlinks) and entry.name == ".gitignore": + yield entry.path + except PermissionError as ex: + log.debug(f"Skipping entry due to permission error: {entry.path}", exc_info=ex) + continue + except FileNotFoundError as ex: + log.debug(f"Skipping entry due to file not found error (possibly a broken link): {entry.path}", exc_info=ex) + continue + + while queue: + next_abs_path = queue.pop(0) + if next_abs_path != self.repo_root: + try: + rel_path = os.path.relpath(next_abs_path, self.repo_root) + except ValueError: + # If the path is on a different drive (Windows) or cannot be made relative for another reason, we ignore it + continue + if self.should_ignore(rel_path): + continue + yield from scan(next_abs_path) + + def _create_ignore_spec(self, gitignore_file_path: str) -> GitignoreSpec: + """ + Create a GitignoreSpec from a single gitignore file. + + :param gitignore_file_path: Path to the .gitignore file + :return: GitignoreSpec object for the gitignore patterns + """ + try: + with open(gitignore_file_path, encoding="utf-8") as f: + content = f.read() + except (OSError, UnicodeDecodeError): + # If we can't read the file, return an empty spec + return GitignoreSpec(gitignore_file_path, []) + + gitignore_dir = os.path.dirname(gitignore_file_path) + patterns = self._parse_gitignore_content(content, gitignore_dir) + + return GitignoreSpec(gitignore_file_path, patterns) + + def _parse_gitignore_content(self, content: str, gitignore_dir: str) -> list[str]: + """ + Parse gitignore content and adjust patterns based on the gitignore file location. + + :param content: Content of the .gitignore file + :param gitignore_dir: Directory containing the .gitignore file (absolute path) + :return: List of adjusted patterns + """ + patterns = [] + + # Get the relative path from repo root to the gitignore directory + rel_dir = os.path.relpath(gitignore_dir, self.repo_root) + if rel_dir == ".": + rel_dir = "" + + for line in content.splitlines(): + # Strip trailing whitespace (but preserve leading whitespace for now) + line = line.rstrip() + + # Skip empty lines and comments + if not line or line.lstrip().startswith("#"): + continue + + # Store whether this is a negation pattern + is_negation = line.startswith("!") + if is_negation: + line = line[1:] + + # Strip leading/trailing whitespace after removing negation + line = line.strip() + + if not line: + continue + + # Handle escaped characters at the beginning + if line.startswith(("\\#", "\\!")): + line = line[1:] + + # Determine if pattern is anchored to the gitignore directory and remove leading slash for processing + is_anchored = line.startswith("/") + if is_anchored: + line = line[1:] + + # Adjust pattern based on gitignore file location + if rel_dir: + if is_anchored: + # Anchored patterns are relative to the gitignore directory + adjusted_pattern = os.path.join(rel_dir, line) + else: + # Non-anchored patterns can match anywhere below the gitignore directory + # We need to preserve this behavior + if line.startswith("**/"): + # Even if pattern starts with **, it should still be scoped to the subdirectory + adjusted_pattern = os.path.join(rel_dir, line) + else: + # Add the directory prefix but also allow matching in subdirectories + adjusted_pattern = os.path.join(rel_dir, "**", line) + else: + if is_anchored: + # Anchored patterns in root should only match at root level + # Add leading slash back to indicate root-only matching + adjusted_pattern = "/" + line + else: + # Non-anchored patterns can match anywhere + adjusted_pattern = line + + # Re-add negation if needed + if is_negation: + adjusted_pattern = "!" + adjusted_pattern + + # Normalize path separators to forward slashes (gitignore uses forward slashes) + adjusted_pattern = adjusted_pattern.replace(os.sep, "/") + + patterns.append(adjusted_pattern) + + return patterns + + def should_ignore(self, path: str) -> bool: + """ + Check if a path should be ignored based on the gitignore rules. + + :param path: Path to check (absolute or relative to repo_root) + :return: True if the path should be ignored, False otherwise + """ + # Convert to relative path from repo root + if os.path.isabs(path): + try: + rel_path = os.path.relpath(path, self.repo_root) + except Exception as e: + # If the path could not be converted to a relative path, + # it is outside the repository root, so we ignore it + log.info("Ignoring path '%s' which is outside of the repository root (%s)", path, e) + return True + else: + rel_path = path + + # Ignore paths inside .git + rel_path_first_path = Path(rel_path).parts[0] + if rel_path_first_path == ".git": + return True + + abs_path = os.path.join(self.repo_root, rel_path) + + # Normalize path separators + rel_path = rel_path.replace(os.sep, "/") + + if os.path.exists(abs_path) and os.path.isdir(abs_path) and not rel_path.endswith("/"): + rel_path = rel_path + "/" + + # Check against each ignore spec + for spec in self.ignore_specs: + if spec.matches(rel_path): + return True + + return False + + def get_ignore_specs(self) -> list[GitignoreSpec]: + """ + Get all loaded gitignore specs. + + :return: List of GitignoreSpec objects + """ + return self.ignore_specs + + def reload(self) -> None: + """Reload all gitignore files from the repository.""" + self.ignore_specs.clear() + self._load_gitignore_files() + + +def match_path(relative_path: str, path_spec: PathSpec, root_path: str = "") -> bool: + """ + Match a relative path against a given pathspec. Just pathspec.match_file() is not enough, + we need to do some massaging to fix issues with pathspec matching. + + :param relative_path: relative path to match against the pathspec + :param path_spec: the pathspec to match against + :param root_path: the root path from which the relative path is derived + :return: + """ + if str(relative_path) in {"", "."}: + return False + + normalized_path = str(relative_path).replace(os.path.sep, "/") + + # We can have patterns like /src/..., which would only match corresponding paths from the repo root + # Unfortunately, pathspec can't know whether a relative path is relative to the repo root or not, + # so it will never match src/... + # The fix is to just always assume that the input path is relative to the repo root and to + # prefix it with /. + if not normalized_path.startswith("/"): + normalized_path = "/" + normalized_path + + # pathspec can't handle the matching of directories if they don't end with a slash! + # see https://github.com/cpburnz/python-pathspec/issues/89 + abs_path = os.path.abspath(os.path.join(root_path, relative_path)) + if os.path.isdir(abs_path) and not normalized_path.endswith("/"): + normalized_path = normalized_path + "/" + return path_spec.match_file(normalized_path) diff --git a/src/serena/util/git.py b/src/serena/util/git.py new file mode 100644 index 0000000..ec88179 --- /dev/null +++ b/src/serena/util/git.py @@ -0,0 +1,22 @@ +import logging + +from sensai.util.git import GitStatus + +from ..constants import REPO_ROOT +from .shell import subprocess_check_output + +log = logging.getLogger(__name__) + + +def get_git_status() -> GitStatus | None: + try: + cwd = REPO_ROOT + commit_hash = subprocess_check_output(["git", "rev-parse", "HEAD"], cwd=cwd) + unstaged = bool(subprocess_check_output(["git", "diff", "--name-only"], cwd=cwd)) + staged = bool(subprocess_check_output(["git", "diff", "--staged", "--name-only"], cwd=cwd)) + untracked = bool(subprocess_check_output(["git", "ls-files", "--others", "--exclude-standard"], cwd=cwd)) + return GitStatus( + commit=commit_hash, has_unstaged_changes=unstaged, has_staged_uncommitted_changes=staged, has_untracked_files=untracked + ) + except: + return None diff --git a/src/serena/util/gui.py b/src/serena/util/gui.py new file mode 100644 index 0000000..f87236d --- /dev/null +++ b/src/serena/util/gui.py @@ -0,0 +1,22 @@ +import os +import platform + + +def system_has_usable_display() -> bool: + system = platform.system() + + # macOS and native Windows: assume display is available for desktop usage + if system == "Darwin" or system == "Windows": + return True + + # Other systems, assumed to be Unix-like (Linux, FreeBSD, Cygwin/MSYS, etc.): + # detect display availability since users may operate in CLI contexts + else: + # Check X11 or Wayland - if environment variables are set to non-empty values, assume display is usable + display = os.environ.get("DISPLAY", "") + wayland_display = os.environ.get("WAYLAND_DISPLAY", "") + + if display or wayland_display: + return True + + return False diff --git a/src/serena/util/inspection.py b/src/serena/util/inspection.py new file mode 100644 index 0000000..440d7c5 --- /dev/null +++ b/src/serena/util/inspection.py @@ -0,0 +1,70 @@ +import logging +import os +from collections.abc import Callable, Iterator +from typing import TypeVar + +from serena.util.file_system import find_all_non_ignored_files +from solidlsp.ls_config import Language + +T = TypeVar("T") + +log = logging.getLogger(__name__) + + +def iter_subclasses( + cls: type[T], recursive: bool = True, inclusion_predicate: Callable[[type[T]], bool] = lambda t: True +) -> Iterator[type[T]]: + """Iterate over all subclasses of a class. + + :param cls: The class whose subclasses to iterate over. + :param recursive: If True, also iterate over all subclasses of all subclasses. + :param inclusion_predicate: a predicate function to decide whether to include a subclass in the result + """ + for subclass in cls.__subclasses__(): + if inclusion_predicate(subclass): + yield subclass + if recursive: + yield from iter_subclasses(subclass, recursive, inclusion_predicate) + + +def determine_programming_language_composition(repo_path: str) -> dict[Language, float]: + """ + Determine the programming language composition of a repository. + + Percentages are computed relative to the number of files that match at least + one supported language, not the total file count. This prevents files that + belong to no supported language (images, plain text, licenses, lock files, etc.) + from diluting language percentages in repositories where such files dominate. + + :param repo_path: Path to the repository to analyze + :return: Dictionary mapping languages to percentages of recognised source files + matching each language (denominator = files matched by at least one language) + """ + all_files = find_all_non_ignored_files(repo_path) + + if not all_files: + return {} + + # collect all language matchers once + all_languages = list(Language.iter_all(include_experimental=False)) + matchers = {lang: lang.get_source_fn_matcher() for lang in all_languages} + + # count files per language in a single pass over the files + language_counts: dict[Language, int] = {} + recognised_files = 0 + for file_path in all_files: + # Use just the filename for matching, not the full path + filename = os.path.basename(file_path) + matched_any = False + for lang, matcher in matchers.items(): + if matcher.is_relevant_filename(filename): + language_counts[lang] = language_counts.get(lang, 0) + 1 + matched_any = True + if matched_any: + recognised_files += 1 + + if recognised_files == 0: + return {} + + # convert to percentages relative to recognised source files only + return {lang: round(count / recognised_files * 100, 2) for lang, count in language_counts.items()} diff --git a/src/serena/util/logging.py b/src/serena/util/logging.py new file mode 100644 index 0000000..279f955 --- /dev/null +++ b/src/serena/util/logging.py @@ -0,0 +1,152 @@ +import queue +import threading +from collections.abc import Callable +from dataclasses import dataclass +from typing import Optional + +from sensai.util import logging + +from serena.constants import LOG_MESSAGES_BUFFER_SIZE, SERENA_LOG_FORMAT + +lg = logging + + +@dataclass +class LogMessages: + messages: list[str] + """ + the list of log messages, ordered from oldest to newest + """ + max_idx: int + """ + the 0-based index of the last message in `messages` (in the full log history) + """ + + +class MemoryLogHandler(logging.Handler): + def __init__(self, level: int = logging.NOTSET, max_messages: int | None = LOG_MESSAGES_BUFFER_SIZE) -> None: + super().__init__(level=level) + self.setFormatter(logging.Formatter(SERENA_LOG_FORMAT)) + self._log_buffer = LogBuffer(max_messages=max_messages) + self._log_queue: queue.Queue[str] = queue.Queue() + self._stop_event = threading.Event() + self._emit_callbacks: list[Callable[[str], None]] = [] + + # start background thread to process logs + self.worker_thread = threading.Thread(target=self._process_queue, daemon=True) + self.worker_thread.start() + + def add_emit_callback(self, callback: Callable[[str], None]) -> None: + """ + Adds a callback that will be called with each log message. + The callback should accept a single string argument (the log message). + """ + self._emit_callbacks.append(callback) + + def emit(self, record: logging.LogRecord) -> None: + msg = self.format(record) + self._log_queue.put_nowait(msg) + + def _process_queue(self) -> None: + while not self._stop_event.is_set(): + try: + msg = self._log_queue.get(timeout=1) + self._log_buffer.append(msg) + for callback in self._emit_callbacks: + try: + callback(msg) + except: + pass + self._log_queue.task_done() + except queue.Empty: + continue + + def get_log_messages(self, from_idx: int = 0) -> LogMessages: + return self._log_buffer.get_log_messages(from_idx=from_idx) + + def clear_log_messages(self) -> None: + self._log_buffer.clear() + + +class LogBuffer: + """ + A thread-safe buffer for storing (an optionally limited number of) log messages. + """ + + def __init__(self, max_messages: int | None = None) -> None: + self._max_messages = max_messages + self._log_messages: list[str] = [] + self._lock = threading.Lock() + self._max_idx = -1 + """ + the 0-based index of the most recently added log message + """ + + def append(self, msg: str) -> None: + with self._lock: + self._log_messages.append(msg) + self._max_idx += 1 + if self._max_messages is not None and len(self._log_messages) > self._max_messages: + excess = len(self._log_messages) - self._max_messages + self._log_messages = self._log_messages[excess:] + + def clear(self) -> None: + with self._lock: + self._log_messages = [] + self._max_idx = -1 + + def get_log_messages(self, from_idx: int = 0) -> LogMessages: + """ + :param from_idx: the 0-based index of the first log message to return. + If from_idx is less than or equal to the index of the oldest message in the buffer, + then all messages in the buffer will be returned. + :return: the list of messages + """ + from_idx = max(from_idx, 0) + with self._lock: + first_stored_idx = self._max_idx - len(self._log_messages) + 1 + if from_idx <= first_stored_idx: + messages = self._log_messages.copy() + else: + start_idx = from_idx - first_stored_idx + messages = self._log_messages[start_idx:].copy() + return LogMessages(messages=messages, max_idx=self._max_idx) + + +class SuspendedLoggersContext: + """A context manager that provides an isolated logging environment. + + Temporarily removes all root log handlers upon entry, providing a clean slate + for defining new log handlers within the context. Upon exit, restores the original + logging configuration. This is useful when you need to temporarily configure + an isolated logging setup with well-defined log handlers. + + The context manager: + - Removes all existing (root) log handlers on entry + - Allows defining new temporary handlers within the context + - Restores the original configuration (handlers and root log level) on exit + + Example: + >>> with SuspendedLoggersContext(): + ... # No handlers are active here (configure your own and set desired log level) + ... pass + >>> # Original log handlers are restored here + + """ + + def __init__(self) -> None: + self.saved_root_handlers: list = [] + self.saved_root_level: Optional[int] = None + + def __enter__(self) -> "SuspendedLoggersContext": + root_logger = lg.getLogger() + self.saved_root_handlers = root_logger.handlers.copy() + self.saved_root_level = root_logger.level + root_logger.handlers.clear() + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + root_logger = lg.getLogger() + root_logger.handlers = self.saved_root_handlers + if self.saved_root_level is not None: + root_logger.setLevel(self.saved_root_level) diff --git a/src/serena/util/ls_diagnostics.py b/src/serena/util/ls_diagnostics.py new file mode 100644 index 0000000..13b505e --- /dev/null +++ b/src/serena/util/ls_diagnostics.py @@ -0,0 +1,203 @@ +import json +from collections.abc import Iterable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from solidlsp import ls_types +from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity + +if TYPE_CHECKING: + from serena.symbol import LanguageServerSymbolRetriever + + +@dataclass(frozen=True) +class EditedFilePath: + before_relative_path: str + after_relative_path: str + + +@dataclass(frozen=True) +class DiagnosticIdentity: + message: str + start_line: int + start_character: int + end_line: int + end_character: int + severity: int | None + code_repr: str | None + source: str | None + + @classmethod + def from_diagnostic(cls, diagnostic: ls_types.Diagnostic) -> "DiagnosticIdentity": + diagnostic_range = diagnostic["range"] + start = diagnostic_range["start"] + end = diagnostic_range["end"] + return DiagnosticIdentity( + message=diagnostic["message"], + start_line=start["line"], + start_character=start["character"], + end_line=end["line"], + end_character=end["character"], + severity=diagnostic.get("severity"), + code_repr=cls._diagnostic_code_repr(diagnostic.get("code")), + source=diagnostic.get("source"), + ) + + @staticmethod + def _diagnostic_code_repr(code: Any) -> str | None: + if code is None: + return None + try: + return json.dumps(code, sort_keys=True, ensure_ascii=False) + except TypeError: + return repr(code) + + +class PublishedDiagnosticsSnapshot: + def __init__(self, edited_file_paths: Iterable[EditedFilePath], symbol_retriever: "LanguageServerSymbolRetriever"): + generation_by_after_path: dict[str, int] = {} + warning_identities_by_before_path: dict[str, set[DiagnosticIdentity]] = {} + + for edited_file_path in edited_file_paths: + try: + language_server = symbol_retriever.get_language_server(edited_file_path.after_relative_path) + except Exception: + continue + + generation_by_after_path[edited_file_path.after_relative_path] = language_server.get_published_diagnostics_generation( + edited_file_path.after_relative_path + ) + + cached_diagnostics = language_server.get_cached_published_text_document_diagnostics( + edited_file_path.before_relative_path, + min_severity=2, + ) + if cached_diagnostics is None: + try: + cached_diagnostics = language_server.request_text_document_diagnostics( + edited_file_path.before_relative_path, + min_severity=2, + ) + except Exception: + cached_diagnostics = [] + warning_identities_by_before_path[edited_file_path.before_relative_path] = { + DiagnosticIdentity.from_diagnostic(diagnostic) for diagnostic in cached_diagnostics or [] + } + + self.generation_by_after_path = generation_by_after_path + self.warning_identities_by_before_path = warning_identities_by_before_path + + +class GroupedDiagnostics: + def __init__(self) -> None: + self._grouped_diagnostics: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {} + + def add(self, relative_path: str, name_path: str, diagnostic: ls_types.Diagnostic) -> None: + severity_name = self._diagnostic_severity_name(diagnostic.get("severity")) + self._grouped_diagnostics.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append( + self._diagnostic_output_dict(diagnostic) + ) + + def get_dict(self) -> dict[str, dict[str, dict[str, list[dict[str, Any]]]]]: + """ + Returns a nested dictionary of the form: + { + relative_file_path: { + severity_name: { + name_path: [ + diagnostic_dict, + ... + ], + ... + }, + ... + }, + ... + } + where: + - relative_file_path is the relative path of the file containing the diagnostic + - severity_name is the name of the diagnostic severity (e.g. "Warning", "Error") + - name_path is the name path of the symbol that owns the diagnostic (or "" if no owner symbol was found) + - diagnostic_dict is a dictionary containing the diagnostic's message, range, and optionally code and source + """ + return self._grouped_diagnostics + + @staticmethod + def _diagnostic_severity_name(severity: int | None) -> str: + if severity is None: + return "Unknown" + try: + return DiagnosticSeverity(severity).name + except ValueError: + return f"Severity_{severity}" + + @staticmethod + def _diagnostic_output_dict(diagnostic: ls_types.Diagnostic) -> dict[str, Any]: + result: dict[str, Any] = { + "message": diagnostic["message"], + "range": diagnostic["range"], + } + if "code" in diagnostic: + result["code"] = diagnostic["code"] + if "source" in diagnostic: + result["source"] = diagnostic["source"] + return result + + +class DiagnosticsDiff: + def __init__( + self, + before_snapshot: PublishedDiagnosticsSnapshot, + edited_files: Iterable[EditedFilePath], + symbol_retriever: "LanguageServerSymbolRetriever", + ): + grouped_diagnostics = GroupedDiagnostics() + + for edited_file_path in edited_files: + try: + language_server = symbol_retriever.get_language_server(edited_file_path.after_relative_path) + except: + continue + + published_diagnostics = language_server.request_published_text_document_diagnostics( + relative_file_path=edited_file_path.after_relative_path, + after_generation=before_snapshot.generation_by_after_path.get(edited_file_path.after_relative_path, -1), + timeout=2.5, + min_severity=2, + allow_cached=True, + ) + if not published_diagnostics: + try: + published_diagnostics = language_server.request_text_document_diagnostics( + edited_file_path.after_relative_path, + min_severity=2, + ) + except: + published_diagnostics = None + if published_diagnostics is None: + continue + + existing_warning_identities = before_snapshot.warning_identities_by_before_path.get( + edited_file_path.before_relative_path, set() + ) + new_warning_identities: set[DiagnosticIdentity] = set() + + for diagnostic in published_diagnostics: + diagnostic_identity = DiagnosticIdentity.from_diagnostic(diagnostic) + if diagnostic_identity in existing_warning_identities or diagnostic_identity in new_warning_identities: + continue + new_warning_identities.add(diagnostic_identity) + + diagnostic_start = diagnostic["range"]["start"] + owner_symbol = symbol_retriever.find_diagnostic_owner_symbol( + relative_file_path=edited_file_path.after_relative_path, + line=diagnostic_start["line"], + column=diagnostic_start["character"], + ) + name_path = owner_symbol.get_name_path() if owner_symbol is not None else "" + grouped_diagnostics.add(edited_file_path.after_relative_path, name_path, diagnostic) + + self._grouped_diagnostics = grouped_diagnostics + + def get_grouped_diagnostics(self) -> GroupedDiagnostics: + return self._grouped_diagnostics diff --git a/src/serena/util/pywebview.py b/src/serena/util/pywebview.py new file mode 100644 index 0000000..6489437 --- /dev/null +++ b/src/serena/util/pywebview.py @@ -0,0 +1,233 @@ +import logging +import os +import sys +import threading +import time +from typing import Any + +import psutil +import webview +from PIL import Image + +log = logging.getLogger(__name__) + + +class WebViewWithTray: + """ + Web view window with optional system tray. + """ + + DEBUG = False + + def __init__( + self, + url: str, + *, + title: str, + start_minimized: bool = False, + width: int = 1400, + height: int = 900, + tray: bool = False, + parent_process_id: int | None = None, + app_id: str | None = None, + app_icon_path: str | None = None, + tray_icon_path: str | None = None, + ): + """ + :param url: the URL to be show in the web view + :param title: the title of the window + :param start_minimized: whether to show the window minimised on startup (if `tray` is enabled, minimised to tray) + :param width: width of the window in pixels + :param height: height of the window in pixels + :param tray: whether to enable the system tray icon and functionality. + If this is enabled, closing the window will minimise it to tray instead of quitting the application. + :param parent_process_id: the PID of the parent process to monitor; if specified, the viewer will automatically + shut itself down when the parent process exits. + :param app_id: the application ID to use for the viewer to ensure that the window is grouped separately (with separate + icon) in the taskbar + """ + self._url = url + self._use_tray = tray + self._title = title + self._start_minimized = start_minimized + self._parent_process_id = parent_process_id + self._app_id = app_id + + self.window: webview.Window + self._tray_icon: Any + self._quitting = False + self._app_icon_path = app_icon_path + self._tray_icon_path = tray_icon_path + + # Create hidden to avoid flash; show/restore/minimize in start callback. + window = webview.create_window( + self._title, + self._url, + width=width, + height=height, + hidden=self._start_minimized, + text_select=True, + zoomable=True, + ) + assert window is not None + self.window = window + + def run(self) -> None: + # set app id (avoid app being lumped together with other Python-based apps in Windows taskbar) + if self._app_id is not None and sys.platform == "win32": + import ctypes + + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("oraios.serena") + + if self._use_tray: + self.window.events.closing += self._on_closing + self._start_tray() + + def _start_callback() -> None: + if self._start_minimized: + self._hide_window() + else: + self._show_window() + + # start a thread to monitor the parent process + if self._parent_process_id is not None: + threading.Thread(target=self._monitor_parent_process, daemon=True).start() + + webview.start(_start_callback, icon=self._app_icon_path) + + def _monitor_parent_process(self) -> None: + """ + Monitors the parent process and shuts down the dashboard viewer if the parent process exits. + """ + pid = self._parent_process_id + log.info("Starting parent process monitor thread (parent_pid=%d, pid=%s)", pid, os.getpid()) + try: + parent_process = psutil.Process(pid) + while parent_process.is_running(): + time.sleep(1) + except psutil.NoSuchProcess: + pass # Parent process already exited + log.info("Parent process (pid=%d) has exited, shutting down dashboard viewer", pid) + self._terminate() + + def _show_window(self) -> None: + if not self.window: + return + + if sys.platform == "darwin": + from PyObjCTools.AppHelper import callAfter + + callAfter(self._show_window_on_macos) + else: + self.window.show() + self.window.restore() + + def _hide_window(self) -> None: + if not self.window: + return + + if sys.platform == "darwin": + from PyObjCTools.AppHelper import callAfter + + callAfter(self._hide_window_on_macos) + else: + self.window.hide() + + def _show_window_on_macos(self) -> None: + from AppKit import ( + NSApplication, + NSApplicationActivationPolicyRegular, + ) + from PyObjCTools.AppHelper import callLater + + ns_app = NSApplication.sharedApplication() + ns_app.setActivationPolicy_(NSApplicationActivationPolicyRegular) + self._set_macos_app_icon(ns_app) + ns_app.unhide_(None) + ns_app.activateIgnoringOtherApps_(True) + # Give the status item menu a beat to close before restoring the window. + callLater(0.1, self._restore_window_on_macos) + + def _restore_window_on_macos(self) -> None: + self.window.show() + self.window.restore() + + def _hide_window_on_macos(self) -> None: + from AppKit import ( + NSApplication, + NSApplicationActivationPolicyAccessory, + ) + + self.window.hide() + NSApplication.sharedApplication().setActivationPolicy_(NSApplicationActivationPolicyAccessory) + + def _set_macos_app_icon(self, ns_app: Any) -> None: + if not self._app_icon_path: + return + + from AppKit import NSImage + + ns_image = NSImage.alloc().initByReferencingFile_(self._app_icon_path) + if ns_image is not None: + ns_app.setApplicationIconImage_(ns_image) + + def _on_closing(self) -> bool: + """Intercept window close: hide window instead of quitting (macOS standard behavior).""" + if self._quitting: + return True + self._hide_window() + return False # prevent the window from actually closing + + def _terminate(self) -> None: + """ + Terminates the viewer application and the tray icon + """ + self._quitting = True + try: + if self._tray_icon: + self._tray_icon.stop() + finally: + if self.window: + self.window.destroy() + + def _start_tray(self) -> None: + # import pystray locally, because the import fails when there is no display! + import pystray + from pystray import MenuItem as Item + from pystray._base import Icon as TrayIcon + + icon_img = None + if self._tray_icon_path is not None: + icon_img = Image.open(self._tray_icon_path) + + def show(_icon: TrayIcon, _item: Item) -> None: + self._show_window() + + def hide(_icon: TrayIcon, _item: Item) -> None: + self._hide_window() + + def quit_app(_icon: TrayIcon, _item: Item) -> None: + self._terminate() + + menu = pystray.Menu( + Item("Open", show, default=True), + Item("Hide", hide), + Item("Quit", quit_app), + ) + + kwargs: dict[str, Any] = {} + if sys.platform == "darwin": + # Passing darwin_nsapplication integrates pystray with the NSApplication + # run loop that webview.start() is about to enter. sharedApplication() + # is idempotent; pywebview will reuse the same singleton. + from AppKit import NSApplication + + kwargs["darwin_nsapplication"] = NSApplication.sharedApplication() + + self._tray_icon = pystray.Icon("dashboard_viewer", icon_img, self._title, menu, **kwargs) + + # On Windows/Linux, run_detached spawns pystray's own internal thread and + # returns immediately. On macOS it hooks into the NSApplication run loop + # that webview.start() is about to enter (run_detached is always called + # before webview.start() on macOS — see run()). + self._tray_icon.run_detached() diff --git a/src/serena/util/shell.py b/src/serena/util/shell.py new file mode 100644 index 0000000..8de5a67 --- /dev/null +++ b/src/serena/util/shell.py @@ -0,0 +1,53 @@ +import os +import subprocess + +from pydantic import BaseModel + +from solidlsp.util.subprocess_util import subprocess_kwargs + + +class ShellCommandResult(BaseModel): + stdout: str + return_code: int + cwd: str + stderr: str | None = None + + +def execute_shell_command(command: str, cwd: str | None = None, capture_stderr: bool = False) -> ShellCommandResult: + """ + Execute a shell command and return the output. + + :param command: The command to execute. + :param cwd: The working directory to execute the command in. If None, the current working directory will be used. + :param capture_stderr: Whether to capture the stderr output. + :return: The output of the command. + """ + if cwd is None: + cwd = os.getcwd() + + process = subprocess.Popen( + command, + shell=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE if capture_stderr else None, + text=True, + encoding="utf-8", + errors="replace", + cwd=cwd, + **subprocess_kwargs(), + ) + + stdout, stderr = process.communicate() + return ShellCommandResult(stdout=stdout, stderr=stderr, return_code=process.returncode, cwd=cwd) + + +def subprocess_check_output( + args: list[str], encoding: str = "utf-8", strip: bool = True, timeout: float | None = None, cwd: str | None = None +) -> str: + output = subprocess.check_output( + args, stdin=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=timeout, env=os.environ.copy(), cwd=cwd, **subprocess_kwargs() + ).decode(encoding) + if strip: + output = output.strip() + return output diff --git a/src/serena/util/text_utils.py b/src/serena/util/text_utils.py new file mode 100644 index 0000000..992c243 --- /dev/null +++ b/src/serena/util/text_utils.py @@ -0,0 +1,676 @@ +import fnmatch +import hashlib +import logging +import re +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any, Literal, Self + +from bs4 import BeautifulSoup +from joblib import Parallel, delayed + +from serena.util.file_proxy import FileCollection, FileProxy +from solidlsp.ls_utils import TextUtils + +log = logging.getLogger(__name__) + + +class LineType(StrEnum): + """Enum for different types of lines in search results.""" + + MATCH = "match" + """Part of the matched lines""" + BEFORE_MATCH = "prefix" + """Lines before the match""" + AFTER_MATCH = "postfix" + """Lines after the match""" + + +@dataclass(kw_only=True) +class TextLine: + """Represents a line of text with information on how it relates to the match.""" + + line_number: int + line_content: str + match_type: LineType + """Represents the type of line (match, prefix, postfix)""" + + def get_display_prefix(self) -> str: + """Get the display prefix for this line based on the match type.""" + if self.match_type == LineType.MATCH: + return " >" + return "..." + + def format_line(self, include_line_numbers: bool = True) -> str: + """Format the line for display (e.g.,for logging or passing to an LLM). + + :param include_line_numbers: Whether to include the line number in the result. + """ + prefix = self.get_display_prefix() + if include_line_numbers: + line_num = str(self.line_number).rjust(4) + prefix = f"{prefix}{line_num}" + return f"{prefix}:{self.line_content}" + + +@dataclass(kw_only=True) +class MatchedConsecutiveLines: + """Represents a collection of consecutive lines found through some criterion in a text file or a string. + May include lines before, after, and matched. + """ + + lines: list[TextLine] + """All lines in the context of the match. At least one of them is of `match_type` `MATCH`.""" + source_file_path: str | None = None + """Path to the file where the match was found (Metadata).""" + + # set in post-init + lines_before_matched: list[TextLine] = field(default_factory=list) + matched_lines: list[TextLine] = field(default_factory=list) + lines_after_matched: list[TextLine] = field(default_factory=list) + + def __post_init__(self) -> None: + for line in self.lines: + if line.match_type == LineType.BEFORE_MATCH: + self.lines_before_matched.append(line) + elif line.match_type == LineType.MATCH: + self.matched_lines.append(line) + elif line.match_type == LineType.AFTER_MATCH: + self.lines_after_matched.append(line) + + assert len(self.matched_lines) > 0, "At least one matched line is required" + + @property + def start_line(self) -> int: + return self.lines[0].line_number + + @property + def end_line(self) -> int: + return self.lines[-1].line_number + + @property + def num_matched_lines(self) -> int: + return len(self.matched_lines) + + def to_display_string(self, include_line_numbers: bool = True) -> str: + return "\n".join([line.format_line(include_line_numbers) for line in self.lines]) + + @classmethod + def from_file_contents( + cls, file_contents: str, line: int, context_lines_before: int = 0, context_lines_after: int = 0, source_file_path: str | None = None + ) -> Self: + line_contents = file_contents.split("\n") + start_lineno = max(0, line - context_lines_before) + end_lineno = min(len(line_contents) - 1, line + context_lines_after) + text_lines: list[TextLine] = [] + # before the line + for lineno in range(start_lineno, line): + text_lines.append(TextLine(line_number=lineno, line_content=line_contents[lineno], match_type=LineType.BEFORE_MATCH)) + # the line + text_lines.append(TextLine(line_number=line, line_content=line_contents[line], match_type=LineType.MATCH)) + # after the line + for lineno in range(line + 1, end_lineno + 1): + text_lines.append(TextLine(line_number=lineno, line_content=line_contents[lineno], match_type=LineType.AFTER_MATCH)) + + return cls(lines=text_lines, source_file_path=source_file_path) + + +def glob_to_regex(glob_pat: str) -> str: + regex_parts: list[str] = [] + i = 0 + while i < len(glob_pat): + ch = glob_pat[i] + if ch == "*": + regex_parts.append(".*") + elif ch == "?": + regex_parts.append(".") + elif ch == "\\": + i += 1 + if i < len(glob_pat): + regex_parts.append(re.escape(glob_pat[i])) + else: + regex_parts.append("\\") + else: + regex_parts.append(re.escape(ch)) + i += 1 + return "".join(regex_parts) + + +def search_text( + pattern: str, + content: str | None = None, + source_file_path: str | None = None, + context_lines_before: int = 0, + context_lines_after: int = 0, + is_glob: bool = False, + multiline: bool = True, +) -> list[MatchedConsecutiveLines]: + """ + Search for a pattern in text content. Supports both regex and glob-like patterns. + + :param pattern: Pattern to search for (regex or glob-like pattern) + :param content: The text content to search. May be None if source_file_path is provided. + :param source_file_path: Optional path to the source file. If content is None, + this has to be passed and the file will be read. + :param context_lines_before: Number of context lines to include before matches + :param context_lines_after: Number of context lines to include after matches + :param is_glob: If True, pattern is treated as a glob-like pattern (e.g., "*.py", "test_??.py") + and will be converted to regex internally + :param multiline: whether to apply multi-line matching, enabling the flags re.DOTALL and re.MULTILINE + :return: List of `TextSearchMatch` objects + :raises: ValueError if the pattern is not valid + """ + if source_file_path and content is None: + with open(source_file_path) as f: + content = f.read() + + if content is None: + raise ValueError("Pass either content or source_file_path") + + matches = [] + lines = content.splitlines() + total_lines = len(lines) + + # Convert pattern to a compiled regex if it's a string + if is_glob: + pattern = glob_to_regex(pattern) + + # For multiline matches, optionally use DOTALL so '.' matches newlines + flags = (re.MULTILINE | re.DOTALL) if multiline else 0 + compiled_pattern = re.compile(pattern, flags) + # Search across the entire content as a single string + for match in compiled_pattern.finditer(content): + start_pos = match.start() + end_pos = match.end() + + # Find the line numbers for the start and end positions + start_line_num = content[:start_pos].count("\n") + end_line_num = content[:end_pos].count("\n") + + # Calculate the range of lines to include in the context + context_start = max(0, start_line_num - context_lines_before) + context_end = min(total_lines - 1, end_line_num + context_lines_after) + + # Create TextLine objects for the context + context_lines = [] + for line_num in range(context_start, context_end + 1): + if context_start <= line_num < start_line_num: + match_type = LineType.BEFORE_MATCH + elif end_line_num < line_num <= context_end: + match_type = LineType.AFTER_MATCH + else: + match_type = LineType.MATCH + + context_lines.append(TextLine(line_number=line_num, line_content=lines[line_num], match_type=match_type)) + + matches.append(MatchedConsecutiveLines(lines=context_lines, source_file_path=source_file_path)) + + return matches + + +def expand_braces(pattern: str) -> list[str]: + """ + Expands brace patterns in a glob string. + For example, "**/*.{js,jsx,ts,tsx}" becomes ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]. + Handles multiple brace sets as well. + """ + patterns = [pattern] + while any("{" in p for p in patterns): + new_patterns = [] + for p in patterns: + match = re.search(r"\{([^{}]+)\}", p) + if match: + prefix = p[: match.start()] + suffix = p[match.end() :] + options = match.group(1).split(",") + for option in options: + new_patterns.append(f"{prefix}{option}{suffix}") + else: + new_patterns.append(p) + patterns = new_patterns + return patterns + + +def glob_match(pattern: str, path: str) -> bool: + """ + Match a file path against a glob pattern. + + Supports standard glob patterns: + - * matches any number of characters except / + - ** matches any number of directories (zero or more) + - ? matches a single character except / + - [seq] matches any character in seq + + Supports brace expansion: + - {a,b,c} expands to multiple patterns (including nesting) + + Unsupported patterns: + - Bash extended glob features are unavailable in Python's fnmatch + - Extended globs like !(), ?(), +(), *(), @() are not supported + + :param pattern: Glob pattern (e.g., 'src/**/*.py', '**agent.py') + :param path: File path to match against + :return: True if path matches pattern + """ + pattern = pattern.replace("\\", "/") # Normalize backslashes to forward slashes + path = path.replace("\\", "/") # Normalize path backslashes to forward slashes + + # Handle ** patterns that should match zero or more directories + if "**" in pattern: + # Method 1: Standard fnmatch (matches one or more directories) + regex1 = fnmatch.translate(pattern) + if re.match(regex1, path): + return True + + # Method 2: Handle zero-directory case by removing /** entirely + # Convert "src/**/test.py" to "src/test.py" + if "/**/" in pattern: + zero_dir_pattern = pattern.replace("/**/", "/") + regex2 = fnmatch.translate(zero_dir_pattern) + if re.match(regex2, path): + return True + + # Method 3: Handle leading ** case by removing **/ + # Convert "**/test.py" to "test.py" + if pattern.startswith("**/"): + zero_dir_pattern = pattern[3:] # Remove "**/" + regex3 = fnmatch.translate(zero_dir_pattern) + if re.match(regex3, path): + return True + + return False + else: + # Simple pattern without **, use fnmatch directly + return fnmatch.fnmatch(path, pattern) + + +class GlobMatcher: + def __init__(self, expr: str): + self._patterns = expand_braces(expr) + + def matches(self, path: str) -> bool: + return any(glob_match(p, path) for p in self._patterns) + + +def search_files( + file_collection: FileCollection, + pattern: str, + context_lines_before: int = 0, + context_lines_after: int = 0, + paths_include_glob: str | None = None, + paths_exclude_glob: str | None = None, + multiline: bool = True, +) -> list[MatchedConsecutiveLines]: + """ + Search for a pattern in a list of files. + + :param file_collection: the collection of files to search (will be optionally filtered by glob patterns) + :param pattern: pattern to search for + :param context_lines_before: number of context lines to include before matches + :param context_lines_after: number of context lines to include after matches + :param paths_include_glob: optional glob pattern to include files from the list + :param paths_exclude_glob: optional glob pattern to exclude files from the list + :param multiline: whether to apply multi-line matching, enabling the flags re.DOTALL and re.MULTILINE (default: True) + :return: list of MatchedConsecutiveLines objects + """ + # apply glob filter + file_collection = file_collection.filter_glob(paths_include_glob=paths_include_glob, paths_exclude_glob=paths_exclude_glob) + log.info(f"Processing {len(file_collection)} files.") + + def process_single_file(file_proxy: FileProxy) -> dict[str, Any]: + """Process a single file - this function will be parallelized.""" + relative_path = file_proxy.get_relative_path() + try: + file_content = file_proxy.get_contents() + search_results = search_text( + pattern, + content=file_content, + source_file_path=relative_path, + context_lines_before=context_lines_before, + context_lines_after=context_lines_after, + multiline=multiline, + ) + if len(search_results) > 0: + log.debug(f"Found {len(search_results)} matches in {relative_path}") + return {"path": relative_path, "results": search_results, "error": None} + except Exception as e: + log.debug(f"Error processing {relative_path}: {e}") + return {"path": relative_path, "results": [], "error": str(e)} + + # Execute in parallel using joblib + results = Parallel( + n_jobs=-1, + backend="threading", + )(delayed(process_single_file)(file_proxy) for file_proxy in file_collection) + + # Collect results and errors + matches = [] + skipped_file_error_tuples = [] + + for result in results: + if result["error"]: + skipped_file_error_tuples.append((result["path"], result["error"])) + else: + matches.extend(result["results"]) + + if skipped_file_error_tuples: + log.debug(f"Failed to read {len(skipped_file_error_tuples)} files: {skipped_file_error_tuples}") + + log.info(f"Found {len(matches)} total matches across {len(file_collection)} files") + return matches + + +def render_html(html: str) -> str: + """ + Remove HTML tags and decode HTML entities from text while preserving the actual content. + This keeps type information and structure but removes all formatting. + + :param html: HTML text to clean + :return: Plain text without HTML tags and with decoded entities + """ + soup = BeautifulSoup(html, "html.parser") + # join text with spaces to avoid concatenation of words + text = soup.get_text(separator=" ", strip=True) + + # normalize non-breaking spaces + text = text.replace("\xa0", " ") + + return text.strip() + + +class ContentReplacer: + """ + This is an LLM-optimised content replacer, which elegantly circumvents escaping and which + provides dual modes for maximum flexibility. + """ + + def __init__(self, mode: Literal["literal", "regex"], allow_multiple_occurrences: bool, regex_multiline: bool = True): + """ + + :param mode: the mode indicating whether to the needle in replacements corresponds to a regular expression + (mode "regex") or to a literal string (mode "literal") + :param allow_multiple_occurrences: whether it is allowed that the search expression matches multiple occurrences. + If False, an error will be raised if more than one match is found. + :param regex_multiline: whether to apply multi-line regex matching, enabling the flags re.DOTALL and re.MULTILINE + """ + self.mode = mode + self.allow_multiple_occurrences = allow_multiple_occurrences + self.regex_multiline = regex_multiline + + @staticmethod + def _create_replacement_function(regex_pattern: str, repl_template: str, regex_flags: int) -> Callable[[re.Match], str]: + """ + Creates a replacement function that validates for ambiguity and handles backreferences. + + :param regex_pattern: The regex pattern being used for matching + :param repl_template: The replacement template with $!1, $!2, etc. for backreferences + :param regex_flags: The flags to use when searching (e.g., re.DOTALL | re.MULTILINE) + :return: A function suitable for use with re.sub() or re.subn() + """ + + def validate_and_replace(match: re.Match) -> str: + matched_text = match.group(0) + + # For multi-line match, check if the same pattern matches again within the already-matched text, + # rendering the match ambiguous. Typical pattern in the code: + # + # When matching + # .*? + # this will match the entire span above, while only the suffix may have been intended. + # (See test case for a practical example.) + # To detect this, we check if the same pattern matches again within the matched text, + if "\n" in matched_text and re.search(regex_pattern, matched_text[1:], flags=regex_flags): + raise ValueError( + "Match is ambiguous: the search pattern matches multiple overlapping occurrences. " + "Please revise the search pattern to be more specific to avoid ambiguity, " + "e.g. by matching specific context after the match, or try using the literal mode." + ) + + # Handle backreferences: replace $!1, $!2, etc. with actual matched groups + def expand_backreference(m: re.Match) -> str: + group_num = int(m.group(1)) + group_value = match.group(group_num) + return group_value if group_value is not None else m.group(0) + + result = re.sub(r"\$!(\d+)", expand_backreference, repl_template) + return result + + return validate_and_replace + + def replace( + self, + content: str, + needle: str, + repl: str, + ) -> str: + """ + Performs the replacement. + + Raises ValueError if no match is found, or if multiple matches are found while allow_multiple_occurrences is False. + + :param content: the content in which to perform the replacement + :param needle: the search expression, which is either a literal string or a regular expression, depending on the mode + :param repl: the replacement string, which, in regex mode, may contain backreferences in the form of $!1, $!2, etc. to + refer to matched groups in the search expression + :return: the updated content after performing the replacement + """ + if self.mode == "literal": + regex = re.escape(needle) + elif self.mode == "regex": + regex = needle + else: + raise ValueError(f"Invalid mode: '{self.mode}', expected 'literal' or 'regex'.") + + regex_flags = (re.MULTILINE | re.DOTALL) if self.regex_multiline else 0 + + # create replacement function with validation and backreference handling + repl_fn = self._create_replacement_function(regex, repl, regex_flags=regex_flags) + + # perform replacement + updated_content, n = re.subn(regex, repl_fn, content, flags=regex_flags) + + if n == 0: + raise ValueError("Error: No matches of search expression found.") + if not self.allow_multiple_occurrences and n > 1: + raise ValueError( + f"Expression matches {n} occurrences. " + "Please revise the expression to be more specific or enable allow_multiple_occurrences if this is expected." + ) + return updated_content + + +@dataclass +class ReplacementOccurrence: + """A single prospective replacement of a pattern match within one file.""" + + occurrence_id: str + """stable, content-anchored identifier: ':@'""" + relative_path: str + index_in_file: int + """0-based index of this match among the matches within its file (in position order)""" + start: int + """character offset of the match start within the file content""" + end: int + """character offset of the match end within the file content""" + matched_text: str + replacement: str + """the fully expanded replacement text (backreferences already resolved)""" + start_line: int + """0-based line number of the match start""" + end_line: int + """0-based line number of the match end""" + is_ambiguous: bool = False + """whether the pattern matches again within the matched text (possible over-match)""" + + +class MultiFileContentReplacer: + """ + Occurrence-level counterpart of :class:`ContentReplacer` operating on multiple files: + finds every match of a pattern across a set of file contents, assigns each occurrence a + stable content-anchored id, renders minimal line diffs for previewing, and computes the + updated content of a file for a selected subset of occurrences. + """ + + OCCURRENCE_ID_REGEX = re.compile(r"^(?P.+):(?P\d+)@(?P[0-9a-f]{6})$") + _DIGEST_LEN = 6 + + def __init__(self, mode: Literal["literal", "regex"], regex_multiline: bool = True): + """ + :param mode: whether the needle is a literal string ("literal") or a regular expression ("regex") + :param regex_multiline: whether to apply multi-line regex matching, enabling the flags re.DOTALL and re.MULTILINE + """ + if mode not in ("literal", "regex"): + raise ValueError(f"Invalid mode: '{mode}', expected 'literal' or 'regex'.") + self.mode = mode + self._flags = (re.MULTILINE | re.DOTALL) if regex_multiline else 0 + + def _compile(self, needle: str) -> re.Pattern: + return re.compile(re.escape(needle) if self.mode == "literal" else needle, flags=self._flags) + + @classmethod + def _digest(cls, matched_text: str) -> str: + return hashlib.sha1(matched_text.encode("utf-8")).hexdigest()[: cls._DIGEST_LEN] + + @classmethod + def make_occurrence_id(cls, relative_path: str, index_in_file: int, matched_text: str) -> str: + return f"{relative_path}:{index_in_file}@{cls._digest(matched_text)}" + + @staticmethod + def _expand_backreferences(match: re.Match, repl_template: str) -> str: + """Expands $!1, $!2, ... in the replacement template (same syntax as :class:`ContentReplacer`).""" + + def expand(m: re.Match) -> str: + group_value = match.group(int(m.group(1))) + return group_value if group_value is not None else m.group(0) + + return re.sub(r"\$!(\d+)", expand, repl_template) + + def find_occurrences(self, files: list[tuple[str, str]], needle: str, repl: str) -> list[ReplacementOccurrence]: + """ + Finds all matches of the needle in the given files. + + :param files: (relative_path, content) pairs; processed in the given order + :param needle: the search expression (literal string or regex, depending on the mode) + :param repl: the replacement template (may contain $!N backreferences in regex mode) + :return: occurrences in deterministic order (file order, then position within the file) + """ + pattern = self._compile(needle) + occurrences: list[ReplacementOccurrence] = [] + for relative_path, content in files: + for index_in_file, match in enumerate(pattern.finditer(content)): + matched_text = match.group(0) + replacement = self._expand_backreferences(match, repl) if self.mode == "regex" else repl + # same over-match heuristic as ContentReplacer: for a multi-line match, the pattern + # matching again within the matched text indicates the match may have swallowed + # more than intended + is_ambiguous = "\n" in matched_text and pattern.search(matched_text[1:]) is not None + occurrences.append( + ReplacementOccurrence( + occurrence_id=self.make_occurrence_id(relative_path, index_in_file, matched_text), + relative_path=relative_path, + index_in_file=index_in_file, + start=match.start(), + end=match.end(), + matched_text=matched_text, + replacement=replacement, + start_line=content.count("\n", 0, match.start()), + end_line=content.count("\n", 0, match.end()), + is_ambiguous=is_ambiguous, + ) + ) + return occurrences + + @staticmethod + def apply_to_content(content: str, occurrences: list[ReplacementOccurrence]) -> str: + """ + Applies the given occurrences (which must have been derived from exactly this content) + and returns the updated content. + """ + for occ in sorted(occurrences, key=lambda o: o.start, reverse=True): + assert content[occ.start : occ.end] == occ.matched_text, ( + f"Occurrence {occ.occurrence_id} does not match the content it is being applied to" + ) + content = content[: occ.start] + occ.replacement + content[occ.end :] + return content + + @staticmethod + def _format_block(block: str, prefix: str, max_lines: int, max_line_chars: int) -> list[str]: + lines = block.split("\n") + shown = lines[:max_lines] + result = [] + for line in shown: + if len(line) > max_line_chars: + line = line[:max_line_chars] + f"… (+{len(line) - max_line_chars} chars)" + result.append(f" {prefix} {line}") + if len(lines) > max_lines: + result.append(f" {prefix} … ({len(lines) - max_lines} more lines)") + return result + + def render_occurrence_diff( + self, occ: ReplacementOccurrence, content: str, max_lines_per_side: int = 6, max_line_chars: int = 200 + ) -> str: + """ + Renders a minimal line diff for the occurrence: the full lines spanned by the match, + before and after the replacement. + + :param occ: the occurrence (must have been derived from exactly this content) + :param content: the file content the occurrence was found in + :param max_lines_per_side: cap on the number of displayed lines per diff side + :param max_line_chars: cap on the number of displayed characters per line + :return: the rendered diff + """ + line_start = content.rfind("\n", 0, occ.start) + 1 + line_end = content.find("\n", occ.end) + if line_end == -1: + line_end = len(content) + old_block = content[line_start:line_end] + new_block = content[line_start : occ.start] + occ.replacement + content[occ.end : line_end] + location = f"line {occ.start_line}" if occ.start_line == occ.end_line else f"lines {occ.start_line}-{occ.end_line}" + header = f" [{occ.occurrence_id}] {location}" + if occ.is_ambiguous: + header += " (WARNING: the pattern matches again inside this match — possible over-match, verify the diff)" + diff_lines = [header] + diff_lines += self._format_block(old_block, "-", max_lines_per_side, max_line_chars) + diff_lines += self._format_block(new_block, "+", max_lines_per_side, max_line_chars) + return "\n".join(diff_lines) + + +@dataclass +class TextCoords: + line: int + """ + 0-based line number + """ + col: int + """ + 0-based column number + """ + + +def find_text_coordinates(content: str, regex: str, require_unique: bool = False) -> TextCoords | None: + """ + Finds the line and column number of the first match of a regex pattern in the given content. + + :param content: the text content to search through + :param regex: the regular expression pattern to search for; it must match part of a single line, + and contain exactly one group that captures the position of interest (e.g., the exact variable name to find the coordinates of) + :param require_unique: if True, raises an error if not exactly one match is found; + if False, returns None if no match is found, and returns the coordinates of the first match if multiple matches are found + :return: the coordinates of the match or None + """ + pattern = re.compile(regex, flags=re.MULTILINE | re.DOTALL) + matches = list(pattern.finditer(content)) + if len(matches) == 0: + if require_unique: + raise ValueError(f"No match found for regex: {regex}") + return None + else: + if require_unique and len(matches) > 1: + raise ValueError(f"Match must be unique; found {len(matches)} matches for regex: {regex}") + match = matches[0] + if len(match.groups()) != 1: + raise ValueError(f"Regex must contain exactly one group to capture the position, but found {len(match.groups())} groups.") + index_in_content = match.start(1) + line, col = TextUtils.get_line_col_from_index(content, index_in_content) + return TextCoords(line, col) diff --git a/src/serena/util/thread.py b/src/serena/util/thread.py new file mode 100644 index 0000000..6d21ebe --- /dev/null +++ b/src/serena/util/thread.py @@ -0,0 +1,68 @@ +import threading +from collections.abc import Callable +from enum import Enum +from typing import Generic, TypeVar + +from sensai.util.string import ToStringMixin + + +class TimeoutException(Exception): + def __init__(self, message: str, timeout: float) -> None: + super().__init__(message) + self.timeout = timeout + + +T = TypeVar("T") + + +class ExecutionResult(Generic[T], ToStringMixin): + class Status(Enum): + SUCCESS = "success" + TIMEOUT = "timeout" + EXCEPTION = "error" + + def __init__(self) -> None: + self.result_value: T | None = None + self.status: ExecutionResult.Status | None = None + self.exception: Exception | None = None + + def set_result_value(self, value: T) -> None: + self.result_value = value + self.status = ExecutionResult.Status.SUCCESS + + def set_timed_out(self, exception: TimeoutException) -> None: + self.exception = exception + self.status = ExecutionResult.Status.TIMEOUT + + def set_exception(self, exception: Exception) -> None: + self.exception = exception + self.status = ExecutionResult.Status.EXCEPTION + + +def execute_with_timeout(func: Callable[[], T], timeout: float, function_name: str) -> ExecutionResult[T]: + """ + Executes the given function with a timeout + + :param func: the function to execute + :param timeout: the timeout in seconds + :param function_name: the name of the function (for error messages) + :returns: the execution result + """ + execution_result: ExecutionResult[T] = ExecutionResult() + + def target() -> None: + try: + value = func() + execution_result.set_result_value(value) + except Exception as e: + execution_result.set_exception(e) + + thread = threading.Thread(target=target, daemon=True) + thread.start() + thread.join(timeout=timeout) + + if thread.is_alive(): + timeout_exception = TimeoutException(f"Execution of '{function_name}' timed out after {timeout} seconds.", timeout) + execution_result.set_timed_out(timeout_exception) + + return execution_result diff --git a/src/serena/util/version.py b/src/serena/util/version.py new file mode 100644 index 0000000..142d435 --- /dev/null +++ b/src/serena/util/version.py @@ -0,0 +1,85 @@ +class Version: + """ + Represents a version, specifically the numeric components of a version string. + + Suffixes like "rc1" or "-dev" are ignored, i.e. for a version string like "1.2.3rc1", + the components are [1, 2, 3]. + """ + + def __init__(self, package_or_version: object | str): + """ + :param package_or_version: a package object (with a `__version__` attribute) or a version string like "1.2.3". + If a version contains a suffix (like "1.2.3rc1" or "1.2.3-dev"), the suffix is ignored. + """ + if isinstance(package_or_version, str): + version_string = package_or_version + elif hasattr(package_or_version, "__version__"): + package_version_string = getattr(package_or_version, "__version__", None) + if package_version_string is None: + raise ValueError(f"The given package object {package_or_version} has no __version__ attribute") + version_string = package_version_string + else: + raise ValueError("The given argument must be either a version string or a package object with a __version__ attribute") + self.version_string = version_string + self.components = self._get_version_components(version_string) + + def __repr__(self) -> str: + return self.version_string + + @staticmethod + def _get_version_components(version_string: str) -> list[int]: + components = version_string.split(".") + int_components = [] + for c in components: + num_str = "" + for ch in c: + if ch.isdigit(): + num_str += ch + else: + break + if num_str == "": + break + int_components.append(int(num_str)) + return int_components + + def is_at_least(self, *components: int) -> bool: + """ + Checks this version against the given version components. + This version object must contain at least the respective number of components + + :param components: version components in order (i.e. major, minor, patch, etc.) + :return: True if the version is at least the given version, False otherwise + """ + for i, desired_min_version in enumerate(components): + actual_version = self.components[i] + if actual_version < desired_min_version: + return False + elif actual_version > desired_min_version: + return True + return True + + def is_at_most(self, *components: int) -> bool: + """ + Checks this version against the given version components. + This version object must contain at least the respective number of components + + :param components: version components in order (i.e. major, minor, patch, etc.) + :return: True if the version is at most the given version, False otherwise + """ + for i, desired_max_version in enumerate(components): + actual_version = self.components[i] + if actual_version > desired_max_version: + return False + elif actual_version < desired_max_version: + return True + return True + + def is_equal(self, *components: int) -> bool: + """ + Checks this version against the given version components. + This version object must contain at least the respective number of components + + :param components: version components in order (i.e. major, minor, patch, etc.) + :return: True if the version is the given version, False otherwise + """ + return self.components[: len(components)] == list(components) diff --git a/src/serena/util/yaml.py b/src/serena/util/yaml.py new file mode 100644 index 0000000..bc16f43 --- /dev/null +++ b/src/serena/util/yaml.py @@ -0,0 +1,311 @@ +import logging +import os +import tempfile +import time +from collections.abc import Sequence +from enum import Enum +from typing import Any + +from ruamel.yaml import YAML, CommentedSeq, CommentToken, StreamMark +from ruamel.yaml.comments import CommentedMap + +from serena.constants import SERENA_FILE_ENCODING + +log = logging.getLogger(__name__) + + +def _create_yaml(preserve_comments: bool = False) -> YAML: + """ + Creates a YAML that can load/save with comments if preserve_comments is True. + """ + typ = None if preserve_comments else "safe" + result = YAML(typ=typ) + result.preserve_quotes = preserve_comments + return result + + +class YamlCommentNormalisation(Enum): + """ + Defines a normalisation to be applied to the comment representation in a ruamel CommentedMap. + + Note that even though a YAML document may seem to consistently contain, for example, leading comments + before a key only, ruamel may still parse some comments as trailing comments of the previous key + or as document-level comments. + The normalisations define ways to adjust the comment representation accordingly, clearly associating + comments with the keys they belong to. + """ + + NONE = "none" + """ + No comment normalisation is performed. + Comments are kept as parsed by ruamel.yaml. + """ + LEADING = "leading" + """ + Document is assumed to have leading comments only, i.e. comments before keys, only full-line comments. + This normalisation achieves that comments are properly associated with keys as leading comments. + """ + LEADING_WITH_CONVERSION_FROM_TRAILING = "leading_with_conversion_from_trailing" + """ + Document is assumed to have a mixture of leading comments (before keys) and trailing comments (after values), only full-line comments. + This normalisation achieves that all comments are converted to leading comments and properly associated with keys. + """ + # NOTE: Normalisation for trailing comments was attempted but is extremely hard, because + # it is difficult to position the comments properly after values, especially for complex values. + + +DOC_COMMENT_INDEX_POST = 0 +DOC_COMMENT_INDEX_PRE = 1 + +# item comment indices: (post key, pre key, post value, pre value) +ITEM_COMMENT_INDEX_BEFORE = 1 # (pre-key; must be a list of CommentToken at this index) +ITEM_COMMENT_INDEX_AFTER = 2 # (post-value; must be an instance of CommentToken at this index) + + +def load_yaml(path: str, comment_normalisation: YamlCommentNormalisation = YamlCommentNormalisation.NONE) -> CommentedMap: + """ + :param path: the path to the YAML file to load + :param comment_normalisation: the comment normalisation to apply after loading + :return: the loaded commented map + """ + with open(path, encoding=SERENA_FILE_ENCODING) as f: + yaml = _create_yaml(preserve_comments=True) + commented_map: CommentedMap | None = yaml.load(f) + if commented_map is None: # ruamel returns None for empty documents, but we want an empty CommentedMap + commented_map = CommentedMap() + normalise_yaml_comments(commented_map, comment_normalisation) + return commented_map + + +def normalise_yaml_comments(commented_map: CommentedMap, comment_normalisation: YamlCommentNormalisation) -> None: + """ + Applies the given comment normalisation to the given commented map in-place. + + :param commented_map: the commented map whose comments are to be normalised + :param comment_normalisation: the comment normalisation to apply + """ + + def make_list(comment_entry: Any) -> list: + if not isinstance(comment_entry, list): + return [comment_entry] + return comment_entry + + def make_unit(comment_entry: Any) -> Any: + """ + Converts a list-valued comment entry into a single comment entry. + """ + if isinstance(comment_entry, list): + if len(comment_entry) == 0: + return None + elif len(comment_entry) == 1: + return comment_entry[0] + else: + if all(isinstance(item, CommentToken) for item in comment_entry): + start_mark = StreamMark(name="", index=0, line=0, column=0) + comment_str = "".join(item.value for item in comment_entry) + if not comment_str.startswith("\n"): + comment_str = "\n" + comment_str + return CommentToken(value=comment_str, start_mark=start_mark, end_mark=None) + else: + types = set(type(item) for item in comment_entry) + log.warning("Unhandled types in list-valued comment entry: %s; not updating entry", types) + return None + else: + return comment_entry + + def trailing_to_leading(comment_entry: Any) -> Any: + if comment_entry is None: + return None + token_list = make_list(comment_entry) + first_token = token_list[0] + if isinstance(first_token, CommentToken): + # remove leading newline if present + if first_token.value.startswith("\n"): + first_token.value = first_token.value[1:] + return token_list + + def remove_nested_comments() -> None: + """ + Removes nested comments, particularly of sequences, which incorrectly capture comments + that are actually intended for top-level keys. + """ + for key in commented_map.keys(): + entry = commented_map[key] + if isinstance(entry, CommentedSeq): + items = entry.ca.items + if isinstance(items, dict): + items_keys = list(items.keys()) + for i in items_keys: + items[i] = [None] * 4 + + match comment_normalisation: + case YamlCommentNormalisation.NONE: + pass + case YamlCommentNormalisation.LEADING | YamlCommentNormalisation.LEADING_WITH_CONVERSION_FROM_TRAILING: + # Comments are supposed to be leading comments (i.e., before a key and associated with the key). + # When ruamel parses a YAML, however, comments belonging to a key may be stored as trailing + # comments of the previous key or as a document-level comment. + # Move them accordingly. + keys = list(commented_map.keys()) + comment_items = commented_map.ca.items + doc_comment = commented_map.ca.comment + preceding_comment = None + for i, key in enumerate(keys): + current_comment = comment_items.get(key, [None] * 4) + comment_items[key] = current_comment + if current_comment[ITEM_COMMENT_INDEX_BEFORE] is None: + if i == 0 and doc_comment is not None and doc_comment[DOC_COMMENT_INDEX_PRE] is not None: + # move document pre-comment to leading comment of first key + current_comment[ITEM_COMMENT_INDEX_BEFORE] = make_list(doc_comment[DOC_COMMENT_INDEX_PRE]) + doc_comment[DOC_COMMENT_INDEX_PRE] = None + elif preceding_comment is not None and preceding_comment[ITEM_COMMENT_INDEX_AFTER] is not None: + # move trailing comment of preceding key to leading comment of current key + current_comment[ITEM_COMMENT_INDEX_BEFORE] = trailing_to_leading(preceding_comment[ITEM_COMMENT_INDEX_AFTER]) + preceding_comment[ITEM_COMMENT_INDEX_AFTER] = None + preceding_comment = current_comment + + if comment_normalisation == YamlCommentNormalisation.LEADING_WITH_CONVERSION_FROM_TRAILING: + # Second pass: conversion of trailing comments + # If a leading comment ends with "\n\n", i.e. it has an empty line between the comment and the key, + # it was actually intended as a trailing comment for the preceding key, so we associate it with + # the preceding key instead (if the preceding key has no leading comment already). + preceding_comment = None + for key in keys: + current_comment = comment_items.get(key, [None] * 4) + if current_comment[ITEM_COMMENT_INDEX_BEFORE] is not None: + token_list = make_list(current_comment[ITEM_COMMENT_INDEX_BEFORE]) + if len(token_list) > 0: + last_token = token_list[-1] + if isinstance(last_token, CommentToken) and last_token.value.endswith("\n\n"): + # move comment to preceding key, removing the empty line, + # and adding an empty line at the beginning instead + if preceding_comment is not None and yaml_comment_entry_is_empty( + preceding_comment[ITEM_COMMENT_INDEX_BEFORE] + ): + last_token.value = last_token.value[:-1] + + first_token = token_list[0] + if isinstance(first_token, CommentToken): + if not first_token.value.startswith("\n"): + first_token.value = "\n" + first_token.value + + preceding_comment[ITEM_COMMENT_INDEX_BEFORE] = token_list + current_comment[ITEM_COMMENT_INDEX_BEFORE] = None + preceding_comment = current_comment + + # remove nested comments, as we assume that only top-level keys are supposed to be commented + remove_nested_comments() + case _: + raise ValueError(f"Unhandled comment normalisation: {comment_normalisation}") + + +def save_yaml(path: str, data: dict | CommentedMap, preserve_comments: bool = True) -> None: + yaml = _create_yaml(preserve_comments) + target_dir = os.path.dirname(path) + os.makedirs(target_dir, exist_ok=True) + # Atomic write: dump to a temp file in the SAME directory, then os.replace onto the target. + # A plain truncate-and-write (open(path, "w")) is NOT atomic: a concurrent writer (e.g. a second + # Serena process updating the auto-managed registered-projects list) or an interrupted write can + # leave the file half-overwritten — writing a shorter value over a longer one leaves a stale tail, + # which corrupts the YAML so it no longer parses and every later load fails. temp + os.replace makes + # each write all-or-nothing (last-writer-wins, never a corrupt interleave). + fd, tmp = tempfile.mkstemp(dir=target_dir, prefix=os.path.basename(path) + ".", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding=SERENA_FILE_ENCODING) as f: + yaml.dump(data, f) + _replace_with_retry(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def _replace_with_retry(src: str, dst: str, *, attempts: int = 10, delay_s: float = 0.05) -> None: + """``os.replace(src, dst)`` with a short retry on a Windows sharing violation. + + On Windows the atomic rename fails with ``PermissionError`` (WinError 5/32) if another process + momentarily holds ``dst`` open — e.g. a second Serena process reading or replacing the same + config. A brief bounded retry rides out that contention; the temp file is still complete, so we + never fall back to a non-atomic write. + """ + for attempt in range(attempts): + try: + os.replace(src, dst) + return + except PermissionError: + if attempt == attempts - 1: + raise + time.sleep(delay_s) + + +def yaml_comment_entry_is_empty(comment_entry: Any) -> bool: + if comment_entry is None: + return True + elif isinstance(comment_entry, list): + for item in comment_entry: + if isinstance(item, CommentToken): + if item.value.strip() != "": + return False + else: + return False + return True + elif isinstance(comment_entry, CommentToken): + return comment_entry.value.strip() == "" + else: + return False + + +def transfer_yaml_comments_by_index( + source: CommentedMap, target: CommentedMap, indices: list[int], forced_update_keys: Sequence[str] = (), force_update_all: bool = False +) -> None: + """ + :param source: the source, from which to transfer missing comments + :param target: the target map, whose comments will be updated + :param indices: list of comment indices to transfer + :param forced_update_keys: keys for which comments are always transferred, even if present in target + :param force_update_all: if True, comments are transferred for all keys, even if present in target + """ + for key in target.keys(): + if key in source: + source_comment = source.ca.items.get(key) + if source_comment is None: + continue + target_comment = target.ca.items.get(key) + # initialise target comment if needed + if target_comment is None: + target_comment = [None] * 4 + target.ca.items[key] = target_comment + # transfer comments at specified indices + is_forced_update = force_update_all or key in forced_update_keys + for index in indices: + if is_forced_update or yaml_comment_entry_is_empty(target_comment[index]): + target_comment[index] = source_comment[index] + + +def transfer_yaml_comments( + source: CommentedMap, + target: CommentedMap, + comment_normalisation: YamlCommentNormalisation, + forced_update_keys: Sequence[str] = (), + force_update_all: bool = False, +) -> None: + """ + Transfers missing comments from source to target YAML. + + :param source: the source, from which to transfer missing comments + :param target: the target map, whose comments will be updated. + :param comment_normalisation: the comment normalisation to assume; if NONE, no comments are transferred + :param forced_update_keys: keys for which comments are always transferred, even if present in target + :param force_update_all: if True, comments are transferred for all keys, even if present in target + """ + match comment_normalisation: + case YamlCommentNormalisation.NONE: + pass + case YamlCommentNormalisation.LEADING | YamlCommentNormalisation.LEADING_WITH_CONVERSION_FROM_TRAILING: + transfer_yaml_comments_by_index( + source, target, [ITEM_COMMENT_INDEX_BEFORE], forced_update_keys=forced_update_keys, force_update_all=force_update_all + ) + case _: + raise ValueError(f"Unhandled comment normalisation: {comment_normalisation}") diff --git a/src/solidlsp/.gitignore b/src/solidlsp/.gitignore new file mode 100644 index 0000000..c6d3356 --- /dev/null +++ b/src/solidlsp/.gitignore @@ -0,0 +1 @@ +language_servers/static \ No newline at end of file diff --git a/src/solidlsp/__init__.py b/src/solidlsp/__init__.py new file mode 100644 index 0000000..0378e2d --- /dev/null +++ b/src/solidlsp/__init__.py @@ -0,0 +1,2 @@ +# ruff: noqa +from .ls import SolidLanguageServer diff --git a/src/solidlsp/initialize_params.py b/src/solidlsp/initialize_params.py new file mode 100644 index 0000000..90c5b4d --- /dev/null +++ b/src/solidlsp/initialize_params.py @@ -0,0 +1,82 @@ +import logging +import os +import pathlib +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Self, cast + +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, WorkspaceFolder + +if TYPE_CHECKING: + from solidlsp import SolidLanguageServer + + +log = logging.getLogger(__name__) + + +class InitializeParamsBuilder(ABC): + def __init__(self): + self._params = {} + + def with_base_options(self, options: dict | InitializeParams) -> Self: + self._params.update(options) + return self + + def _set(self, key: str, value: Any) -> None: + if key in self._params: + log.debug("Overriding existing option '%s' with new value: %s (old value: %s)", key, value, self._params[key]) + self._params[key] = value + + @abstractmethod + def _apply_updates(self) -> None: + """ + Applies implementation-specific updates to the options. + """ + + def build(self) -> InitializeParams: + self._apply_updates() + return cast(InitializeParams, self._params) + + +class DefaultInitializeParamsBuilder(InitializeParamsBuilder): + def __init__(self, ls: "SolidLanguageServer", set_workspace_folders: bool = True): + super().__init__() + self._ls = ls + self._set_workspace_folders = set_workspace_folders + + @staticmethod + def _create_workspace_folder_entry(path: str) -> WorkspaceFolder: + abs_path = os.path.abspath(path) + return {"uri": pathlib.Path(abs_path).as_uri(), "name": os.path.basename(abs_path)} + + def _apply_updates(self): + root_abs_path = self._ls.repository_root_path + + self._set("processId", os.getpid()) + self._set("rootPath", root_abs_path) + self._set("rootUri", pathlib.Path(root_abs_path).as_uri()) + self._set("clientInfo", {"name": "Serena"}) + + if self._set_workspace_folders: + abs_workspace_paths = self._ls.config.get_absolute_workspace_folders(root_abs_path) + log.info("Workspace folders: %s", abs_workspace_paths) + workspace_folders = [self._create_workspace_folder_entry(abs_path) for abs_path in abs_workspace_paths] + additional_abs_workspace_paths = [ + p for p in self._ls.config.get_absolute_additional_workspace_folders(root_abs_path) if p not in abs_workspace_paths + ] + if additional_abs_workspace_paths: + log.info("Additional workspace folders (not indexed by SolidLSP): %s", additional_abs_workspace_paths) + workspace_folders.extend([self._create_workspace_folder_entry(abs_path) for abs_path in additional_abs_workspace_paths]) + self._set("workspaceFolders", workspace_folders) + + init_options_key = "initializationOptions" + custom_init_options = self._ls.custom_settings.get(init_options_key, None) + if custom_init_options: + if not isinstance(custom_init_options, dict): + log.error("Custom initialization options should be a dictionary, but got %s. Ignoring.", type(custom_init_options).__name__) + else: + log.info("Applying custom initialization options: %s", custom_init_options) + if init_options_key in self._params: + self._params[init_options_key].update(custom_init_options) + else: + self._params[init_options_key] = custom_init_options + log.info("Final initialization options: %s", self._params[init_options_key]) diff --git a/src/solidlsp/language_servers/ada_language_server.py b/src/solidlsp/language_servers/ada_language_server.py new file mode 100644 index 0000000..30040ec --- /dev/null +++ b/src/solidlsp/language_servers/ada_language_server.py @@ -0,0 +1,217 @@ +""" +Provides Ada / SPARK specific instantiation of the LanguageServer class using +AdaCore's Ada Language Server (ALS). + +ALS handles both Ada and SPARK code transparently — SPARK is identified by +pragmas/aspects in source rather than file extension, so a single +``Language.ADA`` covers both. +""" + +import logging +import os +import threading + +from overrides import override + +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +from .common import RuntimeDependency, RuntimeDependencyCollection + +log = logging.getLogger(__name__) + +# Bumped on upgrades; install dir is namespaced by version so old caches stay valid. +DEFAULT_ALS_VERSION = "2026.2.202604091" +DEFAULT_ALS_SHA256_BY_PLATFORM = { + "osx-arm64": "1d25ded29b6beafcb34c9d0084d52809d84577356e467bf92e8fef32fc4216c4", + "osx-x64": "18d3277a25a6e08ce3ee7230c3e5ac20419d573cb8d8c883e7983f387a67d223", + "linux-arm64": "65c57df715df90f7581ecd6a1d1884663376baaf38cedbab80d10060ff91b03d", + "linux-x64": "2eb436a7c0e3740128cceaa15da9ff856fa75ef3fbb3e2e9d3a1bd17e15cb949", + "win-x64": "bca024dc3643b2d91aebbb747398e2e6f183ad8cb2f149e24fe7a38cdb16ed8d", +} + + +def _als_sha(version: str, platform_key: str) -> str | None: + if version == DEFAULT_ALS_VERSION: + return DEFAULT_ALS_SHA256_BY_PLATFORM[platform_key] + return None + + +# AdaCore's tarballs are downloaded directly from github.com release URLs; +# GitHub redirects asset downloads through release-assets/objects subdomains. +ALS_ALLOWED_HOSTS = ( + "github.com", + "release-assets.githubusercontent.com", + "objects.githubusercontent.com", +) + +# Each AdaCore tarball nests the binary under integration/vscode/ada/{arch}/{os}/... +# alongside any bundled shared libraries (e.g. libgmp on macOS). +_BIN_RELPATH_BY_PLATFORM = { + "osx-arm64": "integration/vscode/ada/arm64/darwin/ada_language_server", + "osx-x64": "integration/vscode/ada/x64/darwin/ada_language_server", + "linux-arm64": "integration/vscode/ada/arm64/linux/ada_language_server", + "linux-x64": "integration/vscode/ada/x64/linux/ada_language_server", + "win-x64": "integration/vscode/ada/x64/win32/ada_language_server.exe", +} + +# AdaCore uses "darwin"/"win32" in asset names; remap from Serena's platform ids. +_ASSET_SUFFIX_BY_PLATFORM = { + "osx-arm64": "darwin-arm64", + "osx-x64": "darwin-x64", + "linux-arm64": "linux-arm64", + "linux-x64": "linux-x64", + "win-x64": "win32-x64", +} + + +class AdaLanguageServer(SolidLanguageServer): + """ + Provides Ada / SPARK specific instantiation of the LanguageServer class + using AdaCore's Ada Language Server. + + You can pass the following entries in ``ls_specific_settings["ada"]``: + - ls_path: Absolute path to a pre-installed ``ada_language_server`` + executable. If set, Serena does not download ALS and uses this + binary directly. Useful for users who already have ALS available + via Alire (``alr install ada_language_server``), GNAT Studio, or + the Ada VS Code extension. + - als_version: Override the pinned ALS version downloaded by Serena + (default: the bundled Serena version). Setting this requires + ``ls_path`` since custom-version SHA256 sums are unknown. + """ + + ALS_ALLOWED_HOSTS = ALS_ALLOWED_HOSTS + + # GNAT default output directories — pruned during workspace traversal. + _IGNORED_DIRS = frozenset({"obj", "lib", ".obj", ".objects"}) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in self._IGNORED_DIRS + + @classmethod + def _runtime_dependencies(cls, version: str) -> RuntimeDependencyCollection: + base_url = f"https://github.com/AdaCore/ada_language_server/releases/download/{version}" + return RuntimeDependencyCollection( + [ + RuntimeDependency( + id="ada-language-server", + description=f"Ada Language Server for {platform_key}", + url=f"{base_url}/als-{version}-{_ASSET_SUFFIX_BY_PLATFORM[platform_key]}.tar.gz", + platform_id=platform_key, + archive_type="gztar", + binary_name=_BIN_RELPATH_BY_PLATFORM[platform_key], + sha256=_als_sha(version, platform_key), + allowed_hosts=ALS_ALLOWED_HOSTS, + ) + for platform_key in _BIN_RELPATH_BY_PLATFORM + ] + ) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """Setup runtime dependencies for ALS and return the path to the executable.""" + als_version = self._custom_settings.get("als_version", DEFAULT_ALS_VERSION) + deps = AdaLanguageServer._runtime_dependencies(als_version) + dependency = deps.get_single_dep_for_current_platform() + + install_dir = os.path.join(self._ls_resources_dir, f"als-{als_version}") + als_executable_path = deps.binary_path(install_dir) + if not os.path.exists(als_executable_path): + log.info( + "Downloading and extracting ada_language_server from %s to %s", + dependency.url, + install_dir, + ) + deps.install(install_dir) + if not os.path.exists(als_executable_path): + raise FileNotFoundError(f"Download failed? Could not find ada_language_server executable at {als_executable_path}") + os.chmod(als_executable_path, 0o755) + return als_executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + # ALS speaks LSP over stdio by default; no flags needed. + return [core_path] + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates an AdaLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + super().__init__( + config, + repository_root_path, + None, + "ada", + solidlsp_settings, + ) + self.server_ready = threading.Event() + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + def _create_base_initialize_params(self) -> dict: + """Returns the initialization params for the Ada Language Server.""" + result = { + "capabilities": { + "workspace": { + "applyEdit": True, + "workspaceEdit": {"documentChanges": True}, + "symbol": {"symbolKind": {"valueSet": list(range(1, 27))}}, + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + }, + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "publishDiagnostics": {"relatedInformation": True}, + }, + "general": {"positionEncodings": ["utf-16"]}, + }, + "initializationOptions": {}, + "trace": "off", + } + return result + + def _start_server(self) -> None: + """Start the Ada Language Server process and complete the LSP handshake.""" + + def register_capability_handler(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info("LSP: window/logMessage: %s", msg) + + def do_nothing(params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("window/showMessage", do_nothing) + + log.info("Starting ada_language_server process") + self.server.start() + + initialize_params = self._create_initialize_params() + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + capabilities = init_response["capabilities"] + assert "textDocumentSync" in capabilities, "ALS must support textDocumentSync" + assert "definitionProvider" in capabilities, "ALS must support textDocument/definition" + assert "documentSymbolProvider" in capabilities, "ALS must support textDocument/documentSymbol" + assert "referencesProvider" in capabilities, "ALS must support textDocument/references" + + self.server.notify.initialized({}) + self.server_ready.set() diff --git a/src/solidlsp/language_servers/al_language_server.py b/src/solidlsp/language_servers/al_language_server.py new file mode 100644 index 0000000..f67ee2b --- /dev/null +++ b/src/solidlsp/language_servers/al_language_server.py @@ -0,0 +1,1072 @@ +"""AL Language Server implementation for Microsoft Dynamics 365 Business Central. + +You can pass the following entries in ``ls_specific_settings["al"]``: + - al_extension_version: Override the pinned AL VS Code extension version + downloaded by Serena (default: the bundled Serena version). +""" + +import logging +import os +import platform +import re +import stat +import time +from pathlib import Path + +from overrides import override + +from solidlsp import ls_types +from solidlsp.language_servers.common import quote_windows_path +from solidlsp.ls import DocumentSymbols, LSPFileBuffer, RawDocumentSymbol, SolidLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation +from solidlsp.ls_utils import FileUtils +from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_AL_EXTENSION_VERSION = "18.0.2242655" +INITIAL_AL_EXTENSION_SHA256 = "3971995e61a59dc4fcce4a65053072a67991ed624a16635c4f2911f12564b2b9" +DEFAULT_AL_EXTENSION_VERSION = "18.0.2242655" +DEFAULT_AL_EXTENSION_SHA256 = "3971995e61a59dc4fcce4a65053072a67991ed624a16635c4f2911f12564b2b9" + + +def _al_extension_sha(version: str) -> str | None: + if version == INITIAL_AL_EXTENSION_VERSION: + return INITIAL_AL_EXTENSION_SHA256 + if version == DEFAULT_AL_EXTENSION_VERSION: + return DEFAULT_AL_EXTENSION_SHA256 + return None + + +def _al_extension_dirname(version: str) -> str: + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + return "al-extension" if version == INITIAL_AL_EXTENSION_VERSION else f"al-extension-{version}" + + +AL_EXTENSION_ALLOWED_HOSTS = ("marketplace.visualstudio.com",) + + +class ALLanguageServer(SolidLanguageServer): + """ + Language server implementation for AL (Microsoft Dynamics 365 Business Central). + + This implementation uses the AL Language Server from the VS Code AL extension + (ms-dynamics-smb.al). The extension must be installed or available locally. + + Key Features: + - Automatic download of AL extension from VS Code marketplace if not present + - Platform-specific executable detection (Windows/Linux/macOS) + - Special initialization sequence required by AL Language Server + - Custom AL-specific LSP commands (al/gotodefinition, al/setActiveWorkspace) + - File opening requirement before symbol retrieval + - `al_extension_version` to override the bundled AL VS Code extension version + """ + + # Regex pattern to match AL object names like: + # - 'Table 50000 "TEST Customer"' -> captures 'TEST Customer' + # - 'Codeunit 50000 CustomerMgt' -> captures 'CustomerMgt' + # - 'Interface IPaymentProcessor' -> captures 'IPaymentProcessor' + # - 'Enum 50000 CustomerType' -> captures 'CustomerType' + # Pattern: [] (|) + _AL_OBJECT_NAME_PATTERN = re.compile( + r"^(?:Table|Page|Codeunit|Enum|Interface|Report|Query|XMLPort|PermissionSet|" + r"PermissionSetExtension|Profile|PageExtension|TableExtension|EnumExtension|" + r"PageCustomization|ReportExtension|ControlAddin|DotNetPackage)" # Object type + r"(?:\s+\d+)?" # Optional object ID + r"\s+" # Required space before name + r'(?:"([^"]+)"|(\S+))$' # Quoted name (group 1) or unquoted identifier (group 2) + ) + + @staticmethod + def _extract_al_display_name(full_name: str) -> str: + """ + Extract the display name from an AL symbol's full name. + + AL Language Server returns symbol names in format: + - 'Table 50000 "TEST Customer"' -> 'TEST Customer' + - 'Codeunit 50000 CustomerMgt' -> 'CustomerMgt' + - 'Interface IPaymentProcessor' -> 'IPaymentProcessor' + - 'fields' -> 'fields' (non-AL-object symbols pass through unchanged) + + Args: + full_name: The full symbol name as returned by AL Language Server + + Returns: + The extracted display name for matching, or the original name if not an AL object + + """ + match = ALLanguageServer._AL_OBJECT_NAME_PATTERN.match(full_name) + if match: + # Return quoted name (group 1) or unquoted name (group 2) + return match.group(1) or match.group(2) or full_name + return full_name + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Initialize the AL Language Server. + + Args: + config: Language server configuration + logger: Logger instance for debugging + repository_root_path: Root path of the AL project (must contain app.json) + solidlsp_settings: Solid LSP settings + + Note: + The initialization process will automatically: + 1. Check for AL extension in the resources directory + 2. Download it from VS Code marketplace if not found + 3. Extract and configure the platform-specific executable + + """ + # Setup runtime dependencies and get the language server command + # This will download the AL extension if needed + cmd = self._setup_runtime_dependencies(config, solidlsp_settings) + + self._project_load_check_supported: bool = True + """Whether the AL server supports the project load status check request. + + Some AL server versions don't support the 'al/hasProjectClosureLoadedRequest' + custom LSP request. This flag starts as True and is set to False if the + request fails, preventing repeated unsuccessful attempts. + """ + + super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), "al", solidlsp_settings) + + # Cache mapping (file_path, line, char) -> original_full_name for hover injection + self._al_original_names: dict[tuple[str, int, int], str] = {} + + @staticmethod + def _normalize_path(path: str) -> str: + """Normalize file path for consistent cache key usage across platforms.""" + return path.replace("\\", "/") + + @classmethod + def _download_al_extension(cls, url: str, target_dir: str, expected_sha256: str | None) -> bool: + """ + Download and extract the AL extension from VS Code marketplace. + + The VS Code marketplace packages extensions as .vsix files (which are ZIP archives). + This method downloads the VSIX file and extracts it to get the language server binaries. + + Note: + The download includes progress tracking and proper user-agent headers + to ensure compatibility with the VS Code marketplace. + + """ + try: + log.info(f"Downloading AL extension from {url}") + os.makedirs(target_dir, exist_ok=True) + FileUtils.download_and_extract_archive_verified( + url, + target_dir, + "zip", + expected_sha256=expected_sha256, + allowed_hosts=AL_EXTENSION_ALLOWED_HOSTS, + ) + log.info("AL extension extracted successfully") + return True + + except Exception as e: + log.error(f"Error downloading/extracting AL extension: {e}") + return False + + @classmethod + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str: + """ + Setup runtime dependencies for AL Language Server and return the command to start the server. + + This method handles the complete setup process: + 1. Checks for existing AL extension installations + 2. Downloads from VS Code marketplace if not found + 3. Configures executable permissions on Unix systems + 4. Returns the properly formatted command string + + The AL Language Server executable is located in different paths based on the platform: + - Windows: bin/win32/Microsoft.Dynamics.Nav.EditorServices.Host.exe + - Linux: bin/linux/Microsoft.Dynamics.Nav.EditorServices.Host + - macOS: bin/darwin/Microsoft.Dynamics.Nav.EditorServices.Host + """ + system = platform.system() + + # Find existing extension or download if needed + extension_path = cls._find_al_extension(solidlsp_settings) + if extension_path is None: + log.info("AL extension not found on disk, attempting to download...") + extension_path = cls._download_and_install_al_extension(solidlsp_settings) + + if extension_path is None: + raise RuntimeError( + "Failed to locate or download AL Language Server. Please either:\n" + "1. Set AL_EXTENSION_PATH environment variable to the AL extension directory\n" + "2. Install the AL extension in VS Code (ms-dynamics-smb.al)\n" + "3. Ensure internet connection for automatic download" + ) + + # Build executable path based on platform + executable_path = cls._get_executable_path(extension_path, system) + + if not os.path.exists(executable_path): + raise RuntimeError(f"AL Language Server executable not found at: {executable_path}") + + # Prepare and return the executable command + return cls._prepare_executable(executable_path, system) + + @classmethod + def _find_al_extension(cls, solidlsp_settings: SolidLSPSettings) -> str | None: + """ + Find AL extension in various locations. + + Search order: + 1. Environment variable (AL_EXTENSION_PATH) + 2. Default download location (~/.serena/ls_resources/al-extension) + 3. VS Code installed extensions + + Returns: + Path to AL extension directory or None if not found + + """ + # Check environment variable + env_path = os.environ.get("AL_EXTENSION_PATH") + if env_path and os.path.exists(env_path): + log.debug(f"Found AL extension via AL_EXTENSION_PATH: {env_path}") + return env_path + elif env_path: + log.warning(f"AL_EXTENSION_PATH set but directory not found: {env_path}") + + # Check the resolved-version download location (versioned for non-INITIAL, legacy "al-extension" for INITIAL) + al_settings = solidlsp_settings.get_ls_specific_settings(Language.AL) + al_extension_version = al_settings.get("al_extension_version", DEFAULT_AL_EXTENSION_VERSION) + default_path = os.path.join(cls.ls_resources_dir(solidlsp_settings), _al_extension_dirname(al_extension_version), "extension") + if os.path.exists(default_path): + log.debug(f"Found AL extension in default location: {default_path}") + return default_path + + # Search VS Code extensions + vscode_path = cls._find_al_extension_in_vscode() + if vscode_path: + log.debug(f"Found AL extension in VS Code: {vscode_path}") + return vscode_path + + log.debug("AL extension not found in any known location") + return None + + @classmethod + def _download_and_install_al_extension(cls, solidlsp_settings: SolidLSPSettings) -> str | None: + """ + Download and install AL extension from VS Code marketplace. + + Returns: + Path to installed extension or None if download failed + + """ + al_settings = solidlsp_settings.get_ls_specific_settings(Language.AL) + al_extension_version = al_settings.get("al_extension_version", DEFAULT_AL_EXTENSION_VERSION) + al_extension_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), _al_extension_dirname(al_extension_version)) + al_extension_url = ( + "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ms-dynamics-smb/" + f"vsextensions/al/{al_extension_version}/vspackage" + ) + + log.info(f"Downloading AL extension from: {al_extension_url}") + + if cls._download_al_extension(al_extension_url, al_extension_dir, _al_extension_sha(al_extension_version)): + extension_path = os.path.join(al_extension_dir, "extension") + if os.path.exists(extension_path): + log.info("AL extension downloaded and installed successfully") + return extension_path + else: + log.error(f"Download completed but extension not found at: {extension_path}") + else: + log.error("Failed to download AL extension from marketplace") + + return None + + @classmethod + def _get_executable_path(cls, extension_path: str, system: str) -> str: + """ + Build platform-specific executable path. + + Args: + extension_path: Path to AL extension directory + system: Operating system name + + Returns: + Full path to executable + + """ + if system == "Windows": + return os.path.join(extension_path, "bin", "win32", "Microsoft.Dynamics.Nav.EditorServices.Host.exe") + elif system == "Linux": + return os.path.join(extension_path, "bin", "linux", "Microsoft.Dynamics.Nav.EditorServices.Host") + elif system == "Darwin": + return os.path.join(extension_path, "bin", "darwin", "Microsoft.Dynamics.Nav.EditorServices.Host") + else: + raise RuntimeError(f"Unsupported platform: {system}") + + @classmethod + def _prepare_executable(cls, executable_path: str, system: str) -> str: + """ + Prepare the executable by setting permissions and handling path quoting. + + Args: + executable_path: Path to the executable + system: Operating system name + logger: Logger instance + + Returns: + Properly formatted command string + + """ + # Make sure executable has proper permissions on Unix-like systems + if system in ["Linux", "Darwin"]: + st = os.stat(executable_path) + os.chmod(executable_path, st.st_mode | stat.S_IEXEC) + log.debug(f"Set execute permission on: {executable_path}") + + log.info(f"Using AL Language Server executable: {executable_path}") + + # The AL Language Server uses stdio for LSP communication by default + # Use the utility function to handle Windows path quoting + return quote_windows_path(executable_path) + + @classmethod + def _get_language_server_command_fallback(cls) -> str: + """ + Get the command to start the AL language server. + + Returns: + Command string to launch the AL language server + + Raises: + RuntimeError: If AL extension cannot be found + + """ + # Check if AL extension path is configured via environment variable + al_extension_path = os.environ.get("AL_EXTENSION_PATH") + + if not al_extension_path: + # Try to find the extension in the current working directory + # (for development/testing when extension is in the serena repo) + cwd_path = Path.cwd() + potential_extension = None + + # Look for ms-dynamics-smb.al-* directories + for item in cwd_path.iterdir(): + if item.is_dir() and item.name.startswith("ms-dynamics-smb.al-"): + potential_extension = item + break + + if potential_extension: + al_extension_path = str(potential_extension) + log.debug(f"Found AL extension in current directory: {al_extension_path}") + else: + # Try to find in common VS Code extension locations + al_extension_path = cls._find_al_extension_in_vscode() + + if not al_extension_path: + raise RuntimeError( + "AL Language Server not found. Please either:\n" + "1. Set AL_EXTENSION_PATH environment variable to the VS Code AL extension directory\n" + "2. Install the AL extension in VS Code (ms-dynamics-smb.al)\n" + "3. Place the extension directory in the current working directory" + ) + + # Determine platform-specific executable + system = platform.system() + if system == "Windows": + executable = os.path.join(al_extension_path, "bin", "win32", "Microsoft.Dynamics.Nav.EditorServices.Host.exe") + elif system == "Linux": + executable = os.path.join(al_extension_path, "bin", "linux", "Microsoft.Dynamics.Nav.EditorServices.Host") + elif system == "Darwin": + executable = os.path.join(al_extension_path, "bin", "darwin", "Microsoft.Dynamics.Nav.EditorServices.Host") + else: + raise RuntimeError(f"Unsupported platform: {system}") + + # Verify executable exists + if not os.path.exists(executable): + raise RuntimeError( + f"AL Language Server executable not found at: {executable}\nPlease ensure the AL extension is properly installed." + ) + + # Make sure executable has proper permissions on Unix-like systems + if system in ["Linux", "Darwin"]: + st = os.stat(executable) + os.chmod(executable, st.st_mode | stat.S_IEXEC) + + log.info(f"Using AL Language Server executable: {executable}") + + # The AL Language Server uses stdio for LSP communication (no --stdio flag needed) + # Use the utility function to handle Windows path quoting + return quote_windows_path(executable) + + @classmethod + def _find_al_extension_in_vscode(cls) -> str | None: + """ + Try to find AL extension in common VS Code extension locations. + + Returns: + Path to AL extension directory or None if not found + + """ + home = Path.home() + possible_paths = [] + + # Common VS Code extension paths + if platform.system() == "Windows": + possible_paths.extend( + [ + home / ".vscode" / "extensions", + home / ".vscode-insiders" / "extensions", + Path(os.environ.get("APPDATA", "")) / "Code" / "User" / "extensions", + Path(os.environ.get("APPDATA", "")) / "Code - Insiders" / "User" / "extensions", + ] + ) + else: + possible_paths.extend( + [ + home / ".vscode" / "extensions", + home / ".vscode-server" / "extensions", + home / ".vscode-insiders" / "extensions", + ] + ) + + for base_path in possible_paths: + if base_path.exists(): + log.debug(f"Searching for AL extension in: {base_path}") + # Look for AL extension directories + for item in base_path.iterdir(): + if item.is_dir() and item.name.startswith("ms-dynamics-smb.al-"): + log.debug(f"Found AL extension at: {item}") + return str(item) + + return None + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the AL Language Server. + """ + # AL requires extensive capabilities based on VS Code trace + initialize_params = { + "capabilities": { + "workspace": { + "applyEdit": True, + "workspaceEdit": { + "documentChanges": True, + "resourceOperations": ["create", "rename", "delete"], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": True, + }, + "configuration": True, + "didChangeWatchedFiles": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}}, + "executeCommand": {"dynamicRegistration": True}, + "didChangeConfiguration": {"dynamicRegistration": True}, + "workspaceFolders": True, + }, + "textDocument": { + "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, + "completion": { + "dynamicRegistration": True, + "contextSupport": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + }, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentHighlight": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "hierarchicalDocumentSymbolSupport": True, + }, + "codeAction": {"dynamicRegistration": True}, + "formatting": {"dynamicRegistration": True}, + "rangeFormatting": {"dynamicRegistration": True}, + "rename": {"dynamicRegistration": True, "prepareSupport": True}, + }, + "window": { + "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, + "showDocument": {"support": True}, + "workDoneProgress": True, + }, + }, + "trace": "verbose", + } + + return initialize_params + + @override + def _start_server(self) -> None: + """ + Starts the AL Language Server process and initializes it. + + This method sets up custom notification handlers for AL-specific messages + before starting the server. The AL server sends various notifications + during initialization and project loading that need to be handled. + """ + + # Set up event handlers + def do_nothing(params: str) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"AL LSP: window/logMessage: {msg}") + + def publish_diagnostics(params: dict) -> None: + # AL server publishes diagnostics during initialization + uri = params.get("uri", "") + diagnostics = params.get("diagnostics", []) + log.debug(f"AL LSP: Diagnostics for {uri}: {len(diagnostics)} issues") + + def handle_al_notifications(params: dict) -> None: + # AL server sends custom notifications during project loading + log.debug("AL LSP: Notification received") + + # Register handlers for AL-specific notifications + # These notifications are sent by the AL server during initialization and operation + self.server.on_notification("window/logMessage", window_log_message) # Server log messages + self.server.on_notification("textDocument/publishDiagnostics", publish_diagnostics) # Compilation diagnostics + self.server.on_notification("$/progress", do_nothing) # Progress notifications during loading + self.server.on_notification("al/refreshExplorerObjects", handle_al_notifications) # AL-specific object updates + + # Start the server process + log.info("Starting AL Language Server process") + self.server.start() + + # Send initialize request + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to AL LSP server and awaiting response") + + # Send initialize and wait for response + resp = self.server.send_request("initialize", dict(initialize_params)) + if resp is None: + raise RuntimeError("AL Language Server initialization failed - no response") + + log.info("AL Language Server initialized successfully") + + # Send initialized notification + self.server.send_notification("initialized", {}) + log.info("Sent initialized notification") + + @override + def start(self) -> "ALLanguageServer": + """ + Start the AL Language Server with special initialization. + """ + # Call parent start method + super().start() + + # AL-specific post-initialization + self._post_initialize_al_workspace() + + # Note: set_active_workspace() can be called manually if needed for multi-workspace scenarios + # We don't call it automatically to avoid issues during single-workspace initialization + + return self + + def _post_initialize_al_workspace(self) -> None: + """ + Post-initialization setup for AL Language Server. + + The AL server requires additional setup after initialization: + 1. Send workspace configuration - provides AL settings and paths + 2. Open app.json to trigger project loading - AL uses app.json to identify project structure + 3. Optionally wait for project to be loaded if supported + + This special initialization sequence is unique to AL and necessary for proper + symbol resolution and navigation features. + """ + # No sleep needed - server is already initialized + + # Send workspace configuration first + # This tells AL about assembly paths, package caches, and code analysis settings + try: + self.server.send_notification( + "workspace/didChangeConfiguration", + { + "settings": { + "workspacePath": self.repository_root_path, + "alResourceConfigurationSettings": { + "assemblyProbingPaths": ["./.netpackages"], + "codeAnalyzers": [], + "enableCodeAnalysis": False, + "backgroundCodeAnalysis": "Project", + "packageCachePaths": ["./.alpackages"], + "ruleSetPath": None, + "enableCodeActions": True, + "incrementalBuild": False, + "outputAnalyzerStatistics": True, + "enableExternalRulesets": True, + }, + "setActiveWorkspace": True, + "expectedProjectReferenceDefinitions": [], + "activeWorkspaceClosure": [self.repository_root_path], + } + }, + ) + log.debug("Sent workspace configuration") + except Exception as e: + log.warning(f"Failed to send workspace config: {e}") + + # Check if app.json exists and open it + # app.json is the AL project manifest file (similar to package.json for Node.js) + # Opening it triggers AL to load the project and index all AL files + app_json_path = Path(self.repository_root_path) / "app.json" + if app_json_path.exists(): + try: + with open(app_json_path, encoding="utf-8") as f: + app_json_content = f.read() + + # Use forward slashes for URI + app_json_uri = app_json_path.as_uri() + + # Send textDocument/didOpen for app.json + self.server.send_notification( + "textDocument/didOpen", + {"textDocument": {"uri": app_json_uri, "languageId": "json", "version": 1, "text": app_json_content}}, + ) + + log.debug(f"Opened app.json: {app_json_uri}") + except Exception as e: + log.warning(f"Failed to open app.json: {e}") + + # Try to set active workspace (AL-specific custom LSP request) + # This is optional and may not be supported by all AL server versions + workspace_uri = Path(self.repository_root_path).resolve().as_uri() + try: + result = self.server.send_request( + "al/setActiveWorkspace", + { + "currentWorkspaceFolderPath": {"uri": workspace_uri, "name": Path(self.repository_root_path).name, "index": 0}, + "settings": { + "workspacePath": self.repository_root_path, + "setActiveWorkspace": True, + }, + "timeout": 2, # Quick timeout since this is optional + }, + ) + log.debug(f"Set active workspace result: {result}") + except Exception as e: + # This is a custom AL request, not critical if it fails + log.debug(f"Failed to set active workspace (non-critical): {e}") + + # Check if project supports load status check (optional) + # Many AL server versions don't support this, so we use a short timeout + # and continue regardless of the result + self._wait_for_project_load(timeout=3) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + """ + Define AL-specific directories to ignore during file scanning. + + These directories contain generated files, dependencies, or cache data + that should not be analyzed for symbols. + + Args: + dirname: Directory name to check + + Returns: + True if directory should be ignored + + """ + al_ignore_dirs = { + ".alpackages", # AL package cache - downloaded dependencies + ".alcache", # AL compiler cache - intermediate compilation files + ".altemplates", # AL templates - code generation templates + ".snapshots", # Test snapshots - test result snapshots + "out", # Compiled output - generated .app files + ".vscode", # VS Code settings - editor configuration + "Reference", # Reference assemblies - .NET dependencies + ".netpackages", # .NET packages - NuGet packages for AL + "bin", # Binary output - compiled binaries + "obj", # Object files - intermediate build artifacts + } + + # Check parent class ignore list first, then AL-specific + return super().is_ignored_dirname(dirname) or dirname in al_ignore_dirs + + @override + def request_full_symbol_tree(self, within_relative_path: str | None = None) -> list[UnifiedSymbolInformation]: + """ + Override to handle AL's requirement of opening files before requesting symbols. + + The AL Language Server requires files to be explicitly opened via textDocument/didOpen + before it can provide meaningful symbols. Without this, it only returns directory symbols. + This is different from most language servers which can provide symbols for unopened files. + + This method: + 1. Scans the repository for all AL files (.al and .dal extensions) + 2. Opens each file with the AL server + 3. Requests symbols for each file + 4. Combines all symbols into a hierarchical tree structure + 5. Closes the files to free resources + + Args: + within_relative_path: Restrict search to this file or directory path + include_body: Whether to include symbol body content + + Returns: + Full symbol tree with all AL symbols from opened files organized by directory + + """ + log.debug("AL: Starting request_full_symbol_tree with file opening") + + # Determine the root path for scanning + if within_relative_path is not None: + within_abs_path = os.path.join(self.repository_root_path, within_relative_path) + if not os.path.exists(within_abs_path): + raise FileNotFoundError(f"File or directory not found: {within_abs_path}") + + if os.path.isfile(within_abs_path): + # Single file case - use parent class implementation + root_nodes = self.request_document_symbols(within_relative_path).root_symbols + return root_nodes + + # Directory case - scan within this directory + scan_root = Path(within_abs_path) + else: + # Scan entire repository + scan_root = Path(self.repository_root_path) + + # For AL, we always need to open files to get symbols + al_files = [] + + # Walk through the repository to find all AL files + for root, dirs, files in os.walk(scan_root): + # Skip ignored directories + dirs[:] = [d for d in dirs if not self.is_ignored_dirname(d)] + + # Find AL files + for file in files: + if file.endswith((".al", ".dal")): + file_path = Path(root) / file + # Use forward slashes for consistent paths + try: + relative_path = str(file_path.relative_to(self.repository_root_path)).replace("\\", "/") + al_files.append((file_path, relative_path)) + except ValueError: + # File is outside repository root, skip it + continue + + log.debug(f"AL: Found {len(al_files)} AL files") + + if not al_files: + log.warning("AL: No AL files found in repository") + return [] + + # Collect all symbols from all files + all_file_symbols: list[UnifiedSymbolInformation] = [] + + file_symbol: UnifiedSymbolInformation + for file_path, relative_path in al_files: + try: + # Use our overridden request_document_symbols which handles opening + log.debug(f"AL: Getting symbols for {relative_path}") + all_syms, root_syms = self.request_document_symbols(relative_path).get_all_symbols_and_roots() + + if root_syms: + # Create a file-level symbol containing the document symbols + file_symbol = { + "name": file_path.stem, # Just the filename without extension + "kind": SymbolKind.File, + "children": root_syms, + "location": { + "uri": file_path.as_uri(), + "relativePath": relative_path, + "absolutePath": str(file_path), + "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, + }, + } + all_file_symbols.append(file_symbol) + log.debug(f"AL: Added {len(root_syms)} symbols from {relative_path}") + elif all_syms: + # If we only got all_syms but not root, use all_syms + file_symbol = { + "name": file_path.stem, + "kind": SymbolKind.File, + "children": all_syms, + "location": { + "uri": file_path.as_uri(), + "relativePath": relative_path, + "absolutePath": str(file_path), + "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, + }, + } + all_file_symbols.append(file_symbol) + log.debug(f"AL: Added {len(all_syms)} symbols from {relative_path}") + + except Exception as e: + log.warning(f"AL: Failed to get symbols for {relative_path}: {e}") + + if all_file_symbols: + log.debug(f"AL: Returning symbols from {len(all_file_symbols)} files") + + # Group files by directory + directory_structure: dict[str, list] = {} + + for file_symbol in all_file_symbols: + rel_path = file_symbol["location"]["relativePath"] + assert rel_path is not None + path_parts = rel_path.split("/") + + if len(path_parts) > 1: + # File is in a subdirectory + dir_path = "/".join(path_parts[:-1]) + if dir_path not in directory_structure: + directory_structure[dir_path] = [] + directory_structure[dir_path].append(file_symbol) + else: + # File is in root + if "." not in directory_structure: + directory_structure["."] = [] + directory_structure["."].append(file_symbol) + + # Build hierarchical structure + result = [] + repo_path = Path(self.repository_root_path) + for dir_path, file_symbols in directory_structure.items(): + if dir_path == ".": + # Root level files + result.extend(file_symbols) + else: + # Create directory symbol + dir_symbol = { + "name": Path(dir_path).name, + "kind": SymbolKind.Package, # Package/Directory + "children": file_symbols, + "location": { + "relativePath": dir_path, + "absolutePath": str(repo_path / dir_path), + "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, + }, + } + result.append(dir_symbol) + + return result + else: + log.warning("AL: No symbols found in any files") + return [] + + # ===== Phase 1: Custom AL Command Implementations ===== + + @override + def _send_definition_request(self, definition_params: DefinitionParams) -> Definition | list[LocationLink] | None: + """ + Override to use AL's custom gotodefinition command. + + AL Language Server uses 'al/gotodefinition' instead of the standard + 'textDocument/definition' request. This custom command provides better + navigation for AL-specific constructs like table extensions, page extensions, + and codeunit references. + + If the custom command fails, we fall back to the standard LSP method. + """ + # Convert standard params to AL format (same structure, different method) + al_params = {"textDocument": definition_params["textDocument"], "position": definition_params["position"]} + + try: + # Use custom AL command instead of standard LSP + response = self.server.send_request("al/gotodefinition", al_params) + log.debug(f"AL gotodefinition response: {response}") + return response # type: ignore[return-value] + except Exception as e: + log.warning(f"Failed to use al/gotodefinition, falling back to standard: {e}") + # Fallback to standard LSP method if custom command fails + return super()._send_definition_request(definition_params) + + def check_project_loaded(self) -> bool: + """ + Check if AL project closure is fully loaded. + + Uses AL's custom 'al/hasProjectClosureLoadedRequest' to determine if + the project and all its dependencies have been fully loaded and indexed. + This is important because AL operations may fail or return incomplete + results if the project is still loading. + + Returns: + bool: True if project is loaded, False otherwise + + """ + if not hasattr(self, "server") or not self.server_started: + log.debug("Cannot check project load - server not started") + return False + + # Check if we've already determined this request isn't supported + if not self._project_load_check_supported: + return True # Assume loaded if check isn't supported + + try: + # Use a very short timeout since this is just a status check + response = self.server.send_request("al/hasProjectClosureLoadedRequest", {"timeout": 1}) + # Response can be boolean directly, dict with 'loaded' field, or None + if isinstance(response, bool): + return response + elif isinstance(response, dict): + return response.get("loaded", False) + elif response is None: + # None typically means the project is still loading + log.debug("Project load check returned None") + return False + else: + log.debug(f"Unexpected response type for project load check: {type(response)}") + return False + except Exception as e: + # Mark as unsupported to avoid repeated failed attempts + self._project_load_check_supported = False + log.debug(f"Project load check not supported by this AL server version: {e}") + # Assume loaded if we can't check + return True + + def _wait_for_project_load(self, timeout: int = 3) -> bool: + """ + Wait for project to be fully loaded. + + Polls the AL server to check if the project is loaded. + This is optional as not all AL server versions support this check. + We use a short timeout and continue regardless of the result. + + Args: + timeout: Maximum time to wait in seconds (default 3s) + + Returns: + bool: True if project loaded within timeout, False otherwise + + """ + start_time = time.time() + log.debug(f"Checking AL project load status (timeout: {timeout}s)...") + + while time.time() - start_time < timeout: + if self.check_project_loaded(): + elapsed = time.time() - start_time + log.info(f"AL project fully loaded after {elapsed:.1f}s") + return True + time.sleep(0.5) + + log.debug(f"Project load check timed out after {timeout}s (non-critical)") + return False + + def set_active_workspace(self, workspace_uri: str | None = None) -> None: + """ + Set the active AL workspace. + + This is important when multiple workspaces exist to ensure operations + target the correct workspace. The AL server can handle multiple projects + simultaneously, but only one can be "active" at a time for operations + like symbol search and navigation. + + This uses the custom 'al/setActiveWorkspace' LSP command. + + Args: + workspace_uri: URI of workspace to set as active, or None to use repository root + + """ + if not hasattr(self, "server") or not self.server_started: + log.debug("Cannot set active workspace - server not started") + return + + if workspace_uri is None: + workspace_uri = Path(self.repository_root_path).resolve().as_uri() + + params = {"workspaceUri": workspace_uri} + + try: + self.server.send_request("al/setActiveWorkspace", params) + log.info(f"Set active workspace to: {workspace_uri}") + except Exception as e: + log.warning(f"Failed to set active workspace: {e}") + # Non-critical error, continue operation + + @override + def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: + """ + Override to normalize AL symbol names by stripping object type and ID metadata. + + AL Language Server returns symbol names with full object format like + 'Table 50000 "TEST Customer"', but symbol names should be pure without metadata. + This follows the same pattern as Java LS which strips type information from names. + + Metadata (object type, ID) is available via the hover LSP method when using + include_info=True in find_symbol. + """ + # Normalize path separators for cross-platform compatibility (backslash → forward slash) + relative_file_path = self._normalize_path(relative_file_path) + + # Get symbols from parent implementation + document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer) + + return document_symbols + + def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: + original_name = symbol["name"] + normalized_name = self._extract_al_display_name(original_name) + + if symbol.get("kind") in (SymbolKind.Function, SymbolKind.Method) and "(" in normalized_name: + normalized_name = normalized_name.split("(", 1)[0].strip() + + if symbol.get("kind") == SymbolKind.Method and normalized_name.lower().startswith("action "): + normalized_name = normalized_name.split(None, 1)[-1].strip() + + if symbol.get("kind") == SymbolKind.Field and ":" in normalized_name: + normalized_name = normalized_name.split(":", 1)[0].strip() + + # Store original name if it was normalized for an AL object declaration + # Only store if we have valid position data to avoid false matches at (0, 0) + if original_name != normalized_name and self._AL_OBJECT_NAME_PATTERN.match(original_name): + sel_range = symbol.get("selectionRange") + if sel_range: + start = sel_range.get("start") + if start and "line" in start and "character" in start: + line = start["line"] + char = start["character"] + self._al_original_names[(relative_file_path, line, char)] = original_name + + return normalized_name + + @override + def _document_symbols_cache_fingerprint(self) -> int: + normalize_symbol_name_version = 1 + return normalize_symbol_name_version + + @override + def request_hover( + self, relative_file_path: str, line: int, column: int, file_buffer: LSPFileBuffer | None = None + ) -> ls_types.Hover | None: + """ + Override to inject original AL object name (with type and ID) into hover responses. + + When hovering over a symbol whose name was normalized, we prepend the original + full name (e.g., 'Table 50000 "TEST Customer"') to the hover content. + """ + # Normalize path separators for cross-platform compatibility (backslash → forward slash) + relative_file_path = self._normalize_path(relative_file_path) + + hover = super().request_hover(relative_file_path, line, column, file_buffer=file_buffer) + + if hover is None: + return None + + # Check if we have an original name for this position + original_name = self._al_original_names.get((relative_file_path, line, column)) + + if original_name and "contents" in hover: + contents = hover["contents"] + if isinstance(contents, dict) and "value" in contents: + # Prepend the original full name to the hover content + prefix = f"**{original_name}**\n\n---\n\n" + contents["value"] = prefix + contents["value"] + + return hover diff --git a/src/solidlsp/language_servers/angular_language_server.py b/src/solidlsp/language_servers/angular_language_server.py new file mode 100644 index 0000000..e7bf231 --- /dev/null +++ b/src/solidlsp/language_servers/angular_language_server.py @@ -0,0 +1,696 @@ +""" +Angular Language Server integration for Serena. + +Architecture (tri-server, modeled on the Vue LS dual-server pattern but with +an additional HTML companion because ngserver does not implement +``textDocument/documentSymbol`` for any file type): + + ngserver (parent process, this class) + - handles .html templates: definition, references, hover, completion, + rename on template expressions (@if/@for/{{ }}/[prop]/(event)) + - handles .ts references (ngserver aggregates template + TS usages in + one pass; typescript-language-server alone misses template usages + and often returns partial cross-file .ts references on Angular + projects where files aren't pre-opened) + - exposes Angular-specific custom requests + (IsInAngularProject, GetComponentsWithTemplateFile, ...) + - DOES NOT implement ``textDocument/documentSymbol`` at all — returns + -32601 for every .html and we do not route documentSymbol to it. + + AngularTypeScriptServer (companion process, subclass of TypeScriptLanguageServer) + - handles .ts/.tsx/.cts/.mts documentSymbol, definition, hover, rename + - the @angular/language-service plugin is loaded into the companion + typescript-language-server via initializationOptions.plugins, which + makes tsserver Angular-aware for completions/hover on inline + templates. (Note: template *references* on .ts symbols are + empirically incomplete here; see ngserver routing above.) + + VsCodeHtmlLanguageServer (companion process) + - handles .html ``textDocument/documentSymbol`` only: returns the + structural element tree (``

``, ````, ````…) + which ngserver refuses to provide. Angular template directives like + ``@if``/``@for`` are passed through as text content, which is fine — + this companion is only for structural outline. + +Routing: + request_document_symbols(.ts) -> companion TS server + request_document_symbols(.html) -> companion HTML server + request_definition(.ts) -> companion TS server + request_definition(.html) -> ngserver + request_references(.ts) -> ngserver + request_references(.html) -> ngserver + request_hover(.ts) -> companion TS server + request_hover(.html) -> ngserver + request_rename_symbol_edit -> companion TS server (.ts), ngserver (.html) + +Hard project requirements (failure modes if violated): + * tsconfig.json at the repository root (or above any opened .ts file). + * @angular/core resolvable from that tsconfig, i.e. ``npm install`` has been + run in the project. Without it, ngserver's `isInAngularProject` returns + false for every file and template features silently return empty. +""" + +from __future__ import annotations + +import json +import logging +import os +import pathlib +import shutil +import threading + +from overrides import override + +from solidlsp import ls_types +from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command +from solidlsp.language_servers.typescript_language_server import ( + TypeScriptLanguageServer, + prefer_non_node_modules_definition, +) +from solidlsp.language_servers.vscode_html_language_server import VsCodeHtmlLanguageServer +from solidlsp.ls import LanguageServerDependencyProvider, LSPFileBuffer, SolidLanguageServer +from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig +from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, SymbolInformation +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +# Angular installs four interdependent npm packages into a single ``node_modules`` (npm +# hoists them so ngserver's plugin resolution works); the install-dir name encodes all +# four versions so a bump of any single one routes to a fresh subdir. +DEFAULT_ANGULAR_LANGUAGE_SERVER_VERSION = "21.2.10" +DEFAULT_ANGULAR_LANGUAGE_SERVICE_VERSION = "21.2.10" +DEFAULT_TYPESCRIPT_VERSION = "5.9.3" +DEFAULT_TYPESCRIPT_LANGUAGE_SERVER_VERSION = "5.1.3" +NGSERVER_BIN = "ngserver" +TSLS_BIN = "typescript-language-server" + + +class AngularTypeScriptServer(TypeScriptLanguageServer): + """ + Companion TypeScript Language Server configured with @angular/language-service + loaded as a tsserver plugin. The plugin makes tsserver understand Angular + decorators, inline templates, templateUrl/styleUrls navigation, and + cross-file references that span Angular templates. + """ + + @classmethod + @override + def get_language_enum_instance(cls) -> Language: + return Language.TYPESCRIPT + + def get_source_fn_matcher(self) -> FilenameMatcher: + # Use the Angular matcher so .html template files aren't filtered out of + # reference / search results when the companion is asked about them. + return Language.ANGULAR.get_source_fn_matcher() + + class DependencyProvider(TypeScriptLanguageServer.DependencyProvider): + """Dependency provider that returns a pre-resolved executable path. + + The Angular LS install (run by ``AngularLanguageServer._setup_runtime_dependencies``) + already locates the ``typescript-language-server`` binary alongside ngserver, + so the companion does not need to perform another install lookup — it just + returns the path it was constructed with. + """ + + def __init__( + self, + custom_settings: SolidLSPSettings.CustomLSSettings, + ls_resources_dir: str, + explicit_executable_path: str, + ) -> None: + super().__init__(custom_settings, ls_resources_dir) + self._explicit_executable_path = explicit_executable_path + + @override + def _get_or_install_core_dependency(self) -> str: + return self._explicit_executable_path + + @override + def _get_language_id_for_file(self, relative_file_path: str) -> str: + ext = os.path.splitext(relative_file_path)[1].lower() + if ext in (".ts", ".tsx", ".mts", ".cts"): + return "typescript" + if ext in (".js", ".jsx", ".mjs", ".cjs"): + return "javascript" + if ext in (".html", ".htm"): + return "html" + return "typescript" + + def __init__( + self, + config: LanguageServerConfig, + repository_root_path: str, + solidlsp_settings: SolidLSPSettings, + angular_plugin_path: str, + tsdk_path: str, + ts_ls_executable_path: str, + ): + self._angular_plugin_path = angular_plugin_path + self._custom_tsdk_path = tsdk_path + # Stored as instance state so the override survives across concurrent + # constructions of multiple AngularLanguageServer instances. The class + # attribute pattern this replaces was racy: two parallel constructors + # could see each other's value in the brief window between assignment + # and reset. + self._explicit_ts_ls_executable = ts_ls_executable_path + super().__init__(config, repository_root_path, solidlsp_settings) + + @override + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider( + self._custom_settings, + self._ls_resources_dir, + self._explicit_ts_ls_executable, + ) + + @override + def _create_base_initialize_params(self) -> dict: + params = super()._create_base_initialize_params() + # Load @angular/language-service as a tsserver plugin via typescript-language-server's + # initializationOptions.plugins API (the same API Vue uses for @vue/typescript-plugin). + params["initializationOptions"] = { + "plugins": [ + { + "name": "@angular/language-service", + "location": self._angular_plugin_path, + "languages": ["html"], + } + ], + "tsserver": { + "path": self._custom_tsdk_path, + }, + } + if "workspace" in params["capabilities"]: + params["capabilities"]["workspace"]["executeCommand"] = {"dynamicRegistration": True} + return params + + @override + def _start_server(self) -> None: + def workspace_configuration_handler(params: dict) -> list: + items = params.get("items", []) + return [{} for _ in items] + + self.server.on_request("workspace/configuration", workspace_configuration_handler) + super()._start_server() + + +class AngularLanguageServer(SolidLanguageServer): + """ + Angular Language Server — dual-process orchestration of ngserver + a companion + typescript-language-server with the @angular/language-service plugin loaded. + + ``ls_specific_settings["angular"]`` keys: + * ``angular_language_server_version``: version of ``@angular/language-server`` + (default: ``21.2.10``). + * ``angular_language_service_version``: version of ``@angular/language-service`` + (default: matches the language-server version). + * ``typescript_version``: TypeScript version installed for the companion + (default: ``5.9.3``). + * ``typescript_language_server_version``: typescript-language-server version + (default: ``5.1.3``). + * ``npm_registry``: optional alternative npm registry URL. + """ + + NG_SERVER_READY_TIMEOUT = 10.0 + TS_SERVER_READY_TIMEOUT = 10.0 + HTML_SERVER_READY_TIMEOUT = 10.0 + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + ng_executable, self._tsdk_path, self._ts_ls_executable, self._angular_plugin_path, self._install_dir = ( + self._setup_runtime_dependencies(config, solidlsp_settings) + ) + ng_cmd = [ + ng_executable, + "--stdio", + "--tsProbeLocations", + os.path.join(self._install_dir, "node_modules"), + "--ngProbeLocations", + os.path.join(self._install_dir, "node_modules"), + ] + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=ng_cmd, cwd=repository_root_path), + "angular", + solidlsp_settings, + ) + self.server_ready = threading.Event() + self._ts_server: AngularTypeScriptServer | None = None + self._ts_server_started = False + self._html_server: VsCodeHtmlLanguageServer | None = None + self._html_server_started = False + + @classmethod + @override + def supports_implementation_request(cls) -> bool: + # Angular templates and components are TypeScript code under the hood — ngserver + # delegates to tsserver, which supports textDocument/implementation for class + # members and interfaces (e.g. resolving an Angular lifecycle hook on a component + # back to its OnInit/OnDestroy interface declaration). + return True + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [ + "node_modules", + "dist", + "build", + "coverage", + ".angular", + ".nx", + ] + + @override + def _get_language_id_for_file(self, relative_file_path: str) -> str: + ext = os.path.splitext(relative_file_path)[1].lower() + if ext in (".ts", ".tsx", ".mts", ".cts"): + return "typescript" + if ext in (".js", ".jsx", ".mjs", ".cjs"): + return "javascript" + if ext in (".html", ".htm"): + return "html" + return "typescript" + + @staticmethod + def _is_typescript_file(file_path: str) -> bool: + return os.path.splitext(file_path)[1].lower() in (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs") + + @staticmethod + def _is_html_template_file(file_path: str) -> bool: + return os.path.splitext(file_path)[1].lower() in (".html", ".htm") + + @classmethod + def _setup_runtime_dependencies( + cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings + ) -> tuple[str, str, str, str, str]: + """ + Install the Angular LS stack into the managed ls_resources_dir. + + :return: tuple of (ngserver_path, tsdk_path, ts_ls_executable_path, angular_plugin_path, install_dir) + """ + assert shutil.which("node") is not None, "node is not installed or isn't in PATH. Please install NodeJS and try again." + assert shutil.which("npm") is not None, "npm is not installed or isn't in PATH. Please install npm and try again." + + ng_settings = solidlsp_settings.get_ls_specific_settings(Language.ANGULAR) + ts_settings = solidlsp_settings.get_ls_specific_settings(Language.TYPESCRIPT) + ls_version = ng_settings.get("angular_language_server_version", DEFAULT_ANGULAR_LANGUAGE_SERVER_VERSION) + svc_version = ng_settings.get("angular_language_service_version", DEFAULT_ANGULAR_LANGUAGE_SERVICE_VERSION) + ts_version = ng_settings.get("typescript_version", ts_settings.get("typescript_version", DEFAULT_TYPESCRIPT_VERSION)) + tsls_version = ng_settings.get( + "typescript_language_server_version", + ts_settings.get("typescript_language_server_version", DEFAULT_TYPESCRIPT_LANGUAGE_SERVER_VERSION), + ) + npm_registry = ng_settings.get("npm_registry", ts_settings.get("npm_registry")) + + # Fully-versioned subdir so a bump of any single package cannot silently reuse + # stale companions in a shared node_modules. + ls_dirname = f"angular-lsp-{ls_version}-{svc_version}-{ts_version}-{tsls_version}" + install_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), ls_dirname) + ng_executable = os.path.join(install_dir, "node_modules", ".bin", NGSERVER_BIN) + ts_ls_executable = os.path.join(install_dir, "node_modules", ".bin", TSLS_BIN) + if os.name == "nt": + ng_executable += ".cmd" + ts_ls_executable += ".cmd" + + tsdk_path = os.path.join(install_dir, "node_modules", "typescript", "lib") + angular_plugin_path = os.path.join(install_dir, "node_modules", "@angular", "language-service") + + if not (os.path.exists(ng_executable) and os.path.exists(ts_ls_executable)): + log.info( + "Installing Angular LS stack: ngserver=%s, language-service=%s, typescript=%s, typescript-language-server=%s", + ls_version, + svc_version, + ts_version, + tsls_version, + ) + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="typescript", + description="typescript (tsserver runtime, used by ngserver and the companion TS LS)", + command=build_npm_install_command("typescript", ts_version, npm_registry), + platform_id="any", + ), + RuntimeDependency( + id="@angular/language-service", + description="Angular language service tsserver plugin", + command=build_npm_install_command("@angular/language-service", svc_version, npm_registry), + platform_id="any", + ), + RuntimeDependency( + id="@angular/language-server", + description="Angular language server (ngserver binary)", + command=build_npm_install_command("@angular/language-server", ls_version, npm_registry), + platform_id="any", + ), + RuntimeDependency( + id="typescript-language-server", + description="typescript-language-server (companion LS for .ts operations)", + command=build_npm_install_command("typescript-language-server", tsls_version, npm_registry), + platform_id="any", + ), + ] + ) + deps.install(install_dir) + + for path, label in ( + (ng_executable, NGSERVER_BIN), + (ts_ls_executable, TSLS_BIN), + (angular_plugin_path, "@angular/language-service"), + (os.path.join(tsdk_path, "tsserverlibrary.js"), "typescript/lib/tsserverlibrary.js"), + ): + if not os.path.exists(path): + raise FileNotFoundError(f"Expected {label} at {path} after install, but it was not found.") + + return ng_executable, tsdk_path, ts_ls_executable, angular_plugin_path, install_dir + + def _start_typescript_server(self) -> None: + try: + ts_config = LanguageServerConfig(code_language=Language.TYPESCRIPT, trace_lsp_communication=False) + log.info("Creating companion AngularTypeScriptServer") + self._ts_server = AngularTypeScriptServer( + config=ts_config, + repository_root_path=self.repository_root_path, + solidlsp_settings=self._solidlsp_settings, + angular_plugin_path=self._angular_plugin_path, + tsdk_path=self._tsdk_path, + ts_ls_executable_path=self._ts_ls_executable, + ) + log.info("Starting companion TypeScript server") + self._ts_server.start() + log.info("Waiting for companion TypeScript server to be ready...") + if not self._ts_server.server_ready.wait(timeout=self.TS_SERVER_READY_TIMEOUT): + log.warning("Companion TS server ready timeout (%s s); proceeding anyway", self.TS_SERVER_READY_TIMEOUT) + self._ts_server.server_ready.set() + self._ts_server_started = True + log.info("Companion TypeScript server ready") + except Exception: + log.exception("Error starting companion TypeScript server") + self._ts_server = None + self._ts_server_started = False + raise + + def _stop_typescript_server(self) -> None: + if self._ts_server is not None: + try: + log.info("Stopping companion TypeScript server") + self._ts_server.stop() + except Exception as e: + log.warning(f"Error stopping companion TypeScript server: {e}") + finally: + self._ts_server = None + self._ts_server_started = False + + def _start_html_server(self) -> None: + """Spawn vscode-html-language-server as a tertiary companion. + + ngserver does not implement ``textDocument/documentSymbol`` (returns + -32601 for every .html file — both plain HTML and Angular templates). + The HTML companion provides the structural element outline the user + expects from documentSymbol on .html files. Failure to start it is + non-fatal: we log and fall back to returning an empty list. + """ + try: + html_config = LanguageServerConfig(code_language=Language.HTML, trace_lsp_communication=False) + log.info("Creating companion VsCodeHtmlLanguageServer") + self._html_server = VsCodeHtmlLanguageServer( + config=html_config, + repository_root_path=self.repository_root_path, + solidlsp_settings=self._solidlsp_settings, + ) + log.info("Starting companion HTML server") + self._html_server.start() + if not self._html_server.server_ready.wait(timeout=self.HTML_SERVER_READY_TIMEOUT): + log.warning("Companion HTML server ready timeout (%s s); proceeding anyway", self.HTML_SERVER_READY_TIMEOUT) + self._html_server.server_ready.set() + self._html_server_started = True + log.info("Companion HTML server ready") + except Exception: + log.exception("Error starting companion HTML server; .html documentSymbol will return []") + self._html_server = None + self._html_server_started = False + + def _stop_html_server(self) -> None: + if self._html_server is not None: + try: + log.info("Stopping companion HTML server") + self._html_server.stop() + except Exception as e: + log.warning(f"Error stopping companion HTML server: {e}") + finally: + self._html_server = None + self._html_server_started = False + + def _find_angular_core_install(self) -> str | None: + """Walk up from ``repository_root_path`` looking for ``node_modules/@angular/core``. + + Handles monorepo layouts (Nx, yarn/pnpm workspaces) where ``node_modules`` is + hoisted to a workspace root above the activated sub-package. Stops walking at: + the filesystem root, a mount-point change, or a ``package.json`` that declares + ``"workspaces"`` (the workspace root — no need to look further). + + :return: absolute path to the discovered ``@angular/core/package.json``, or None. + """ + cur = pathlib.Path(self.repository_root_path).resolve() + try: + start_dev = cur.stat().st_dev + except OSError: + start_dev = None + steps = 0 + for parent in [cur, *cur.parents]: + # Stop *before* probing across a mount-point change: a different + # st_dev typically means we've crossed a container/volume boundary + # and node_modules over there is unrelated. + if start_dev is not None: + try: + if parent.stat().st_dev != start_dev: + log.debug("Stopping @angular/core probe at %s after %d step(s) (mount-point change)", parent, steps) + break + except OSError: + break + steps += 1 + candidate = parent / "node_modules" / "@angular" / "core" / "package.json" + if candidate.exists(): + log.debug("Found @angular/core after %d step(s) at %s", steps, candidate) + return str(candidate) + workspace_pkg = parent / "package.json" + if workspace_pkg.exists(): + try: + with open(workspace_pkg, encoding="utf-8") as f: + if "workspaces" in json.load(f): + log.debug("Stopping @angular/core probe at workspace root %s after %d step(s)", parent, steps) + break + except (OSError, ValueError) as e: + log.debug("Could not parse %s as JSON dict (%s); ignoring as workspace marker", workspace_pkg, e) + else: + log.debug("@angular/core probe walked %d ancestor(s) without finding an install or a workspace root", steps) + return None + + def _check_angular_core_in_project(self) -> None: + """Warn loudly if the project does not appear to have @angular/core installed.""" + found = self._find_angular_core_install() + if found is None: + log.warning( + "Angular language server activated but @angular/core was not found in any " + "node_modules from %s upward. ngserver will report files as 'not in an " + "Angular project' and template-aware features will be disabled. Run " + "`npm install` in the workspace root to enable Angular features.", + self.repository_root_path, + ) + else: + log.debug("Found @angular/core at %s", found) + + def _create_base_initialize_params(self) -> dict: + params: dict = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "rename": {"dynamicRegistration": True, "prepareSupport": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + "initializationOptions": { + "ngProbeLocations": [os.path.join(self._install_dir, "node_modules")], + "tsProbeLocations": [os.path.join(self._install_dir, "node_modules")], + "forceStrictTemplates": False, + }, + } + return params + + @override + def _start_server(self) -> None: + self._check_angular_core_in_project() + # Start the companion TS server first so .ts operations are immediately available. + self._start_typescript_server() + # Start the HTML companion so .html documentSymbol works on first call. + self._start_html_server() + + def do_nothing(_params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def angular_project_loading_finish(_params: dict) -> None: + log.info("Angular project loading finished") + self.server_ready.set() + + # Standard LSP boilerplate + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("$/progress", do_nothing) + self.server.on_request("client/registerCapability", lambda _params: None) + self.server.on_request("workspace/configuration", lambda _params: [{}]) + + # Angular-specific notifications (custom protocol from ngserver) + self.server.on_notification("angular/projectLoadingStart", do_nothing) + self.server.on_notification("angular/projectLoadingFinish", angular_project_loading_finish) + self.server.on_notification("angular/projectLanguageService", do_nothing) + + # Companions are already running. If anything below fails, our caller never + # received an initialised handle and therefore can't invoke stop() — so we + # tear down both companions and any partially-started ngserver process here + # to avoid leaking Node processes. + try: + log.info("Starting Angular language server (ngserver)") + self.server.start() + init_params = self._create_initialize_params() + init_response = self.server.send.initialize(init_params) + log.debug("Angular LS initialize response: %s", init_response) + self.server.notify.initialized({}) + # ngserver loads the Angular compiler asynchronously after `initialized`. Wait briefly + # for projectLoadingFinish, then proceed regardless — operations queue inside ngserver. + # ngserver eagerly resolves the project once projectLoadingFinish fires; we previously + # ran a proactive .ts didOpen/didClose pass but empirical testing on real Angular + # projects (181 .ts / 85 .html) showed it added ~4s to cold start without improving + # first-query correctness or latency, so it has been removed. + if not self.server_ready.wait(timeout=self.NG_SERVER_READY_TIMEOUT): + log.info("Timeout waiting for ngserver project load; proceeding anyway") + self.server_ready.set() + except Exception: + self._stop_typescript_server() + self._stop_html_server() + try: + self.server.stop() + except Exception as e: + log.warning("Error stopping ngserver during startup-failure cleanup: %s", e) + raise + + @override + def stop(self, shutdown_timeout: float = 5.0) -> None: + self._stop_typescript_server() + self._stop_html_server() + super().stop(shutdown_timeout) + + @override + def _get_wait_time_for_cross_file_referencing(self) -> float: + return 5.0 + + @override + def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location: + return prefer_non_node_modules_definition(definitions) + + # --------------------------------------------------------------------- + # Request routing — see module docstring for rationale per (op, ext) pair. + # --------------------------------------------------------------------- + + @override + def _request_document_symbols( + self, relative_file_path: str, file_data: LSPFileBuffer | None + ) -> list[SymbolInformation] | list[DocumentSymbol] | None: + if self._ts_server is not None and self._is_typescript_file(relative_file_path): + with self._ts_server.open_file(relative_file_path): + return self._ts_server._request_document_symbols(relative_file_path, file_data=None) + # ngserver returns -32601 for textDocument/documentSymbol on every .html file. + # Route to the HTML companion which gives the structural element tree + # (works on both plain HTML like index.html and Angular templates). + if self._is_html_template_file(relative_file_path): + if self._html_server is not None and self._html_server_started: + with self._html_server.open_file(relative_file_path): + return self._html_server._request_document_symbols(relative_file_path, file_data=None) + log.debug("HTML companion unavailable for %s; returning None", relative_file_path) + return None + return super()._request_document_symbols(relative_file_path, file_data) + + @override + def request_definition(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: + if self._ts_server is not None and self._is_typescript_file(relative_file_path): + with self._ts_server.open_file(relative_file_path): + return self._ts_server.request_definition(relative_file_path, line, column) + # HTML templates: ngserver knows how to resolve template -> component + return super().request_definition(relative_file_path, line, column) + + # request_references is intentionally not overridden: ngserver (the parent + # process) handles both .ts and .html references and returns the full set, + # whereas the TS companion under-reports because it only sees pre-opened + # files. See module docstring routing table. + + @override + def request_rename_symbol_edit(self, relative_file_path: str, line: int, column: int, new_name: str) -> ls_types.WorkspaceEdit | None: + if self._ts_server is not None and self._is_typescript_file(relative_file_path): + with self._ts_server.open_file(relative_file_path): + return self._ts_server.request_rename_symbol_edit(relative_file_path, line, column, new_name) + return super().request_rename_symbol_edit(relative_file_path, line, column, new_name) + + @override + def request_hover( + self, relative_file_path: str, line: int, column: int, file_buffer: LSPFileBuffer | None = None + ) -> ls_types.Hover | None: + if self._ts_server is not None and self._is_typescript_file(relative_file_path): + with self._ts_server.open_file(relative_file_path): + return self._ts_server.request_hover(relative_file_path, line, column, file_buffer=file_buffer) + return super().request_hover(relative_file_path, line, column, file_buffer=file_buffer) + + @override + def request_implementation(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: + # ngserver does not advertise textDocument/implementation (returns -32601); + # the companion typescript-language-server (with the @angular/language-service + # plugin loaded) does, since the underlying tsserver implements it for + # interface→implementation, abstract→concrete, etc. Keep this routed even + # for .html paths because the LSP method is meaningless on plain HTML. + if self._ts_server is not None and self._is_typescript_file(relative_file_path): + with self._ts_server.open_file(relative_file_path): + return self._ts_server.request_implementation(relative_file_path, line, column) + log.debug( + "request_implementation called on non-TS path %s; ngserver does not advertise the LSP method " + "and the request is meaningless on plain HTML — returning []", + relative_file_path, + ) + return [] + + @override + def request_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + # ngserver does not handle pull diagnostics for .ts files in the way tsserver + # does — it produces template diagnostics on .html attached via templateUrl, + # but for component classes we want the TS error stream, which lives in the + # companion typescript-language-server. + if self._ts_server is not None and self._is_typescript_file(relative_file_path): + with self._ts_server.open_file(relative_file_path): + return self._ts_server.request_text_document_diagnostics( + relative_file_path, start_line=start_line, end_line=end_line, min_severity=min_severity + ) + return super().request_text_document_diagnostics( + relative_file_path, start_line=start_line, end_line=end_line, min_severity=min_severity + ) diff --git a/src/solidlsp/language_servers/ansible_language_server.py b/src/solidlsp/language_servers/ansible_language_server.py new file mode 100644 index 0000000..39edcdb --- /dev/null +++ b/src/solidlsp/language_servers/ansible_language_server.py @@ -0,0 +1,324 @@ +""" +Provides Ansible specific instantiation of the LanguageServer class using ansible-language-server. +Contains various configurations and settings specific to Ansible YAML files (playbooks, roles, etc.). +""" + +import fnmatch +import logging +import os +import shutil +from typing import Any, ClassVar + +from overrides import override + +from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_ANSIBLE_LANGUAGE_SERVER_VERSION = "1.2.3" +DEFAULT_ANSIBLE_LANGUAGE_SERVER_VERSION = "1.2.3" + + +def _deep_merge(base: dict, override: dict) -> None: + """Recursively merge *override* into *base*, modifying *base* in-place.""" + for key, value in override.items(): + if key in base and isinstance(base[key], dict) and isinstance(value, dict): + _deep_merge(base[key], value) + else: + base[key] = value + + +class AnsibleLanguageServer(SolidLanguageServer): + """Provides Ansible specific instantiation of the LanguageServer class using ansible-language-server. + + Contains various configurations and settings specific to Ansible YAML files + (playbooks, roles, inventories, etc.). + + Supported ``ls_specific_settings`` keys (via ``serena_config.yml``): + + * ``ls_path`` (str) — path to the ansible-language-server executable + (handled by the base class). + * ``ansible_path`` (str, default ``"ansible"``) — path to the ``ansible`` executable. + * ``python_interpreter_path`` (str, default ``"python3"``) — path to the Python interpreter. + * ``python_activation_script`` (str, default ``""``) — virtualenv activation script. + * ``lint_enabled`` (bool, default ``False``) — enable ansible-lint + (requires a separate installation of ``ansible-lint``). + * ``lint_path`` (str, default ``"ansible-lint"``) — path to ``ansible-lint``. + * ``ansible_settings`` (dict) — full settings dict, deep-merged on top of defaults. + The structure mirrors the Ansible Language Server settings + (``ansible.*``, ``python.*``, ``validation.*``, ``completion.*``, + ``executionEnvironment.*``). + """ + + # directory names that signal ansible content at ANY nesting level + _ANSIBLE_DIR_NAMES: ClassVar[set[str]] = { + "roles", + "playbooks", + "tasks", + "handlers", + "group_vars", + "host_vars", + "inventory", + "inventories", + "defaults", + "vars", + "meta", + } + + # filename patterns handled by ansible LS regardless of path + _ANSIBLE_FILENAME_PATTERNS: ClassVar[list[str]] = [ + "playbook*.yml", + "playbook*.yaml", + "site.yml", + "site.yaml", + "requirements.yml", + "requirements.yaml", + ] + + @staticmethod + def _is_ansible_path(relative_path: str) -> bool: + """Check if a file is in an ansible-specific location. + + Matches if ANY component of the path is an ansible-specific + directory name (e.g. ``roles``, ``tasks``, ``group_vars``), + or if the filename matches an ansible-specific pattern. + This works regardless of nesting depth: + ``project/deploy/roles/web/tasks/main.yml`` matches on both + ``roles`` and ``tasks``. + + :param relative_path: path relative to the repository root + :return: True if the path is an ansible-specific location + """ + normalized = relative_path.replace("\\", "/") + parts = normalized.split("/") + + # check if any directory component is ansible-specific + dir_parts = parts[:-1] + for part in dir_parts: + if part in AnsibleLanguageServer._ANSIBLE_DIR_NAMES: + return True + + # check filename patterns (e.g. playbook.yml, site.yaml) + filename = parts[-1] + for pattern in AnsibleLanguageServer._ANSIBLE_FILENAME_PATTERNS: + if fnmatch.fnmatch(filename, pattern): + return True + + return False + + @override + def is_ignored_path(self, relative_path: str, ignore_unsupported_files: bool = True) -> bool: + # standard ignore rules (extension, gitignore, etc.) + if super().is_ignored_path(relative_path, ignore_unsupported_files): + return True + + # for yml/yaml files, check if they are in ansible-specific paths + if relative_path.endswith((".yml", ".yaml")): + if not self._is_ansible_path(relative_path): + return True + + return False + + @staticmethod + def _determine_log_level(line: str) -> int: + """Classify ansible-language-server stderr output to avoid false-positive errors.""" + line_lower = line.lower() + + if any( + [ + "ansible is not installed" in line_lower, + "ansible-lint" in line_lower and "not found" in line_lower, + "cannot find module" in line_lower, + ] + ): + return logging.DEBUG + + return SolidLanguageServer._determine_log_level(line) + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """Creates an AnsibleLanguageServer instance. + + This class is not meant to be instantiated directly. + Use ``SolidLanguageServer.create()`` instead. + """ + super().__init__( + config, + repository_root_path, + None, + "ansible", + solidlsp_settings, + ) + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """Setup runtime dependencies for Ansible Language Server and return the path to the executable.""" + # verify both node and npm are installed + is_node_installed = shutil.which("node") is not None + assert is_node_installed, "node is not installed or isn't in PATH. Please install Node.js and try again." + is_npm_installed = shutil.which("npm") is not None + assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." + ansible_language_server_version = self._custom_settings.get( + "ansible_language_server_version", DEFAULT_ANSIBLE_LANGUAGE_SERVER_VERSION + ) + npm_registry = self._custom_settings.get("npm_registry") + + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="ansible-language-server", + description="Ansible Language Server (@ansible/ansible-language-server)", + command=build_npm_install_command( + "@ansible/ansible-language-server", + ansible_language_server_version, + npm_registry, + ), + platform_id="any", + ), + ] + ) + + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = ( + "ansible-lsp" + if ansible_language_server_version == INITIAL_ANSIBLE_LANGUAGE_SERVER_VERSION + else f"ansible-lsp-{ansible_language_server_version}" + ) + ansible_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname) + ansible_executable_path = os.path.join(ansible_ls_dir, "node_modules", ".bin", "ansible-language-server") + + # handle Windows executable extension + if os.name == "nt": + ansible_executable_path += ".cmd" + + if not os.path.exists(ansible_executable_path): + log.info(f"Ansible Language Server executable not found at {ansible_executable_path}. Installing...") + deps.install(ansible_ls_dir) + log.info("Ansible Language Server dependencies installed successfully") + + if not os.path.exists(ansible_executable_path): + raise FileNotFoundError( + f"ansible-language-server executable not found at {ansible_executable_path}, " + "something went wrong with the installation." + ) + + return ansible_executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "--stdio"] + + def _create_base_initialize_params(self) -> dict: + """Returns the initialize params for the Ansible Language Server. + + Reads shortcut keys and the ``ansible_settings`` dict from ``_custom_settings`` + to build ``initializationOptions``. + """ + # default ansible settings, populated from shortcut keys + ansible_settings: dict[str, Any] = { + "ansible": { + "path": self._custom_settings.get("ansible_path", "ansible"), + "useFullyQualifiedCollectionNames": True, + }, + "python": { + "interpreterPath": self._custom_settings.get("python_interpreter_path", "python3"), + "activationScript": self._custom_settings.get("python_activation_script", ""), + }, + "validation": { + "enabled": True, + "lint": { + "enabled": self._custom_settings.get("lint_enabled", False), + "path": self._custom_settings.get("lint_path", "ansible-lint"), + }, + }, + "completion": { + "provideRedirectModules": True, + "provideModuleOptionAliases": True, + }, + "executionEnvironment": {"enabled": False}, + } + + # full override via ansible_settings dict for advanced configuration + user_settings = self._custom_settings.settings.get("ansible_settings") + if user_settings: + if not isinstance(user_settings, dict): + raise TypeError( + f"ansible_settings must be a dict, got {type(user_settings).__name__}. " + "Expected structure matching Ansible LS settings: " + "{'ansible': {...}, 'python': {...}, 'validation': {...}, ...}" + ) + _deep_merge(ansible_settings, user_settings) + + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "codeAction": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + "initializationOptions": {"ansible": ansible_settings}, + } + return initialize_params + + def _start_server(self) -> None: + """Starts the Ansible Language Server, waits for the server to be ready.""" + + def register_capability_handler(params: Any) -> None: + return + + def show_message_request_handler(params: Any) -> None: + """Handle ``window/showMessageRequest`` by returning ``null``. + + Per the LSP spec, returning ``null`` means no action was selected. + Without this handler the client replies with ``MethodNotFound``, + which the ansible LS treats as fatal. + """ + log.info(f"LSP: window/showMessageRequest (dismissed): {params.get('message', params)}") + return + + def do_nothing(params: Any) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_request("window/showMessageRequest", show_message_request_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting Ansible server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.debug(f"Received initialize response from Ansible server: {init_response}") + + log.debug(f"Ansible server capabilities: {list(init_response['capabilities'].keys())}") + + self.server.notify.initialized({}) + log.info("Ansible server initialization complete") diff --git a/src/solidlsp/language_servers/bash_language_server.py b/src/solidlsp/language_servers/bash_language_server.py new file mode 100644 index 0000000..5d52bbb --- /dev/null +++ b/src/solidlsp/language_servers/bash_language_server.py @@ -0,0 +1,318 @@ +""" +Provides Bash specific instantiation of the LanguageServer class using bash-language-server. +Contains various configurations and settings specific to Bash scripting. +""" + +import logging +import os +import shutil +import threading + +from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command +from solidlsp.ls import ( + DocumentSymbols, + LanguageServerDependencyProvider, + LanguageServerDependencyProviderSinglePath, + LSPFileBuffer, + SolidLanguageServer, +) +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_utils import FileUtils, PlatformId, PlatformUtils +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +# ShellCheck binary release info; download directly from upstream GitHub releases rather than +# relying on the `shellcheck` npm wrapper (which lazily downloads on first invocation and +# turned the bash LS install into a fragile, network-dependent step). +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_BASH_LANGUAGE_SERVER_VERSION = "5.6.0" +DEFAULT_BASH_LANGUAGE_SERVER_VERSION = "5.6.0" + +# ShellCheck binary path already encodes _SHELLCHECK_VERSION (see _shellcheck_binary_path), +# so version bumps trigger reinstall correctly without further intervention. +_SHELLCHECK_VERSION = "0.10.0" +_SHELLCHECK_RELEASE_BASE = f"https://github.com/koalaman/shellcheck/releases/download/v{_SHELLCHECK_VERSION}" +_SHELLCHECK_ALLOWED_HOSTS = ( + "github.com", + "objects.githubusercontent.com", + "release-assets.githubusercontent.com", +) +# Per-platform archive metadata: tar.xz on POSIX (extracts to shellcheck-v/shellcheck), +# zip on Windows (extracts to shellcheck.exe at archive root). +_SHELLCHECK_DEPENDENCIES: dict[PlatformId, dict[str, str]] = { + PlatformId.LINUX_x64: { + "url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.linux.x86_64.tar.xz", + "sha256": "6c881ab0698e4e6ea235245f22832860544f17ba386442fe7e9d629f8cbedf87", + }, + PlatformId.LINUX_arm64: { + "url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.linux.aarch64.tar.xz", + "sha256": "324a7e89de8fa2aed0d0c28f3dab59cf84c6d74264022c00c22af665ed1a09bb", + }, + PlatformId.OSX_x64: { + "url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.darwin.x86_64.tar.xz", + "sha256": "ef27684f23279d112d8ad84e0823642e43f838993bbb8c0963db9b58a90464c2", + }, + PlatformId.OSX_arm64: { + "url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.darwin.aarch64.tar.xz", + "sha256": "bbd2f14826328eee7679da7221f2bc3afb011f6a928b848c80c321f6046ddf81", + }, + PlatformId.WIN_x64: { + "url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.zip", + "sha256": "eb6cd53a54ea97a56540e9d296ce7e2fa68715aa507ff23574646c1e12b2e143", + }, +} + + +def _shellcheck_install_dir(bash_ls_dir: str) -> str: + return os.path.join(bash_ls_dir, "shellcheck") + + +def _shellcheck_binary_path(bash_ls_dir: str) -> str: + """ + Returns the path to the extracted ShellCheck binary. POSIX archives extract under + ``shellcheck-v/shellcheck``; the Windows zip drops ``shellcheck.exe`` at archive root. + """ + install_dir = _shellcheck_install_dir(bash_ls_dir) + if os.name == "nt": + return os.path.join(install_dir, "shellcheck.exe") + return os.path.join(install_dir, f"shellcheck-v{_SHELLCHECK_VERSION}", "shellcheck") + + +class BashLanguageServer(SolidLanguageServer): + """ + Provides Bash specific instantiation of the LanguageServer class using bash-language-server. + Contains various configurations and settings specific to Bash scripting. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a BashLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + super().__init__( + config, + repository_root_path, + None, + "bash", + solidlsp_settings, + ) + self.server_ready = threading.Event() + self.initialize_searcher_command_available = threading.Event() + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """ + Setup runtime dependencies for Bash Language Server and return the command to start the server. + """ + # verify node + npm are available for the bash-language-server install + is_node_installed = shutil.which("node") is not None + assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." + is_npm_installed = shutil.which("npm") is not None + assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." + bash_language_server_version = self._custom_settings.get("bash_language_server_version", DEFAULT_BASH_LANGUAGE_SERVER_VERSION) + npm_registry = self._custom_settings.get("npm_registry") + + bash_ls_dir = self._resolve_bash_ls_dir(bash_language_server_version) + managed_bin_dir = os.path.join(bash_ls_dir, "node_modules", ".bin") + bash_executable_path = os.path.join(managed_bin_dir, "bash-language-server") + if os.name == "nt": + bash_executable_path += ".cmd" + + # install bash-language-server via npm + if not os.path.exists(bash_executable_path): + bls_deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="bash-language-server", + description="bash-language-server package", + command=build_npm_install_command("bash-language-server", bash_language_server_version, npm_registry), + platform_id="any", + ), + ] + ) + log.info("Installing bash-language-server...") + bls_deps.install(bash_ls_dir) + + # install ShellCheck binary directly from upstream releases for the current platform + self._install_shellcheck_if_missing(bash_ls_dir) + + if not os.path.exists(bash_executable_path): + raise FileNotFoundError( + f"bash-language-server executable not found at {bash_executable_path}, something went wrong with the installation." + ) + + return bash_executable_path + + @staticmethod + def _install_shellcheck_if_missing(bash_ls_dir: str) -> None: + """ + Downloads and extracts the platform-appropriate ShellCheck release into + ``${bash_ls_dir}/shellcheck`` if the binary is not already present. + """ + binary_path = _shellcheck_binary_path(bash_ls_dir) + if os.path.exists(binary_path): + return + + install_dir = _shellcheck_install_dir(bash_ls_dir) + os.makedirs(install_dir, exist_ok=True) + + release = _SHELLCHECK_DEPENDENCIES.get(PlatformUtils.get_platform_id()) + if release is None: + raise RuntimeError(f"ShellCheck has no upstream binary release for platform {PlatformUtils.get_platform_id().value}") + + archive_type = "zip" if os.name == "nt" else "xztar" + log.info(f"Downloading ShellCheck v{_SHELLCHECK_VERSION} for {PlatformUtils.get_platform_id().value}") + FileUtils.download_and_extract_archive_verified( + release["url"], + install_dir, + archive_type, + expected_sha256=release["sha256"], + allowed_hosts=_SHELLCHECK_ALLOWED_HOSTS, + ) + + if not os.path.exists(binary_path): + raise FileNotFoundError(f"ShellCheck binary not found at {binary_path} after extraction; archive layout may have changed.") + + # ensure the binary is executable on POSIX (zip extraction does not preserve perms) + if os.name != "nt": + current = os.stat(binary_path).st_mode + os.chmod(binary_path, current | 0o111) + + def create_launch_command_env(self) -> dict[str, str]: + bash_language_server_version = self._custom_settings.get("bash_language_server_version", DEFAULT_BASH_LANGUAGE_SERVER_VERSION) + bash_ls_dir = self._resolve_bash_ls_dir(bash_language_server_version) + managed_bin_dir = os.path.join(bash_ls_dir, "node_modules", ".bin") + return { + "PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", ""), + "SHELLCHECK_PATH": _shellcheck_binary_path(bash_ls_dir), + } + + def _resolve_bash_ls_dir(self, bash_language_server_version: str) -> str: + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = ( + "bash-lsp" + if bash_language_server_version == INITIAL_BASH_LANGUAGE_SERVER_VERSION + else f"bash-lsp-{bash_language_server_version}" + ) + return os.path.join(self._ls_resources_dir, ls_dirname) + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "start"] + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Bash Language Server. + """ + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": {"dynamicRegistration": True}, + "codeAction": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """ + Starts the Bash Language Server, waits for the server to be ready and yields the LanguageServer instance. + """ + + def register_capability_handler(params: dict) -> None: + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "workspace/executeCommand": + self.initialize_searcher_command_available.set() + return + + def execute_client_command_handler(params: dict) -> list: + return [] + + def do_nothing(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + # Check for bash-language-server ready signals + message_text = msg.get("message", "") + if "Analyzing" in message_text or "analysis complete" in message_text.lower(): + log.info("Bash language server analysis signals detected") + self.server_ready.set() + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting Bash server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.debug(f"Received initialize response from bash server: {init_response}") + + # Enhanced capability checks for bash-language-server 5.6.0 + assert init_response["capabilities"]["textDocumentSync"] in [1, 2] # Full or Incremental + assert "completionProvider" in init_response["capabilities"] + + # Verify document symbol support is available + if "documentSymbolProvider" in init_response["capabilities"]: + log.info("Bash server supports document symbols") + else: + log.warning("Warning: Bash server does not report document symbol support") + + self.server.notify.initialized({}) + + # Wait for server readiness with timeout + log.info("Waiting for Bash language server to be ready...") + if not self.server_ready.wait(timeout=3.0): + # Fallback: assume server is ready after timeout + # This is common. bash-language-server doesn't always send explicit ready signals. Log as info + log.info("Timeout waiting for bash server ready signal, proceeding anyway") + self.server_ready.set() + else: + log.info("Bash server initialization complete") + + def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: + # Uses the standard LSP documentSymbol request which provides reliable function detection + # for all bash function syntaxes including: + # - function name() { ... } (with function keyword) + # - name() { ... } (traditional syntax) + # - Functions with various indentation levels + # - Functions with comments before/after/inside + + log.debug(f"Requesting document symbols via LSP for {relative_file_path}") + + # Use the standard LSP approach - bash-language-server handles all function syntaxes correctly + document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer) + + # Log detection results for debugging + functions = [s for s in document_symbols.iter_symbols() if s.get("kind") == 12] + log.info(f"LSP function detection for {relative_file_path}: Found {len(functions)} functions") + + return document_symbols diff --git a/src/solidlsp/language_servers/bsl_language_server.py b/src/solidlsp/language_servers/bsl_language_server.py new file mode 100644 index 0000000..b3fc8f8 --- /dev/null +++ b/src/solidlsp/language_servers/bsl_language_server.py @@ -0,0 +1,216 @@ +""" +Provides BSL (1C:Enterprise) specific instantiation of the LanguageServer class +using bsl-language-server by 1c-syntax. Supports .bsl and .os files. +Requires Java 21+ on PATH (bsl-language-server v0.29.0 is built with +``targetCompatibility = JavaVersion.VERSION_21``). + +You can configure the following options in ls_specific_settings (in serena_config.yml): + + ls_specific_settings: + bsl: + ls_path: '/path/to/bsl-language-server.jar' # Custom path to BSL Language Server JAR + bsl_ls_version: '0.29.0' # BSL Language Server version (default: current bundled version) +""" + +import logging +import os +import re +import shutil +import subprocess +import threading + +from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection +from solidlsp.ls import ( + LanguageServerDependencyProvider, + LanguageServerDependencyProviderSinglePath, + SolidLanguageServer, +) +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +DEFAULT_BSL_LS_VERSION = "0.29.0" +BSL_LS_JAR_SHA256_BY_VERSION = { + "0.29.0": "d6fa9ad638ba51855e260b88ad1f8ce4e602385845a4ee43600d148f779bcf0b", +} +BSL_LS_MIN_JAVA_VERSION = 21 +"""Minimum Java major version required to run the bundled bsl-language-server JAR. + +bsl-language-server v0.29.0 is compiled with ``targetCompatibility = VERSION_21`` and +therefore fails at class-load time on older JDKs (``UnsupportedClassVersionError``), +which manifests as ``LanguageServerTerminatedException`` in Serena. We enforce the +check up front so the user gets an actionable error instead of a dead LSP process. +""" + + +def _get_java_major_version(java_exe: str) -> int | None: + """Returns the detected Java major version (e.g. 21) or ``None`` if detection fails. + + :param java_exe: path to the ``java`` executable. + """ + try: + result = subprocess.run( + [java_exe, "-version"], + capture_output=True, + text=True, + timeout=15, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + log.warning("Failed to invoke '%s -version': %s", java_exe, exc) + return None + + output = (result.stderr or "") + "\n" + (result.stdout or "") + # modern JDKs print: openjdk version "21.0.2" 2024-01-16 + # legacy JDKs print: java version "1.8.0_402" (major = 8) + m = re.search(r'version "(\d+)(?:\.(\d+))?', output) + if not m: + log.warning("Could not parse Java version from output of '%s -version': %s", java_exe, output.strip()[:200]) + return None + + first = int(m.group(1)) + if first == 1 and m.group(2): + return int(m.group(2)) # 1.8 -> 8 + return first + + +def _verify_java_available() -> None: + """Ensures a suitable ``java`` (>= :data:`BSL_LS_MIN_JAVA_VERSION`) is on ``PATH``. + + :raises RuntimeError: if ``java`` is missing or its major version is too low. + """ + java_exe = shutil.which("java") + if java_exe is None: + raise RuntimeError(f"Java {BSL_LS_MIN_JAVA_VERSION}+ is required for BSL Language Server but 'java' was not found on PATH.") + + major = _get_java_major_version(java_exe) + if major is None: + # detection failed; log a warning but let the LSP attempt to start + log.warning("Could not determine Java version for '%s'; BSL LSP requires Java %d+.", java_exe, BSL_LS_MIN_JAVA_VERSION) + return + + if major < BSL_LS_MIN_JAVA_VERSION: + raise RuntimeError( + f"Java {BSL_LS_MIN_JAVA_VERSION}+ is required for BSL Language Server, but '{java_exe}' is Java {major}. " + f"Install a newer JDK (e.g. Temurin {BSL_LS_MIN_JAVA_VERSION}) and ensure it appears first on PATH " + f"or point JAVA_HOME to it." + ) + + +class BSLLanguageServer(SolidLanguageServer): + """ + BSL (1C:Enterprise / OneScript) language server integration for Serena. + """ + + def __init__( + self, + config: LanguageServerConfig, + repository_root_path: str, + solidlsp_settings: SolidLSPSettings, + ): + super().__init__( + config, + repository_root_path, + None, + "bsl", + solidlsp_settings, + ) + self.server_ready = threading.Event() + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + # resolve target version; custom versions intentionally skip SHA verification below + bsl_version = self._custom_settings.get("bsl_ls_version", DEFAULT_BSL_LS_VERSION) + user_overrode_version = bsl_version != DEFAULT_BSL_LS_VERSION + + jar_dir = os.path.join(self._ls_resources_dir, f"bsl-ls-{bsl_version}") + jar_path = os.path.join(jar_dir, f"bsl-language-server-{bsl_version}-exec.jar") + + # download only when the versioned JAR is missing + if not os.path.exists(jar_path): + jar_url = ( + f"https://github.com/1c-syntax/bsl-language-server/releases/download/" + f"v{bsl_version}/bsl-language-server-{bsl_version}-exec.jar" + ) + expected_sha256 = None if user_overrode_version else BSL_LS_JAR_SHA256_BY_VERSION.get(bsl_version) + + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="bsl-language-server", + description="BSL Language Server JAR by 1c-syntax", + url=jar_url, + sha256=expected_sha256, + archive_type="binary", + binary_name=f"bsl-language-server-{bsl_version}-exec.jar", + platform_id="any", + ), + ] + ) + deps.install(jar_dir) + + if not os.path.exists(jar_path): + raise FileNotFoundError(f"BSL Language Server JAR not found at {jar_path} after installation.") + + log.info(f"Using BSL Language Server v{bsl_version} at {jar_path}") + return jar_path + + def _create_launch_command(self, core_path: str) -> list[str]: + # Java availability+version is required for both managed installs and user-provided jars (ls_path) + _verify_java_available() + return ["java", "-jar", core_path] + + def _create_base_initialize_params(self) -> dict: + return { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "rename": {"dynamicRegistration": True, "prepareSupport": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + } + + def _start_server(self) -> None: + def window_log_message(msg: dict) -> None: + log.info("BSL LSP: %s", msg.get("message", "")) + + def do_nothing(_: dict) -> None: + return + + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_request( + "client/registerCapability", + lambda params: None, + ) + + log.info("Starting BSL language server process") + self.server.start() + + init_params = self._create_initialize_params() + init_response = self.server.send.initialize(init_params) + log.debug("BSL LSP initialize response: %s", init_response) + + assert "capabilities" in init_response, "BSL LSP did not return capabilities" + + self.server.notify.initialized({}) + self.server_ready.set() diff --git a/src/solidlsp/language_servers/ccls_language_server.py b/src/solidlsp/language_servers/ccls_language_server.py new file mode 100644 index 0000000..1c7c1fa --- /dev/null +++ b/src/solidlsp/language_servers/ccls_language_server.py @@ -0,0 +1,151 @@ +""" +This is an alternative to clangd for large C++ codebases where ccls may perform +better for indexing and navigation. Requires ccls to be installed and available +on PATH, or configured via ls_specific_settings with key "ls_path". + +Installation +------------ +ccls must be installed manually as there are no prebuilt binaries available for +direct download. Install using your system package manager: + +**Linux:** +- Ubuntu/Debian (22.04+): ``sudo apt-get install ccls`` +- Fedora/RHEL: ``sudo dnf install ccls`` +- Arch Linux: ``sudo pacman -S ccls`` +- openSUSE Tumbleweed: ``sudo zypper install ccls`` +- Gentoo: ``sudo emerge dev-util/ccls`` + +**macOS:** +- Homebrew: ``brew install ccls`` + +**Windows:** +- Chocolatey: ``choco install ccls`` + +For alternative installation methods and build-from-source instructions, see: +https://github.com/MaskRay/ccls/wiki/Build + +Official documentation: +https://github.com/MaskRay/ccls +""" + +import logging +import threading +from typing import Any + +from overrides import override + +from solidlsp.language_servers.common import UE_IGNORED_DIRNAMES, is_unreal_engine_project +from solidlsp.ls import ( + LanguageServerDependencyProvider, + LanguageServerDependencyProviderSinglePath, + SolidLanguageServer, +) +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class CCLS(SolidLanguageServer): + """ + C/C++ language server implementation using ccls. + + Notes: + - ccls should be installed and on PATH (or specify ls_path in settings) + - compile_commands.json at repo root is recommended for accurate indexing + + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a CclsLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + super().__init__(config, repository_root_path, None, "cpp", solidlsp_settings) + self.server_ready = threading.Event() + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return ( + super().is_ignored_dirname(dirname) + or dirname == ".ccls-cache" + or (is_unreal_engine_project(self.repository_root_path) and dirname in UE_IGNORED_DIRNAMES) + ) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """ + Resolve ccls path from system or raise helpful error if missing. + Allows override via ls_specific_settings[language].ls_path. + """ + import shutil + + ccls_path = shutil.which("ccls") + if not ccls_path: + raise FileNotFoundError( + "ccls is not installed on your system.\n" + "Please install ccls using your system package manager:\n" + " Linux (Ubuntu/Debian): sudo apt-get install ccls\n" + " Linux (Fedora/RHEL): sudo dnf install ccls\n" + " Linux (Arch): sudo pacman -S ccls\n" + " macOS (Homebrew): brew install ccls\n" + " Windows: choco install ccls\n\n" + "For build instructions and more details, see:\n" + " https://github.com/MaskRay/ccls/wiki/Build" + ) + log.info(f"Using system-installed ccls at {ccls_path}") + return ccls_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path] + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the ccls Language Server. + """ + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": {"dynamicRegistration": True}, + }, + "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, + }, + # ccls supports initializationOptions but none are required for basic functionality + } + return initialize_params + + def _start_server(self) -> None: + """ + Starts the ccls language server and initializes the LSP connection. + """ + + def do_nothing(params: Any) -> None: + pass + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + # Register minimal handlers + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting ccls server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to ccls and awaiting response") + self.server.send.initialize(initialize_params) + # Do not assert clangd-specific capability shapes; ccls differs + self.server.notify.initialized({}) + + # Basic readiness + self.server_ready.set() diff --git a/src/solidlsp/language_servers/clangd_language_server.py b/src/solidlsp/language_servers/clangd_language_server.py new file mode 100644 index 0000000..4db4912 --- /dev/null +++ b/src/solidlsp/language_servers/clangd_language_server.py @@ -0,0 +1,417 @@ +import hashlib +import json +import logging +import os +import threading +from collections.abc import Hashable +from typing import Any + +from overrides import override + +from solidlsp.language_servers.common import UE_IGNORED_DIRNAMES +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, ProcessLaunchInfo, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.settings import SolidLSPSettings + +from .common import RuntimeDependency, RuntimeDependencyCollection, is_unreal_engine_project + +log = logging.getLogger(__name__) + +CLANGD_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") + + +class ClangdLanguageServer(SolidLanguageServer): + """ + Provides C/C++ specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C/C++. + As the project gets bigger in size, building index will take time. Try running clangd multiple times to ensure index is built properly. + Also make sure compile_commands.json is created at root of the source directory. Check clangd test case for example. + + You can pass the following entries in ``ls_specific_settings["cpp"]``: + - compile_commands_dir: Directory where Serena writes its transformed + ``compile_commands.json`` if needed. + - clangd_version: Override the pinned Clangd version downloaded by Serena + (default: the bundled Serena version). + """ + + @staticmethod + def _determine_log_level(line: str) -> int: + """ + Classify a clangd stderr line using clangd's explicit level prefix. + + See `clang::clangd::Logger::indicator` for details: + https://clang.llvm.org/extra/doxygen/classclang_1_1clangd_1_1Logger.html + + Clangd emits each log record prefixed by a single indicator character + followed by a timestamp in square brackets, e.g. ``I[12:27:16.234]``. + + The indicators are ``D`` (Debug), ``I`` (Info), ``E`` (Error) and + ``F`` (Fatal). Continuation lines of multi-line records carry no + prefix and are treated as informational. + + Without this override, the base implementation scans the line for + the substrings ``error`` and ``exception``, which produces false + positives on clangd's reconstructed compile commands in some cases + (e.g. ``-DNO_EXCEPTIONS``, ``-fno-exceptions``). + """ + # classify by clangd's level indicator character + if len(line) >= 2 and line[1] == "[": + indicator = line[0] + if indicator in ("E", "F"): + return logging.ERROR + if indicator == "I": + return logging.INFO + if indicator == "D": + return logging.DEBUG + + # continuation line or non-prefixed output: default to INFO, do not keyword-scan + return logging.INFO + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a ClangdLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + super().__init__(config, repository_root_path, None, "cpp", solidlsp_settings) + self.server_ready = threading.Event() + self.service_ready_event = threading.Event() + self.initialize_searcher_command_available = threading.Event() + self.resolve_main_method_available = threading.Event() + + @override + def _document_symbols_cache_fingerprint(self) -> Hashable: + cache_format_version = 1 + cpp_settings = self._custom_settings or {} + return ( + cache_format_version, + cpp_settings.get("clangd_version"), + cpp_settings.get("ls_path"), + cpp_settings.get("compile_commands_dir"), + self._compile_commands_fingerprint(), + ) + + def _compile_commands_fingerprint(self) -> str | None: + compile_db_path = os.path.join(self.repository_root_path, "compile_commands.json") + if not os.path.exists(compile_db_path): + return None + + try: + with open(compile_db_path, "rb") as f: + return hashlib.md5(f.read()).hexdigest() + except OSError as e: + log.warning(f"Failed to fingerprint compile_commands.json: {e}") + return None + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return ( + super().is_ignored_dirname(dirname) + or dirname == ".ccls-cache" + or (is_unreal_engine_project(self.repository_root_path) and dirname in UE_IGNORED_DIRNAMES) + ) + + def _raise_if_unreal_without_compile_db(self) -> None: + """Raise if this is an Unreal Engine project without a usable compilation database. + + clangd cannot resolve engine headers or macros without one. Non-UE projects return + without raising, since clangd works without a database there. + """ + if not is_unreal_engine_project(self.repository_root_path): + return + raise SolidLSPException( + f"No usable compile_commands.json at {self.repository_root_path}, but this looks like an " + "Unreal Engine project (.uproject present). clangd needs a non-empty compilation database " + "to resolve engine headers and macros.\n\n" + "Generate one from the engine's \\Binaries\\DotNET\\UnrealBuildTool\\ directory:\n" + ' UnrealBuildTool.exe -mode=GenerateClangDatabase -project=".uproject" ' + 'Editor Win64 Development -OutputDir=""\n' + "Without a clang toolchain, append -Compiler=VisualStudio2022 to build an MSVC database instead.\n\n" + "Build the editor target once first so the generated headers exist. After creating the " + "database, reconnect or restart Serena's MCP so the language server re-checks for it.\n" + "See docs/03-special-guides/unreal_engine_setup_guide_for_serena.md for details." + ) + + def _prepare_compile_commands(self) -> str | None: + """ + Prepare clangd compilation database with absolute directory paths. + + Clangd requires absolute directory paths in compile_commands.json for correct + cross-file reference finding. This method reads the compile_commands.json, + converts relative directory paths to absolute paths, and writes a transformed + compilation database to the serena managed directory. + + The transformed file is persisted in .serena/serena_compile_commands.json + (or a configurable directory via ls_specific_settings) and is not deleted + on cleanup. This allows clangd to use the absolute-path version without + modifying the user's original compile_commands.json. + + Returns the path to the serena directory containing the transformed database, + or None if no transformation was needed. + """ + compile_db_path = os.path.join(self.repository_root_path, "compile_commands.json") + + if not os.path.exists(compile_db_path): + # clangd can't resolve UE engine headers without the database; fail with guidance. + self._raise_if_unreal_without_compile_db() + return None + + try: + with open(compile_db_path, encoding="utf-8") as f: + compile_commands = json.load(f) + + if not compile_commands: + # An empty [] database is treated like a missing one. + self._raise_if_unreal_without_compile_db() + return None + + # Check if any entries have relative directory paths + has_relative = False + for entry in compile_commands: + directory = entry.get("directory", "") + if directory and not os.path.isabs(directory): + has_relative = True + # Convert to absolute path + entry["directory"] = os.path.abspath(os.path.join(self.repository_root_path, directory)) + + if not has_relative: + # No relative paths found, no need to create transformed database + return None + + # Get the target directory from ls_specific_settings, default to .serena + cpp_settings = self._custom_settings or {} + compile_commands_rel_dir = cpp_settings.get("compile_commands_dir", ".serena") + compile_commands_dir = os.path.join(self.repository_root_path, compile_commands_rel_dir) + os.makedirs(compile_commands_dir, exist_ok=True) + + # Write the transformed compile_commands.json + # clangd looks for compile_commands.json in the --compile-commands-dir + compile_commands_path = os.path.join(compile_commands_dir, "compile_commands.json") + with open(compile_commands_path, "w", encoding="utf-8") as f: + json.dump(compile_commands, f, indent=2) + + # Track the directory for --compile-commands-dir + + log.info(f"Created serena compilation database with absolute paths at {compile_commands_path}") + return compile_commands_dir + + except (OSError, json.JSONDecodeError) as e: + log.warning(f"Failed to prepare compile_commands.json: {e}") + return None + + def _create_process_launch_info(self) -> ProcessLaunchInfo: + """ + Override to add --compile-commands-dir argument if we created a serena compilation database. + """ + # First, ensure the serena compile commands database is prepared + compile_commands_dir = self._prepare_compile_commands() + + # Get the default launch info from parent + launch_info = super()._create_process_launch_info() + + # If we created a serena compilation database, add --compile-commands-dir to the command + if compile_commands_dir: + # Insert --compile-commands-dir after the executable path + cmd = launch_info.cmd + assert isinstance(cmd, list) + launch_info.cmd = [cmd[0], f"--compile-commands-dir={compile_commands_dir}"] + cmd[1:] + + return launch_info + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """ + Setup runtime dependencies for ClangdLanguageServer and return the path to the executable. + """ + import shutil + + clangd_version = self._custom_settings.get("clangd_version", "19.1.2") + default_version = clangd_version == "19.1.2" + + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="Clangd", + description="Clangd for Linux (x64)", + url=f"https://github.com/clangd/clangd/releases/download/{clangd_version}/clangd-linux-{clangd_version}.zip", + platform_id="linux-x64", + archive_type="zip", + binary_name=f"clangd_{clangd_version}/bin/clangd", + sha256="7c09614eff857d590e4502ef516f035ff94cfb8b795de14ece5afbc53a206caf" if default_version else None, + allowed_hosts=CLANGD_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="Clangd", + description="Clangd for Windows (x64)", + url=f"https://github.com/clangd/clangd/releases/download/{clangd_version}/clangd-windows-{clangd_version}.zip", + platform_id="win-x64", + archive_type="zip", + binary_name=f"clangd_{clangd_version}/bin/clangd.exe", + sha256="5b6ceb0f85d63fa0c2c9aab31c29bebd41dc11da1f160ef21bc2fea93270a20d" if default_version else None, + allowed_hosts=CLANGD_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="Clangd", + description="Clangd for macOS (x64)", + url=f"https://github.com/clangd/clangd/releases/download/{clangd_version}/clangd-mac-{clangd_version}.zip", + platform_id="osx-x64", + archive_type="zip", + binary_name=f"clangd_{clangd_version}/bin/clangd", + sha256="d3b329b3f58602c57ca6501d255147af1bccad3691b1cb0c12c258fcd2da1be3" if default_version else None, + allowed_hosts=CLANGD_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="Clangd", + description="Clangd for macOS (Arm64)", + url=f"https://github.com/clangd/clangd/releases/download/{clangd_version}/clangd-mac-{clangd_version}.zip", + platform_id="osx-arm64", + archive_type="zip", + binary_name=f"clangd_{clangd_version}/bin/clangd", + sha256="d3b329b3f58602c57ca6501d255147af1bccad3691b1cb0c12c258fcd2da1be3" if default_version else None, + allowed_hosts=CLANGD_ALLOWED_HOSTS, + ), + ] + ) + + clangd_ls_dir = os.path.join(self._ls_resources_dir, "clangd") + + try: + dep = deps.get_single_dep_for_current_platform() + except RuntimeError: + dep = None + + if dep is None: + # No prebuilt binary available, look for system-installed clangd + clangd_executable_path = shutil.which("clangd") + if not clangd_executable_path: + raise FileNotFoundError( + "Clangd is not installed on your system.\n" + + "Please install clangd using your system package manager:\n" + + " Ubuntu/Debian: sudo apt-get install clangd\n" + + " Fedora/RHEL: sudo dnf install clang-tools-extra\n" + + " Arch Linux: sudo pacman -S clang\n" + + "See https://clangd.llvm.org/installation for more details." + ) + log.info(f"Using system-installed clangd at {clangd_executable_path}") + else: + # Standard download and install for platforms with prebuilt binaries + clangd_executable_path = deps.binary_path(clangd_ls_dir) + if not os.path.exists(clangd_executable_path): + log.info(f"Clangd executable not found at {clangd_executable_path}. Downloading from {dep.url}") + _ = deps.install(clangd_ls_dir) + if not os.path.exists(clangd_executable_path): + raise FileNotFoundError( + f"Clangd executable not found at {clangd_executable_path}.\n" + + "Make sure you have installed clangd. See https://clangd.llvm.org/installation" + ) + os.chmod(clangd_executable_path, 0o755) + return clangd_executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + # --background-index enables clangd to index all files in the project, + # which is required for finding cross-file references + return [core_path, "--background-index"] + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the clangd Language Server. + """ + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + }, + }, + "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, + }, + } + + return initialize_params + + def _start_server(self) -> None: + """ + Starts the Clangd Language Server, waits for the server to be ready and yields the LanguageServer instance. + + Usage: + ``` + async with lsp.start_server(): + # LanguageServer has been initialized and ready to serve requests + await lsp.request_definition(...) + await lsp.request_references(...) + # Shutdown the LanguageServer on exit from scope + # LanguageServer has been shutdown + ``` + """ + + def register_capability_handler(params: Any) -> None: + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "workspace/executeCommand": + self.initialize_searcher_command_available.set() + self.resolve_main_method_available.set() + return + + def lang_status_handler(params: Any) -> None: + # TODO: Should we wait for + # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} + # Before proceeding? + if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": + self.service_ready_event.set() + + def execute_client_command_handler(params: Any) -> list: + return [] + + def do_nothing(params: Any) -> None: + return + + def check_experimental_status(params: Any) -> None: + if params["quiescent"] == True: + self.server_ready.set() + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("language/status", lang_status_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + self.server.on_notification("experimental/serverStatus", check_experimental_status) + + log.info("Starting Clangd server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + capabilities = init_response["capabilities"] + + text_document_sync = capabilities["textDocumentSync"] + if isinstance(text_document_sync, int): + assert text_document_sync == 2 + else: + assert text_document_sync["change"] == 2 + + assert "completionProvider" in capabilities + completion_provider = capabilities["completionProvider"] + trigger_characters = set(completion_provider["triggerCharacters"]) + assert {".", "<", ">", ":", '"', "/"}.issubset(trigger_characters) + assert completion_provider["resolveProvider"] is False + + self.server.notify.initialized({}) + # set ready flag, clangd sends no meaningful notification when ready + # TODO This defeats the purpose of the event; we should wait for the server to actually be ready + self.server_ready.set() + + # wait for server to be ready + self.server_ready.wait() diff --git a/src/solidlsp/language_servers/clojure_lsp.py b/src/solidlsp/language_servers/clojure_lsp.py new file mode 100644 index 0000000..2a8554d --- /dev/null +++ b/src/solidlsp/language_servers/clojure_lsp.py @@ -0,0 +1,396 @@ +""" +Provides Clojure specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Clojure. +""" + +import logging +import os +import pathlib +import re +import shutil +import subprocess +import threading + +from overrides import override + +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +from .common import RuntimeDependency, RuntimeDependencyCollection + +log = logging.getLogger(__name__) + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_CLOJURE_LSP_VERSION = "2026.02.20-16.08.58" +INITIAL_CLOJURE_LSP_SHA256_BY_PLATFORM = { + "osx-arm64": "a14d4db074f665378214e2dc888472e186c228dfa065c777b0534bfda5571669", + "osx-x64": "5507434c27104ab816e096d3336d8191641de8a65b57d76afb585d07167a3cf2", + "linux-arm64": "f8f09fa07dd4b6743b5c57270ccf1ee5cdbc5fca09dbca8b6a3b22705b5da4e1", + "linux-x64": "52e8bf4fd4cf171df0a3077c8bb5a3bf598d4c621e94b4876dab943a61267309", + "win-x64": "817b1271288817c954fb9e595278b1f25003827ce31f8785f253dc4ac911041f", +} +DEFAULT_CLOJURE_LSP_VERSION = "2026.02.20-16.08.58" +DEFAULT_CLOJURE_LSP_SHA256_BY_PLATFORM = { + "osx-arm64": "a14d4db074f665378214e2dc888472e186c228dfa065c777b0534bfda5571669", + "osx-x64": "5507434c27104ab816e096d3336d8191641de8a65b57d76afb585d07167a3cf2", + "linux-arm64": "f8f09fa07dd4b6743b5c57270ccf1ee5cdbc5fca09dbca8b6a3b22705b5da4e1", + "linux-x64": "52e8bf4fd4cf171df0a3077c8bb5a3bf598d4c621e94b4876dab943a61267309", + "win-x64": "817b1271288817c954fb9e595278b1f25003827ce31f8785f253dc4ac911041f", +} + + +def _clojure_lsp_sha(version: str, platform_key: str) -> str | None: + if version == INITIAL_CLOJURE_LSP_VERSION: + return INITIAL_CLOJURE_LSP_SHA256_BY_PLATFORM[platform_key] + if version == DEFAULT_CLOJURE_LSP_VERSION: + return DEFAULT_CLOJURE_LSP_SHA256_BY_PLATFORM[platform_key] + return None + + +CLOJURE_LSP_ALLOWED_HOSTS = ( + "github.com", + "release-assets.githubusercontent.com", + "objects.githubusercontent.com", +) + + +def run_command(cmd: list, capture_output: bool = True) -> subprocess.CompletedProcess: + return subprocess.run( + cmd, stdout=subprocess.PIPE if capture_output else None, stderr=subprocess.STDOUT if capture_output else None, text=True, check=True + ) + + +def verify_clojure_cli() -> None: + install_msg = "Please install the official Clojure CLI from:\n https://clojure.org/guides/getting_started" + if shutil.which("clojure") is None: + raise FileNotFoundError("`clojure` not found.\n" + install_msg) + + help_proc = run_command(["clojure", "--help"]) + if "-Aaliases" not in help_proc.stdout: + raise RuntimeError("Detected a Clojure executable, but it does not support '-Aaliases'.\n" + install_msg) + + spath_proc = run_command(["clojure", "-Spath"], capture_output=False) + if spath_proc.returncode != 0: + raise RuntimeError("`clojure -Spath` failed; please upgrade to Clojure CLI ≥ 1.10.") + + +class ClojureLSP(SolidLanguageServer): + """ + Provides a clojure-lsp specific instantiation of the LanguageServer class. + + You can pass the following entries in ``ls_specific_settings["clojure"]``: + - clojure_lsp_version: Override the pinned clojure-lsp version downloaded + by Serena (default: the bundled Serena version). + - source_paths: Explicit list of source paths (repo-root-relative) to + inject into clojure-lsp's ``initializationOptions``. Skips both the + ``.lsp/config.edn`` lookup and the project-tree scan. + - config_edn_path: Path to a ``config.edn`` file whose ``:source-paths`` + entry should be parsed and injected. Skips the project-tree scan but + is itself skipped if ``source_paths`` is also set. + + Source-path resolution order (first match wins): + 1. ``source_paths`` setting (explicit override) + 2. ``config_edn_path`` setting (explicit config file) + 3. ``/.lsp/config.edn`` exists → trust it (clojure-lsp reads it + natively, so we inject nothing) + 4. Walk the repo for ``deps.edn`` / ``project.clj`` / ``shadow-cljs.edn`` + / ``bb.edn`` and synthesise a source-paths list from their declared + ``:paths`` / ``:extra-paths`` / ``:source-paths``. + """ + + CLOJURE_LSP_ALLOWED_HOSTS = CLOJURE_LSP_ALLOWED_HOSTS + + # Files that mark a directory as the root of a Clojure (sub-)project. Used both to detect + # multi-module monorepos when synthesising clojure-lsp's `source-paths` and to walk the + # tree extracting declared paths. + _PROJECT_DESCRIPTOR_FILENAMES = ("deps.edn", "project.clj", "shadow-cljs.edn", "bb.edn") + + # Best-effort EDN extraction (full EDN parsing would be overkill for discovery hints): + # matches `:paths […]`, `:extra-paths […]` and `:source-paths […]` followed by a vector of strings. + _PATHS_VECTOR_RE = re.compile(r":(?:extra-paths|source-paths|paths)\s*\[([^\]]*)\]") + _QUOTED_STRING_RE = re.compile(r'"([^"]+)"') + + # Clojure-specific directories worth pruning + _IGNORED_DIRS = frozenset({".clj-kondo", ".lsp", ".cpcache", "node_modules", "target", "out", "dist"}) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in self._IGNORED_DIRS + + @staticmethod + def _parse_project_descriptor_paths(descriptor_path: str) -> list[str]: + """Extracts source-path string entries from a Clojure project descriptor (best-effort). + + :param descriptor_path: absolute path to a `deps.edn`, `project.clj`, `shadow-cljs.edn` or `bb.edn` + :return: the path strings declared under `:paths`, `:extra-paths` or `:source-paths` keys, + in the order they appear; relative to the descriptor's own directory + """ + try: + text = pathlib.Path(descriptor_path).read_text(encoding="utf-8") + except OSError as e: + log.debug(f"Could not read project descriptor {descriptor_path}: {e}") + return [] + + declared: list[str] = [] + for vector_match in ClojureLSP._PATHS_VECTOR_RE.finditer(text): + declared.extend(ClojureLSP._QUOTED_STRING_RE.findall(vector_match.group(1))) + return declared + + def _collect_source_paths(self) -> list[str]: + """Walks the repo to discover all declared Clojure source paths across modules. + + Workaround for a clojure-lsp limitation: it discovers source paths only from the + descriptor at the workspace root and does not recurse for sub-module descriptors, + even when additional ``workspaceFolders`` are advertised. For multi-module monorepos + this means references in sibling modules are silently missed until the user happens + to open one of their files. We pass the union of declared paths to clojure-lsp via + ``initializationOptions["source-paths"]`` — equivalent to what users of such monorepos + otherwise have to write manually in ``.lsp/config.edn``. + + :return: deduplicated, repo-root-relative, forward-slash-normalised source paths from + every project descriptor found in the tree (pruning via :py:meth:`is_ignored_dirname`); + falls back to ``["src"]`` if nothing is discovered, matching the clojure-lsp default + """ + repository_absolute_path = self.repository_root_path + discovered: list[str] = [] + for dirpath, dirnames, filenames in os.walk(repository_absolute_path): + # prune ignored dirs at the parent level so we never descend into node_modules, + # target, .git etc. — much cheaper than scandir-ing them and filtering after + dirnames[:] = [d for d in dirnames if not self.is_ignored_dirname(d)] + + # for each descriptor at this level, resolve declared paths back to the repo root + module_rel = os.path.relpath(dirpath, repository_absolute_path) + for descriptor in self._PROJECT_DESCRIPTOR_FILENAMES: + if descriptor in filenames: + for declared_path in self._parse_project_descriptor_paths(os.path.join(dirpath, descriptor)): + if module_rel == ".": + resolved = declared_path + else: + resolved = os.path.normpath(os.path.join(module_rel, declared_path)) + discovered.append(resolved.replace(os.sep, "/")) + + return list(set(discovered)) or ["src"] + + @classmethod + def _runtime_dependencies(cls, version: str) -> RuntimeDependencyCollection: + clojure_lsp_releases = f"https://github.com/clojure-lsp/clojure-lsp/releases/download/{version}" + return RuntimeDependencyCollection( + [ + RuntimeDependency( + id="clojure-lsp", + url=f"{clojure_lsp_releases}/clojure-lsp-native-macos-aarch64.zip", + platform_id="osx-arm64", + archive_type="zip", + binary_name="clojure-lsp", + sha256=_clojure_lsp_sha(version, "osx-arm64"), + allowed_hosts=CLOJURE_LSP_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="clojure-lsp", + url=f"{clojure_lsp_releases}/clojure-lsp-native-macos-amd64.zip", + platform_id="osx-x64", + archive_type="zip", + binary_name="clojure-lsp", + sha256=_clojure_lsp_sha(version, "osx-x64"), + allowed_hosts=CLOJURE_LSP_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="clojure-lsp", + url=f"{clojure_lsp_releases}/clojure-lsp-native-linux-aarch64.zip", + platform_id="linux-arm64", + archive_type="zip", + binary_name="clojure-lsp", + sha256=_clojure_lsp_sha(version, "linux-arm64"), + allowed_hosts=CLOJURE_LSP_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="clojure-lsp", + url=f"{clojure_lsp_releases}/clojure-lsp-native-linux-amd64.zip", + platform_id="linux-x64", + archive_type="zip", + binary_name="clojure-lsp", + sha256=_clojure_lsp_sha(version, "linux-x64"), + allowed_hosts=CLOJURE_LSP_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="clojure-lsp", + url=f"{clojure_lsp_releases}/clojure-lsp-native-windows-amd64.zip", + platform_id="win-x64", + archive_type="zip", + binary_name="clojure-lsp.exe", + sha256=_clojure_lsp_sha(version, "win-x64"), + allowed_hosts=CLOJURE_LSP_ALLOWED_HOSTS, + ), + ] + ) + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a ClojureLSP instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + super().__init__( + config, + repository_root_path, + None, + "clojure", + solidlsp_settings, + ) + self.server_ready = threading.Event() + self.initialize_searcher_command_available = threading.Event() + self.resolve_main_method_available = threading.Event() + self.service_ready_event = threading.Event() + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """Setup runtime dependencies for clojure-lsp and return the path to the executable.""" + verify_clojure_cli() + clojure_lsp_version = self._custom_settings.get("clojure_lsp_version", DEFAULT_CLOJURE_LSP_VERSION) + deps = ClojureLSP._runtime_dependencies(clojure_lsp_version) + dependency = deps.get_single_dep_for_current_platform() + + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + install_dir = ( + self._ls_resources_dir + if clojure_lsp_version == INITIAL_CLOJURE_LSP_VERSION + else os.path.join(self._ls_resources_dir, f"clojure-lsp-{clojure_lsp_version}") + ) + clojurelsp_executable_path = deps.binary_path(install_dir) + if not os.path.exists(clojurelsp_executable_path): + log.info( + f"Downloading and extracting clojure-lsp from {dependency.url} to {install_dir}", + ) + deps.install(install_dir) + if not os.path.exists(clojurelsp_executable_path): + raise FileNotFoundError(f"Download failed? Could not find clojure-lsp executable at {clojurelsp_executable_path}") + os.chmod(clojurelsp_executable_path, 0o755) + return clojurelsp_executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path] + + def _resolve_source_paths(self) -> list[str] | None: + """Determines whether to inject ``source-paths`` into clojure-lsp's init options. + + :return: the list of repo-root-relative source paths to inject, or ``None`` to inject + nothing (because clojure-lsp will read the project's own ``.lsp/config.edn`` natively). + See the class docstring for the precedence order. + """ + # explicit user override of source paths wins outright + explicit_paths = self._custom_settings.get("source_paths") + if explicit_paths: + log.info(f"clojure-lsp source-paths from user setting 'source_paths': {explicit_paths}") + return list(explicit_paths) + + # user-supplied config.edn path: parse and extract source paths from it + explicit_config_edn = self._custom_settings.get("config_edn_path") + if explicit_config_edn: + parsed = self._parse_project_descriptor_paths(explicit_config_edn) + log.info(f"clojure-lsp source-paths from user setting 'config_edn_path' ({explicit_config_edn}): {parsed}") + return parsed or None + + # repo-local .lsp/config.edn: clojure-lsp will read it natively, so we leave it alone + # to avoid clobbering hand-tuned configs in projects like penpot + repo_config_edn = pathlib.Path(self.repository_root_path) / ".lsp" / "config.edn" + if repo_config_edn.is_file(): + log.info(f"clojure-lsp will read project's own {repo_config_edn} (no source-paths injection)") + return None + + # fall back to scanning for project descriptors; this is the workaround for clojure-lsp's + # lack of recursion into sub-module deps.edn files in multi-module monorepos + scanned = self._collect_source_paths() + log.info(f"clojure-lsp source-paths scanned from project descriptors: {scanned}") + return scanned + + def _create_base_initialize_params(self) -> dict: + """Returns the init params for clojure-lsp.""" + source_paths = self._resolve_source_paths() + + initialization_options: dict = {"dependency-scheme": "jar", "text-document-sync-kind": "incremental"} + if source_paths is not None: + initialization_options["source-paths"] = source_paths + + result = { + "capabilities": { + "workspace": { + "applyEdit": True, + "workspaceEdit": {"documentChanges": True}, + "symbol": {"symbolKind": {"valueSet": list(range(1, 27))}}, + "workspaceFolders": True, + }, + "textDocument": { + "synchronization": {"didSave": True}, + "publishDiagnostics": {"relatedInformation": True, "tagSupport": {"valueSet": [1, 2]}}, + "definition": {"linkSupport": True}, + "references": {}, + "hover": {"contentFormat": ["markdown", "plaintext"]}, + "documentSymbol": { + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, # + }, + }, + "general": {"positionEncodings": ["utf-16"]}, + }, + "initializationOptions": initialization_options, + "trace": "off", + } + return result + + def _start_server(self) -> None: + def register_capability_handler(params: dict) -> None: + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "workspace/executeCommand": + self.initialize_searcher_command_available.set() + self.resolve_main_method_available.set() + return + + def lang_status_handler(params: dict) -> None: + # TODO: Should we wait for + # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} + # Before proceeding? + if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": + self.service_ready_event.set() + + def execute_client_command_handler(params: dict) -> list: + return [] + + def do_nothing(params: dict) -> None: + return + + def check_experimental_status(params: dict) -> None: + if params["quiescent"] is True: + self.server_ready.set() + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("language/status", lang_status_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + self.server.on_notification("experimental/serverStatus", check_experimental_status) + + log.info("Starting clojure-lsp server process") + self.server.start() + + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 # type: ignore + assert "completionProvider" in init_response["capabilities"] + # Clojure-lsp completion provider capabilities are more flexible than other servers' + completion_provider = init_response["capabilities"]["completionProvider"] + assert completion_provider["resolveProvider"] is True + assert "triggerCharacters" in completion_provider + self.server.notify.initialized({}) + # after initialize, Clojure-lsp is ready to serve + self.server_ready.set() diff --git a/src/solidlsp/language_servers/common.py b/src/solidlsp/language_servers/common.py new file mode 100644 index 0000000..4a28cca --- /dev/null +++ b/src/solidlsp/language_servers/common.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import logging +import os +import pathlib +import platform +import subprocess +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass, replace +from typing import Any, cast + +from solidlsp.ls_utils import FileUtils, PlatformUtils +from solidlsp.util import subprocess_util + +log = logging.getLogger(__name__) + + +@dataclass(kw_only=True) +class RuntimeDependency: + """Represents a runtime dependency for a language server.""" + + id: str + platform_id: str | None = None + url: str | None = None + sha256: str | None = None + allowed_hosts: tuple[str, ...] | list[str] | None = None + archive_type: str | None = None + binary_name: str | None = None + command: str | list[str] | None = None + package_name: str | None = None + package_version: str | None = None + extract_path: str | None = None + description: str | None = None + + +class RuntimeDependencyCollection: + """Utility to handle installation of runtime dependencies.""" + + def __init__(self, dependencies: Sequence[RuntimeDependency], overrides: Iterable[Mapping[str, Any]] = ()) -> None: + """Initialize the collection with a list of dependencies and optional overrides. + + :param dependencies: List of base RuntimeDependency instances. The combination of 'id' and 'platform_id' must be unique. + :param overrides: List of dictionaries which represent overrides or additions to the base dependencies. + Each entry must contain at least the 'id' key, and optionally 'platform_id' to uniquely identify the dependency to override. + """ + self._id_and_platform_id_to_dep: dict[tuple[str, str | None], RuntimeDependency] = {} + for dep in dependencies: + dep_key = (dep.id, dep.platform_id) + if dep_key in self._id_and_platform_id_to_dep: + raise ValueError(f"Duplicate runtime dependency with id '{dep.id}' and platform_id '{dep.platform_id}':\n{dep}") + self._id_and_platform_id_to_dep[dep_key] = dep + + for dep_values_override in overrides: + override_key = cast(tuple[str, str | None], (dep_values_override["id"], dep_values_override.get("platform_id"))) + base_dep = self._id_and_platform_id_to_dep.get(override_key) + if base_dep is None: + new_runtime_dep = RuntimeDependency(**dep_values_override) + self._id_and_platform_id_to_dep[override_key] = new_runtime_dep + else: + self._id_and_platform_id_to_dep[override_key] = replace(base_dep, **dep_values_override) + + def get_dependencies_for_platform(self, platform_id: str) -> list[RuntimeDependency]: + return [d for d in self._id_and_platform_id_to_dep.values() if d.platform_id in (platform_id, "any", "platform-agnostic", None)] + + def get_dependencies_for_current_platform(self) -> list[RuntimeDependency]: + return self.get_dependencies_for_platform(PlatformUtils.get_platform_id().value) + + def get_single_dep_for_current_platform(self, dependency_id: str | None = None) -> RuntimeDependency: + deps = self.get_dependencies_for_current_platform() + if dependency_id is not None: + deps = [d for d in deps if d.id == dependency_id] + if len(deps) != 1: + raise RuntimeError( + f"Expected exactly one runtime dependency for platform-{PlatformUtils.get_platform_id().value} and {dependency_id=}, found {len(deps)}" + ) + return deps[0] + + def binary_path(self, target_dir: str) -> str: + dep = self.get_single_dep_for_current_platform() + if not dep.binary_name: + return target_dir + return os.path.join(target_dir, dep.binary_name) + + def install(self, target_dir: str) -> dict[str, str]: + """Install all dependencies for the current platform into *target_dir*. + + Returns a mapping from dependency id to the resolved binary path. + """ + os.makedirs(target_dir, exist_ok=True) + results: dict[str, str] = {} + for dep in self.get_dependencies_for_current_platform(): + if dep.url: + self._install_from_url(dep, target_dir) + if dep.command: + self._run_command(dep.command, target_dir) + if dep.binary_name: + results[dep.id] = os.path.join(target_dir, dep.binary_name) + else: + results[dep.id] = target_dir + return results + + @staticmethod + def _run_command(command: str | list[str], cwd: str) -> None: + kwargs = subprocess_util.subprocess_kwargs() + if not PlatformUtils.get_platform_id().is_windows(): + import pwd + + kwargs["user"] = pwd.getpwuid(os.getuid()).pw_name + + command = subprocess_util.convert_shell_cmd(command) + log.info("Running command %s in '%s'", f"'{command}'" if isinstance(command, str) else command, cwd) + + completed_process = subprocess.run( + command, + shell=True, + check=False, + cwd=cwd, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + **kwargs, + ) + if completed_process.returncode != 0: + log.warning("Command '%s' failed with return code %d", command, completed_process.returncode) + log.warning("Command output:\n%s", completed_process.stdout) + raise subprocess.CalledProcessError(completed_process.returncode, command, completed_process.stdout) + log.info("Command completed successfully") + + @staticmethod + def _install_from_url(dep: RuntimeDependency, target_dir: str) -> None: + if not dep.url: + raise ValueError(f"Dependency {dep.id} has no URL") + + if dep.archive_type in ("gz", "binary") and dep.binary_name: + dest = os.path.join(target_dir, dep.binary_name) + FileUtils.download_and_extract_archive_verified( + dep.url, + dest, + dep.archive_type, + expected_sha256=dep.sha256, + allowed_hosts=dep.allowed_hosts, + ) + else: + FileUtils.download_and_extract_archive_verified( + dep.url, + target_dir, + dep.archive_type or "zip", + expected_sha256=dep.sha256, + allowed_hosts=dep.allowed_hosts, + ) + + +def build_npm_install_command(package_name: str, version: str, registry: str | None = None) -> list[str]: + """Build a pinned npm install command for a package in a Serena-managed install directory.""" + command = ["npm", "install", "--prefix", "./"] + if registry: + command.extend(["--registry", registry]) + command.append(f"{package_name}@{version}") + return command + + +def quote_windows_path(path: str) -> str: + """ + Quote a path for Windows command execution if needed. + + On Windows, paths need to be quoted for proper command execution. + The function checks if the path is already quoted to avoid double-quoting. + On other platforms, the path is returned unchanged. + + Args: + path: The file path to potentially quote + + Returns: + The quoted path on Windows (if not already quoted), unchanged path on other platforms + + """ + if platform.system() == "Windows": + # Check if already quoted to avoid double-quoting + if path.startswith('"') and path.endswith('"'): + return path + return f'"{path}"' + return path + + +UE_IGNORED_DIRNAMES = frozenset({"Binaries", "DerivedDataCache", "Intermediate", "Saved"}) +"""Unreal Engine build and cache directories. Matched per directory name, so per-plugin and +per-module copies (e.g. ``Plugins/Foo/Intermediate``) are covered too.""" + + +def is_unreal_engine_project(repository_root_path: str) -> bool: + """:return: whether the repository root contains an Unreal Engine ``.uproject`` file.""" + return any(pathlib.Path(repository_root_path).glob("*.uproject")) diff --git a/src/solidlsp/language_servers/crystal_language_server.py b/src/solidlsp/language_servers/crystal_language_server.py new file mode 100644 index 0000000..2e04c99 --- /dev/null +++ b/src/solidlsp/language_servers/crystal_language_server.py @@ -0,0 +1,152 @@ +""" +Provides Crystal specific instantiation of the LanguageServer class using Crystalline. +""" + +import logging +import shutil +import time + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +# Minimum time (seconds) to wait after initialization before sending +# definition requests. Crystalline needs to compile the project before +# it can resolve definitions. +_MIN_COMPILATION_DELAY = 10 + + +class CrystalLanguageServer(SolidLanguageServer): + """ + Provides Crystal specific instantiation of the LanguageServer class using Crystalline. + + Crystalline is a language server for the Crystal programming language, + implementing the Language Server Protocol. It must be installed separately; + see https://github.com/elbywan/crystalline for installation instructions. + + Known limitations of Crystalline: + + * Only the first ``textDocument/definition`` request per session returns results. + Subsequent requests return empty. This is a Crystalline issue, not a Serena issue. + * ``textDocument/references`` is not functional (documented as partial support). + * Document symbols work reliably for all requests. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + crystal_ls_path = self._find_crystalline() + + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=crystal_ls_path, cwd=repository_root_path), + "crystal", + solidlsp_settings, + ) + self._initialization_timestamp: float | None = None + + @staticmethod + def _find_crystalline() -> str: + """ + Find the Crystalline executable on PATH. + + :return: path to the Crystalline executable + :raises RuntimeError: if Crystalline is not found + """ + path = shutil.which("crystalline") + if path is None: + raise RuntimeError( + "Crystalline (Crystal language server) is not installed or not in PATH.\n" + "Please install it from https://github.com/elbywan/crystalline\n" + "and make sure the 'crystalline' binary is available on your PATH." + ) + return path + + def _wait_for_compilation(self) -> None: + """ + Wait for Crystalline to finish its initial compilation. + + Crystalline compiles the project on startup using the Crystal compiler. + Definition requests will fail if sent before compilation completes. + """ + if self._initialization_timestamp is None: + return + + elapsed = time.time() - self._initialization_timestamp + remaining_delay = max(0, _MIN_COMPILATION_DELAY - elapsed) + if remaining_delay > 0: + log.info(f"Waiting {remaining_delay:.1f}s for Crystalline to compile the project") + time.sleep(remaining_delay) + + def _create_base_initialize_params(self) -> dict: + """ + Return the initialize params for the Crystal language server. + """ + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "completion": { + "dynamicRegistration": True, + "completionItem": { + "snippetSupport": True, + "documentationFormat": ["markdown", "plaintext"], + }, + }, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "configuration": True, + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """Start the Crystal language server process.""" + + def register_capability_handler(_params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(_params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting Crystal language server (Crystalline) process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + # verify server capabilities + capabilities = init_response["capabilities"] + log.info(f"Crystal language server capabilities: {list(capabilities.keys())}") + assert "textDocumentSync" in capabilities, "textDocumentSync capability missing" + + self.server.notify.initialized({}) + + # record initialization timestamp for compilation delay calculation + self._initialization_timestamp = time.time() diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py new file mode 100644 index 0000000..1281c5a --- /dev/null +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -0,0 +1,760 @@ +""" +CSharp Language Server using Roslyn Language Server (Official Roslyn-based LSP server from NuGet.org) +""" + +import logging +import os +import platform +import shutil +import tempfile +import threading +from collections.abc import Hashable, Iterable +from dataclasses import replace +from pathlib import Path +from typing import Any, cast + +from overrides import override + +from serena.util.dotnet import DotNETUtil +from solidlsp.ls import ( + LanguageServerDependencyProvider, + LSPFileBuffer, + RawDocumentSymbol, + SolidLanguageServer, +) +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.ls_types import Hover +from solidlsp.ls_utils import FileUtils, PathUtils +from solidlsp.lsp_protocol_handler.lsp_types import InitializeResult +from solidlsp.settings import SolidLSPSettings + +from .common import RuntimeDependency, RuntimeDependencyCollection + +log = logging.getLogger(__name__) + +NUGET_ALLOWED_HOSTS = ("www.nuget.org", "nuget.org", "globalcdn.nuget.org") +DEFAULT_CSHARP_LANGUAGE_SERVER_VERSION = "5.5.0-2.26078.4" +WINDOWS_MAX_PATH = 260 +ROSLYN_LONGEST_KNOWN_PACKAGE_MEMBER = "Microsoft.CodeAnalysis.ExternalAccess.CompilerDeveloperSDK.dll" + +_RUNTIME_DEPENDENCIES = [ + RuntimeDependency( + id="CSharpLanguageServer", + description="Roslyn Language Server for Windows (x64)", + package_name="roslyn-language-server.win-x64", + package_version=DEFAULT_CSHARP_LANGUAGE_SERVER_VERSION, + url=f"https://www.nuget.org/api/v2/package/roslyn-language-server.win-x64/{DEFAULT_CSHARP_LANGUAGE_SERVER_VERSION}", + platform_id="win-x64", + archive_type="nupkg", + binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", + extract_path="tools/net10.0/win-x64", + sha256="7f3d4119e75305399e6faa81a68240b33c48b94ad523a904594abd00db95572a", + allowed_hosts=NUGET_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="CSharpLanguageServer", + description="Roslyn Language Server for Windows (ARM64)", + package_name="roslyn-language-server.win-arm64", + package_version=DEFAULT_CSHARP_LANGUAGE_SERVER_VERSION, + url=f"https://www.nuget.org/api/v2/package/roslyn-language-server.win-arm64/{DEFAULT_CSHARP_LANGUAGE_SERVER_VERSION}", + platform_id="win-arm64", + archive_type="nupkg", + binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", + extract_path="tools/net10.0/win-arm64", + sha256="0fe3381c4340a7494a5242c3d0c8be1af6ef0802de8b458f947cebca76fd26bc", + allowed_hosts=NUGET_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="CSharpLanguageServer", + description="Roslyn Language Server for macOS (x64)", + package_name="roslyn-language-server.osx-x64", + package_version=DEFAULT_CSHARP_LANGUAGE_SERVER_VERSION, + url=f"https://www.nuget.org/api/v2/package/roslyn-language-server.osx-x64/{DEFAULT_CSHARP_LANGUAGE_SERVER_VERSION}", + platform_id="osx-x64", + archive_type="nupkg", + binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", + extract_path="tools/net10.0/osx-x64", + sha256="c8de61a88c65150e12f561a2659f70b59d27a7465865136a1de950d2ef826c6d", + allowed_hosts=NUGET_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="CSharpLanguageServer", + description="Roslyn Language Server for macOS (ARM64)", + package_name="roslyn-language-server.osx-arm64", + package_version=DEFAULT_CSHARP_LANGUAGE_SERVER_VERSION, + url=f"https://www.nuget.org/api/v2/package/roslyn-language-server.osx-arm64/{DEFAULT_CSHARP_LANGUAGE_SERVER_VERSION}", + platform_id="osx-arm64", + archive_type="nupkg", + binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", + extract_path="tools/net10.0/osx-arm64", + sha256="995207c14e01dafa71e84080a7eb1f045a697b0c3bb468077bb3809b69bdf456", + allowed_hosts=NUGET_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="CSharpLanguageServer", + description="Roslyn Language Server for Linux (x64)", + package_name="roslyn-language-server.linux-x64", + package_version=DEFAULT_CSHARP_LANGUAGE_SERVER_VERSION, + url=f"https://www.nuget.org/api/v2/package/roslyn-language-server.linux-x64/{DEFAULT_CSHARP_LANGUAGE_SERVER_VERSION}", + platform_id="linux-x64", + archive_type="nupkg", + binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", + extract_path="tools/net10.0/linux-x64", + sha256="1aad25de456d637a1eee993ca0d569a1b78d711744ccb36410a3a20250a48aa6", + allowed_hosts=NUGET_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="CSharpLanguageServer", + description="Roslyn Language Server for Linux (ARM64)", + package_name="roslyn-language-server.linux-arm64", + package_version=DEFAULT_CSHARP_LANGUAGE_SERVER_VERSION, + url=f"https://www.nuget.org/api/v2/package/roslyn-language-server.linux-arm64/{DEFAULT_CSHARP_LANGUAGE_SERVER_VERSION}", + platform_id="linux-arm64", + archive_type="nupkg", + binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", + extract_path="tools/net10.0/linux-arm64", + sha256="a7dd49bbc0e25d0e2968ae31ec5f3c774373866db51f3500fcea0ac320e2bbc1", + allowed_hosts=NUGET_ALLOWED_HOSTS, + ), +] + + +def _runtime_dependencies_for_version(version: str) -> list[RuntimeDependency]: + """Return the Roslyn LS runtime dependencies for the configured version.""" + default_version = version == DEFAULT_CSHARP_LANGUAGE_SERVER_VERSION + result: list[RuntimeDependency] = [] + for dependency in _RUNTIME_DEPENDENCIES: + assert dependency.package_version is not None + assert dependency.url is not None + result.append( + replace( + dependency, + package_version=version, + url=dependency.url.replace(dependency.package_version, version), + sha256=dependency.sha256 if default_version else None, + ) + ) + return result + + +def breadth_first_file_scan(root_dir: str) -> Iterable[str]: + """ + Perform a breadth-first scan of files in the given directory. + Yields file paths in breadth-first order. + """ + queue = [root_dir] + while queue: + current_dir = queue.pop(0) + try: + for item in os.listdir(current_dir): + if item.startswith("."): + continue + item_path = os.path.join(current_dir, item) + if os.path.isdir(item_path): + queue.append(item_path) + elif os.path.isfile(item_path): + yield item_path + except (PermissionError, OSError): + # Skip directories we can't access + pass + + +def find_solution_or_project_file(root_dir: str) -> str | None: + """ + Find the first .sln or .slnx file in breadth-first order. + If no solution file is found, look for a .csproj file. + """ + sln_file = None + csproj_file = None + + for filename in breadth_first_file_scan(root_dir): + if filename.endswith((".sln", ".slnx")) and sln_file is None: + sln_file = filename + elif filename.endswith(".csproj") and csproj_file is None: + csproj_file = filename + + # If we found a solution file, return it immediately + if sln_file: + return sln_file + + # If no solution file was found, return the first .csproj file + return csproj_file + + +class CSharpLanguageServer(SolidLanguageServer): + """ + Provides C# specific instantiation of the LanguageServer class using the official Roslyn-based + language server from NuGet.org. + + You can pass a list of runtime dependency overrides in ls_specific_settings["csharp"]["runtime_dependencies"]. + This is a list of dicts, each containing at least the "id" key, and optionally "platform_id" to uniquely + identify the dependency to override. + + You can also set `csharp_language_server_version` in ``ls_specific_settings["csharp"]`` to override + the pinned Roslyn Language Server package version Serena downloads by default. + + Example - Override Roslyn Language Server URL: + ``` + { + "id": "CSharpLanguageServer", + "platform_id": "win-x64", + "url": "https://example.com/custom-roslyn-server.nupkg" + } + ``` + + See the `_RUNTIME_DEPENDENCIES` variable above for the available dependency ids and platform_ids. + + Note: .NET runtime (version 10+) is required and installed automatically via Microsoft's official install + scripts. If you have a custom .NET installation, ensure 'dotnet' is available in PATH with version 10 or higher. + """ + + @classmethod + def supports_implementation_request(cls) -> bool: + return True + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a CSharpLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + super().__init__(config, repository_root_path, None, "csharp", solidlsp_settings) + # Cache for original Roslyn symbol names with type annotations + # Key: (relative_file_path, line, character) -> Value: original name + self._original_symbol_names: dict[tuple[str, int, int], str] = {} + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir, self._solidlsp_settings, self.repository_root_path) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["bin", "obj", "packages", ".vs"] + + @override + def request_hover(self, relative_file_path: str, line: int, column: int, file_buffer: LSPFileBuffer | None = None) -> Hover | None: + """ + Override to inject original Roslyn symbol names (with type annotations) into hover responses. + + When hovering over a symbol whose name was normalized, we prepend the original + full name (e.g., 'Add(int, int) : int') to the hover content. + """ + hover = super().request_hover(relative_file_path, line, column, file_buffer=file_buffer) + + if hover is None: + return None + + # Check if we have an original name for this position + original_name = self._original_symbol_names.get((relative_file_path, line, column)) + + if original_name and "contents" in hover: + contents = hover["contents"] + if isinstance(contents, dict) and "value" in contents: + # Prepend the original full name with type information to the hover content + prefix = f"**{original_name}**\n\n---\n\n" + contents["value"] = prefix + contents["value"] + + return hover + + def _document_symbols_cache_fingerprint(self) -> Hashable | None: + normalize_symbol_name_version = 1 + return normalize_symbol_name_version + + def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: + # Roslyn 5.5.0+ returns symbol names with type annotations: + # - Properties: "Name : string" + # - Methods: "Add(int, int) : int" + # + # This method: + # 1. Normalizes names to base form ("Name", "Add") + # 2. Caches original names for rich information display + # 3. Populates LSP spec's 'detail' field with type/signature info + original_name = symbol["name"] + + # Extract base name and type/signature info + normalized_name, type_info = self._extract_base_name_and_type(original_name) + + # Store original name if it was normalized + if original_name != normalized_name: + sel_range = symbol.get("selectionRange") + if sel_range: + start = sel_range.get("start") + if start and "line" in start and "character" in start: + line = start["line"] + char = start["character"] + cache_key = (relative_file_path, line, char) + self._original_symbol_names[cache_key] = original_name + + # Populate 'detail' field with type/signature information (for UnifiedSymbolInformation) + if type_info and "detail" not in symbol: + symbol["detail"] = type_info # type: ignore + + return normalized_name + + @staticmethod + def _extract_base_name_and_type(roslyn_name: str) -> tuple[str, str]: + """ + Extract base name and type/signature information from Roslyn symbol names. + + Examples: + "Name : string" -> ("Name", ": string") + "Add(int, int) : int" -> ("Add", "(int, int) : int") + "ToString()" -> ("ToString", "()") + "SimpleMethod" -> ("SimpleMethod", "") + + Returns: + Tuple of (base_name, type_info) + + """ + # Check for property pattern: "Name : Type" + if " : " in roslyn_name and "(" not in roslyn_name: + base_name, type_part = roslyn_name.split(" : ", 1) + return base_name.strip(), f": {type_part.strip()}" + + # Check for method pattern: "MethodName(params) : ReturnType" + if "(" in roslyn_name: + paren_idx = roslyn_name.index("(") + base_name = roslyn_name[:paren_idx].strip() + signature = roslyn_name[paren_idx:].strip() + return base_name, signature + + # No type annotation + return roslyn_name, "" + + class DependencyProvider(LanguageServerDependencyProvider): + def __init__( + self, + custom_settings: SolidLSPSettings.CustomLSSettings, + ls_resources_dir: str, + solidlsp_settings: SolidLSPSettings, + repository_root_path: str, + ): + super().__init__(custom_settings, ls_resources_dir) + self._solidlsp_settings = solidlsp_settings + self._repository_root_path = repository_root_path + self._dotnet_path, self._language_server_path = self._ensure_server_installed() + + def create_launch_command(self) -> list[str]: + # Find solution or project file + solution_or_project = find_solution_or_project_file(self._repository_root_path) + + # Create log directory + log_dir = Path(self._ls_resources_dir) / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + + # Build command using dotnet directly + cmd = [self._dotnet_path, self._language_server_path, "--logLevel=Information", f"--extensionLogDirectory={log_dir}", "--stdio"] + + # The language server will discover the solution/project from the workspace root + if solution_or_project: + log.info(f"Found solution/project file: {solution_or_project}") + else: + log.warning("No .sln/.slnx or .csproj file found, language server will attempt auto-discovery") + + log.debug(f"Language server command: {' '.join(cmd)}") + + return cmd + + def _ensure_server_installed(self) -> tuple[str, str]: + """ + Ensure .NET runtime and Microsoft.CodeAnalysis.LanguageServer are available. + Returns a tuple of (dotnet_path, language_server_dll_path). + """ + runtime_dependency_overrides = cast(list[dict[str, Any]], self._custom_settings.get("runtime_dependencies", [])) + + # Filter out deprecated DotNetRuntime overrides and warn users + filtered_overrides = [] + for dep_override in runtime_dependency_overrides: + if dep_override.get("id") == "DotNetRuntime": + log.warning( + "The 'DotNetRuntime' runtime_dependencies override is no longer supported. " + ".NET is now installed automatically via Microsoft's official install scripts. " + "Please remove this override from your configuration." + ) + else: + filtered_overrides.append(dep_override) + + log.debug("Resolving runtime dependencies") + csharp_language_server_version = self._custom_settings.get( + "csharp_language_server_version", DEFAULT_CSHARP_LANGUAGE_SERVER_VERSION + ) + + runtime_dependencies = RuntimeDependencyCollection( + _runtime_dependencies_for_version(csharp_language_server_version), + overrides=filtered_overrides, + ) + + log.debug( + f"Available runtime dependencies: {runtime_dependencies.get_dependencies_for_current_platform}", + ) + + # Find the dependencies for our platform + lang_server_dep = runtime_dependencies.get_single_dep_for_current_platform("CSharpLanguageServer") + dotnet_path = self._ensure_dotnet_runtime() + server_dll_path = self._ensure_language_server(lang_server_dep) + + return dotnet_path, server_dll_path + + def _ensure_dotnet_runtime(self) -> str: + """Ensure .NET runtime is available and return the dotnet executable path.""" + return DotNETUtil("10.0", allow_higher_version=True).get_dotnet_path_or_raise() + + def _ensure_language_server(self, lang_server_dep: RuntimeDependency) -> str: + """Ensure language server is available and return the DLL path.""" + package_name = lang_server_dep.package_name + package_version = lang_server_dep.package_version + + server_dir = Path(self._ls_resources_dir) / f"{package_name}.{package_version}" + assert lang_server_dep.binary_name is not None + server_dll = server_dir / lang_server_dep.binary_name + + if server_dll.exists(): + log.info(f"Using cached Roslyn Language Server from {server_dll}") + return str(server_dll) + + # Download and install the language server + log.info(f"Downloading {package_name} version {package_version} from NuGet.org...") + package_path = self._download_nuget_package(lang_server_dep) + + # Extract and install + self._extract_language_server(lang_server_dep, package_path, server_dir) + + if not server_dll.exists(): + raise SolidLSPException("Roslyn Language Server DLL not found after extraction") + + # Make executable on Unix systems + if platform.system().lower() != "windows": + server_dll.chmod(0o755) + + log.info(f"Successfully installed Roslyn Language Server to {server_dll}") + return str(server_dll) + + @staticmethod + def _extract_language_server(lang_server_dep: RuntimeDependency, package_path: Path, server_dir: Path) -> None: + """Extract language server files from downloaded package.""" + extract_path = lang_server_dep.extract_path or "lib/net9.0" + source_dir = package_path / extract_path + + if not source_dir.exists(): + # Try alternative locations + for possible_dir in [ + package_path / "tools" / "net9.0" / "any", + package_path / "lib" / "net9.0", + package_path / "contentFiles" / "any" / "net9.0", + ]: + if possible_dir.exists(): + source_dir = possible_dir + break + else: + raise SolidLSPException(f"Could not find language server files in package. Searched in {package_path}") + + # Copy files to cache directory + server_dir.mkdir(parents=True, exist_ok=True) + shutil.copytree(source_dir, server_dir, dirs_exist_ok=True) + + def _download_nuget_package(self, dependency: RuntimeDependency) -> Path: + """ + Download a NuGet package from NuGet.org and extract it. + Returns the path to the extracted package directory. + """ + package_name = dependency.package_name + package_version = dependency.package_version + url = dependency.url + + if url is None: + raise SolidLSPException(f"No URL specified for package {package_name} version {package_version}") + + package_extract_dir = Path(self._ls_resources_dir) / "temp_downloads" / f"{package_name}.{package_version}" + projected_path = package_extract_dir / (dependency.extract_path or "") / ROSLYN_LONGEST_KNOWN_PACKAGE_MEMBER + if platform.system() == "Windows" and len(str(projected_path)) >= WINDOWS_MAX_PATH: + log.warning("Using a short temporary directory for Roslyn package extraction because the configured cache path is too deep") + package_extract_dir = Path(tempfile.mkdtemp(prefix="serena-roslyn-")) / package_extract_dir.name + package_extract_dir.parent.mkdir(parents=True, exist_ok=True) + + try: + log.debug(f"Downloading package from: {url}") + FileUtils.download_and_extract_archive_verified( + url, + str(package_extract_dir), + "zip", + expected_sha256=dependency.sha256, + allowed_hosts=dependency.allowed_hosts, + ) + + log.info(f"Successfully downloaded and extracted {package_name} version {package_version} from NuGet.org") + return package_extract_dir + + except Exception as e: + raise SolidLSPException(f"Failed to download package {package_name} version {package_version} from NuGet.org: {e}") from e + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Microsoft.CodeAnalysis.LanguageServer. + """ + return { + "capabilities": { + "window": { + "workDoneProgress": True, + "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, + "showDocument": {"support": True}, + }, + "workspace": { + "applyEdit": True, + "workspaceEdit": {"documentChanges": True}, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "executeCommand": {"dynamicRegistration": True}, + "configuration": True, + "workspaceFolders": True, + "workDoneProgress": True, + }, + "textDocument": { + "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + }, + }, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "hierarchicalDocumentSymbolSupport": True, + }, + }, + }, + } + + def _start_server(self) -> None: + indexing_complete = threading.Event() + + def do_nothing(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + """Log messages from the language server.""" + message_text = msg.get("message", "") + level = msg.get("type", 4) # Default to Log level + + # Map LSP message types to Python logging levels + level_map = {1: logging.ERROR, 2: logging.WARNING, 3: logging.INFO, 4: logging.DEBUG} # Error # Warning # Info # Log + + log.log(level_map.get(level, logging.DEBUG), f"LSP: {message_text}") + + def handle_progress(params: dict) -> None: + """Handle progress notifications from the language server.""" + token = params.get("token", "") + value = params.get("value", {}) + + # Log raw progress for debugging + log.debug(f"Progress notification received: {params}") + + # Handle different progress notification types + kind = value.get("kind") + + if kind == "begin": + title = value.get("title", "Operation in progress") + message = value.get("message", "") + percentage = value.get("percentage") + + if percentage is not None: + log.debug(f"Progress [{token}]: {title} - {message} ({percentage}%)") + else: + log.info(f"Progress [{token}]: {title} - {message}") + + elif kind == "report": + message = value.get("message", "") + percentage = value.get("percentage") + + if percentage is not None: + log.info(f"Progress [{token}]: {message} ({percentage}%)") + elif message: + log.info(f"Progress [{token}]: {message}") + + elif kind == "end": + message = value.get("message", "Operation completed") + log.info(f"Progress [{token}]: {message}") + + def handle_workspace_configuration(params: dict) -> list: + """Handle workspace/configuration requests from the server.""" + items = params.get("items", []) + result: list[Any] = [] + + for item in items: + section = item.get("section", "") + + # Provide default values based on the configuration section + if section.startswith(("dotnet", "csharp")): + # Default configuration for C# settings + if "enable" in section or "show" in section or "suppress" in section or "navigate" in section: + # Boolean settings + result.append(False) + elif "scope" in section: + # Scope settings - use appropriate enum values + if "analyzer_diagnostics_scope" in section: + result.append("openFiles") # BackgroundAnalysisScope + elif "compiler_diagnostics_scope" in section: + result.append("openFiles") # CompilerDiagnosticsScope + else: + result.append("openFiles") + elif section == "dotnet_member_insertion_location": + # ImplementTypeInsertionBehavior enum + result.append("with_other_members_of_the_same_kind") + elif section == "dotnet_property_generation_behavior": + # ImplementTypePropertyGenerationBehavior enum + result.append("prefer_throwing_properties") + elif "location" in section or "behavior" in section: + # Other enum settings - return null to avoid parsing errors + result.append(None) + else: + # Default for other dotnet/csharp settings + result.append(None) + elif section == "tab_width" or section == "indent_size": + # Tab and indent settings + result.append(4) + elif section == "insert_final_newline": + # Editor settings + result.append(True) + else: + # Unknown configuration - return null + result.append(None) + + return result + + def handle_work_done_progress_create(params: dict) -> None: + """Handle work done progress create requests.""" + # Just acknowledge the request + return + + def handle_register_capability(params: dict) -> None: + """Handle client/registerCapability requests.""" + # Just acknowledge the request - we don't need to track these for now + return + + def handle_project_needs_restore(params: dict) -> None: + return + + def handle_workspace_indexing_complete(params: dict) -> None: + indexing_complete.set() + + # Set up notification handlers + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", handle_progress) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("workspace/projectInitializationComplete", handle_workspace_indexing_complete) + self.server.on_request("workspace/configuration", handle_workspace_configuration) + self.server.on_request("window/workDoneProgress/create", handle_work_done_progress_create) + self.server.on_request("client/registerCapability", handle_register_capability) + self.server.on_request("workspace/_roslyn_projectNeedsRestore", handle_project_needs_restore) + + log.info("Starting Microsoft.CodeAnalysis.LanguageServer process") + + try: + self.server.start() + except Exception as e: + log.info(f"Failed to start language server process: {e}", logging.ERROR) + raise SolidLSPException(f"Failed to start C# language server: {e}") + + # Send initialization + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request to language server") + try: + init_response = self.server.send.initialize(initialize_params) + log.info(f"Received initialize response: {init_response}") + except Exception as e: + raise SolidLSPException(f"Failed to initialize C# language server for {self.repository_root_path}: {e}") from e + + # Apply diagnostic capabilities + self._force_pull_diagnostics(init_response) + + # Verify required capabilities + capabilities = init_response.get("capabilities", {}) + required_capabilities = [ + "textDocumentSync", + "definitionProvider", + "referencesProvider", + "documentSymbolProvider", + ] + missing = [cap for cap in required_capabilities if cap not in capabilities] + if missing: + raise RuntimeError( + f"Language server is missing required capabilities: {', '.join(missing)}. " + "Initialization failed. Please ensure the correct version of Microsoft.CodeAnalysis.LanguageServer is installed and the .NET runtime is working." + ) + + # Complete initialization + self.server.notify.initialized({}) + + # Open solution and project files + self._open_solution_and_projects() + + log.info( + "Microsoft.CodeAnalysis.LanguageServer initialized and ready\n" + "Waiting for language server to index project files...\n" + "This may take a while for large projects" + ) + + if indexing_complete.wait(30): # Wait up to 30 seconds for indexing + log.info("Indexing complete") + else: + log.warning("Timeout waiting for indexing to complete, proceeding anyway") + + def _force_pull_diagnostics(self, init_response: dict | InitializeResult) -> None: + """ + Apply the diagnostic capabilities hack. + Forces the server to support pull diagnostics. + """ + capabilities = init_response.get("capabilities", {}) + diagnostic_provider: Any = capabilities.get("diagnosticProvider", {}) + + # Add the diagnostic capabilities hack + if isinstance(diagnostic_provider, dict): + diagnostic_provider.update( + { + "interFileDependencies": True, + "workDoneProgress": True, + "workspaceDiagnostics": True, + } + ) + log.debug("Applied diagnostic capabilities hack for better C# diagnostics") + + def _open_solution_and_projects(self) -> None: + """ + Open solution and project files using notifications. + """ + # Find solution file (.sln or .slnx) + solution_file = None + for filename in breadth_first_file_scan(self.repository_root_path): + if filename.endswith((".sln", ".slnx")): + solution_file = filename + break + + # Send solution/open notification if solution file found + if solution_file: + solution_uri = PathUtils.path_to_uri(solution_file) + self.server.notify.send_notification("solution/open", {"solution": solution_uri}) + log.debug(f"Opened solution file: {solution_file}") + + # Find and open project files + project_files = [] + for filename in breadth_first_file_scan(self.repository_root_path): + if filename.endswith(".csproj"): + project_files.append(filename) + + # Send project/open notifications for each project file + if project_files: + project_uris = [PathUtils.path_to_uri(project_file) for project_file in project_files] + self.server.notify.send_notification("project/open", {"projects": project_uris}) + log.debug(f"Opened project files: {project_files}") + + @override + def _get_wait_time_for_cross_file_referencing(self) -> float: + return 2 diff --git a/src/solidlsp/language_servers/cue_language_server.py b/src/solidlsp/language_servers/cue_language_server.py new file mode 100644 index 0000000..444c5f2 --- /dev/null +++ b/src/solidlsp/language_servers/cue_language_server.py @@ -0,0 +1,259 @@ +""" +Provides CUE-specific instantiation of the LanguageServer class, using the LSP mode of the +``cue`` CLI (``cue lsp``) from the official CUE distribution. +""" + +from __future__ import annotations + +import logging +import os +import threading + +from overrides import override + +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +from .common import RuntimeDependency, RuntimeDependencyCollection + +log = logging.getLogger(__name__) + +# How to refresh the pinned SHA256s when bumping DEFAULT_CUE_VERSION: +# gh release view --repo cue-lang/cue --json assets \ +# --jq '.assets[] | select(.name | test("(darwin|linux|windows)_(amd64|arm64)")) | {name, digest}' +# The `digest` field is `sha256:` — copy the hex portion into DEFAULT_CUE_SHA256_BY_PLATFORM +# keyed by the Serena PlatformId (osx-arm64, osx-x64, linux-arm64, linux-x64, win-x64, win-arm64). +DEFAULT_CUE_VERSION = "v0.16.1" +DEFAULT_CUE_SHA256_BY_PLATFORM = { + "osx-arm64": "a72b0cddb377c52d1b003bed9a335d893b70cd75a182cd5e3fee8bae30ddb6d6", + "osx-x64": "97b0d78e4c5ee49ff72145fd6ef4f4bab0bb332d55f29660de3fec2af5ec96a9", + "linux-arm64": "3cc715a9e969f87b93c4fa34cfaef5388b93e96efa20b248e8ad6826abd25a83", + "linux-x64": "5d644c1305a2b86504c8dcd2ec829cf5b4999efc2cf51ee375624e0455f774ae", + "win-x64": "2f24123f458229fcf283db534bd86692ad1074da806defee0f0cc62976c0397c", + "win-arm64": "e0c15ce53f73e8609b0e8ce6507298f3474b334ac5eb0c826c9497a811fd0cce", +} + + +def _cue_sha(version: str, platform_key: str) -> str | None: + if version == DEFAULT_CUE_VERSION: + return DEFAULT_CUE_SHA256_BY_PLATFORM.get(platform_key) + return None + + +CUE_ALLOWED_HOSTS = ( + "github.com", + "release-assets.githubusercontent.com", + "objects.githubusercontent.com", +) + + +class CueLanguageServer(SolidLanguageServer): + """ + Provides a CUE-specific instantiation of the language server, driven by ``cue lsp`` from the + official CUE CLI distribution. + + Recognised entries in ``ls_specific_settings["cue"]``: + - ``ls_path``: Absolute path to a pre-installed ``cue`` binary. Bypasses Serena's + auto-download mechanism; useful when the user already has ``cue`` on ``$PATH`` + (e.g. via ``brew install cue`` or ``go install``). + - ``cue_version``: Override the pinned cue version downloaded by Serena + (default: the bundled Serena version). Setting this to a version other than + ``DEFAULT_CUE_VERSION`` skips SHA256 verification (checksums for arbitrary + versions are unknown), so pair it with ``ls_path`` if integrity matters. + """ + + CUE_ALLOWED_HOSTS = CUE_ALLOWED_HOSTS + + # Directories worth pruning for CUE projects. cue.mod/gen/ contains generated Go->CUE + # bindings that shouldn't be traversed for symbolic operations; cue.mod/pkg/ holds + # fetched module dependencies (analogous to node_modules / vendor). + _IGNORED_DIRS = frozenset({"cue.mod/gen", "cue.mod/pkg"}) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in self._IGNORED_DIRS + + @classmethod + def _runtime_dependencies(cls, version: str) -> RuntimeDependencyCollection: + """Builds the platform-specific runtime dependency set for the given cue release. + + :param version: the cue release tag (e.g. ``v0.16.1``); the leading ``v`` is stripped when + constructing the archive filename, since cue releases embed the bare version there. + """ + version_no_v = version.lstrip("v") + cue_releases = f"https://github.com/cue-lang/cue/releases/download/{version}" + return RuntimeDependencyCollection( + [ + RuntimeDependency( + id="cue", + url=f"{cue_releases}/cue_v{version_no_v}_darwin_arm64.tar.gz", + platform_id="osx-arm64", + archive_type="gztar", + binary_name="cue", + sha256=_cue_sha(version, "osx-arm64"), + allowed_hosts=CUE_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="cue", + url=f"{cue_releases}/cue_v{version_no_v}_darwin_amd64.tar.gz", + platform_id="osx-x64", + archive_type="gztar", + binary_name="cue", + sha256=_cue_sha(version, "osx-x64"), + allowed_hosts=CUE_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="cue", + url=f"{cue_releases}/cue_v{version_no_v}_linux_arm64.tar.gz", + platform_id="linux-arm64", + archive_type="gztar", + binary_name="cue", + sha256=_cue_sha(version, "linux-arm64"), + allowed_hosts=CUE_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="cue", + url=f"{cue_releases}/cue_v{version_no_v}_linux_amd64.tar.gz", + platform_id="linux-x64", + archive_type="gztar", + binary_name="cue", + sha256=_cue_sha(version, "linux-x64"), + allowed_hosts=CUE_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="cue", + url=f"{cue_releases}/cue_v{version_no_v}_windows_amd64.zip", + platform_id="win-x64", + archive_type="zip", + binary_name="cue.exe", + sha256=_cue_sha(version, "win-x64"), + allowed_hosts=CUE_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="cue", + url=f"{cue_releases}/cue_v{version_no_v}_windows_arm64.zip", + platform_id="win-arm64", + archive_type="zip", + binary_name="cue.exe", + sha256=_cue_sha(version, "win-arm64"), + allowed_hosts=CUE_ALLOWED_HOSTS, + ), + ] + ) + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """Creates a CueLanguageServer instance. + + Not meant to be instantiated directly — use :meth:`SolidLanguageServer.create` instead. + """ + super().__init__( + config, + repository_root_path, + None, + "cue", + solidlsp_settings, + ) + self.server_ready = threading.Event() + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + """Resolves a ``cue`` executable, downloading the pinned release if it isn't cached yet.""" + + def _get_or_install_core_dependency(self) -> str: + cue_version = self._custom_settings.get("cue_version", DEFAULT_CUE_VERSION) + deps = CueLanguageServer._runtime_dependencies(cue_version) + dependency = deps.get_single_dep_for_current_platform() + + install_dir = os.path.join(self._ls_resources_dir, f"cue-{cue_version}") + cue_executable_path = deps.binary_path(install_dir) + if not os.path.exists(cue_executable_path): + log.info(f"Downloading and extracting cue from {dependency.url} to {install_dir}") + deps.install(install_dir) + if not os.path.exists(cue_executable_path): + raise FileNotFoundError(f"Download failed? Could not find cue executable at {cue_executable_path}") + os.chmod(cue_executable_path, 0o755) + return cue_executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + # cue's LSP mode is activated via the `lsp` subcommand; it speaks LSP over stdio. + return [core_path, "lsp"] + + def _create_base_initialize_params(self) -> dict: + """Returns the init params for ``cue lsp``.""" + result = { + "capabilities": { + "workspace": { + "applyEdit": True, + "workspaceEdit": {"documentChanges": True}, + "symbol": {"symbolKind": {"valueSet": list(range(1, 27))}}, + "workspaceFolders": True, + "configuration": True, + }, + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True, "tagSupport": {"valueSet": [1, 2]}}, + "definition": {"linkSupport": True, "dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "hover": {"contentFormat": ["markdown", "plaintext"], "dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "completion": { + "dynamicRegistration": True, + "completionItem": {"snippetSupport": False, "documentationFormat": ["markdown", "plaintext"]}, + }, + }, + "general": {"positionEncodings": ["utf-16"]}, + }, + "initializationOptions": {}, + "trace": "off", + } + return result + + def _start_server(self) -> None: + def register_capability_handler(params: dict) -> None: + return + + def workspace_configuration_handler(params: dict) -> list[dict]: + # cue lsp asks the client for its configuration shortly after initialization + # and will not start servicing requests until it gets a reply. We return an + # empty object per requested item — cue's defaults are fine for our use. + items = params.get("items", []) if isinstance(params, dict) else [] + return [{} for _ in items] + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(params: dict) -> None: + return + + # wire up notification/request handlers before starting the process + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_request("workspace/configuration", workspace_configuration_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting cue lsp server process") + self.server.start() + + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + # sanity-check the server advertises the core capabilities we rely on + capabilities = init_response["capabilities"] + assert "textDocumentSync" in capabilities + assert "definitionProvider" in capabilities + assert "documentSymbolProvider" in capabilities + assert "referencesProvider" in capabilities + + self.server.notify.initialized({}) + # cue lsp is ready to serve immediately after the initialized notification + self.server_ready.set() diff --git a/src/solidlsp/language_servers/dart_language_server.py b/src/solidlsp/language_servers/dart_language_server.py new file mode 100644 index 0000000..59fd432 --- /dev/null +++ b/src/solidlsp/language_servers/dart_language_server.py @@ -0,0 +1,206 @@ +import logging +import os +from collections.abc import Hashable + +from overrides import override + +from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +from ..ls_config import Language, LanguageServerConfig +from .common import RuntimeDependency, RuntimeDependencyCollection + +log = logging.getLogger(__name__) + +DART_ALLOWED_HOSTS = ("storage.googleapis.com",) + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_DART_SDK_VERSION = "3.7.1" +INITIAL_DART_SDK_SHA256_BY_PLATFORM = { + "linux-x64": "2813959e7d9650334015b927cc533f5beadfbf7fa48248beec471f8942a0ee71", + "win-x64": "f56c03122e17abe5be1429eee0a975fb8ed511b6731ec90c6475992d3dee4ea5", + "win-arm64": "fada411c6538d0ac24c35d6360767241f1298f64cbc5e88716387d54757a105a", + "osx-x64": "a2765917b6ae49d1ac119553df9584989f9c441a46e8f18c129ba52489658d2e", + "osx-arm64": "f57c25163092bac818f8ca6250a0d8b2c56344c6a075a1bd7c60da7ac28b32a4", +} +DEFAULT_DART_SDK_VERSION = "3.7.1" +DEFAULT_DART_SDK_SHA256_BY_PLATFORM = { + "linux-x64": "2813959e7d9650334015b927cc533f5beadfbf7fa48248beec471f8942a0ee71", + "win-x64": "f56c03122e17abe5be1429eee0a975fb8ed511b6731ec90c6475992d3dee4ea5", + "win-arm64": "fada411c6538d0ac24c35d6360767241f1298f64cbc5e88716387d54757a105a", + "osx-x64": "a2765917b6ae49d1ac119553df9584989f9c441a46e8f18c129ba52489658d2e", + "osx-arm64": "f57c25163092bac818f8ca6250a0d8b2c56344c6a075a1bd7c60da7ac28b32a4", +} + + +def _dart_sdk_sha(version: str, platform_key: str) -> str | None: + if version == INITIAL_DART_SDK_VERSION: + return INITIAL_DART_SDK_SHA256_BY_PLATFORM[platform_key] + if version == DEFAULT_DART_SDK_VERSION: + return DEFAULT_DART_SDK_SHA256_BY_PLATFORM[platform_key] + return None + + +class DartLanguageServer(SolidLanguageServer): + """ + Provides Dart specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Dart. + + You can pass the following entries in ``ls_specific_settings["dart"]``: + - dart_sdk_version: Override the pinned Dart SDK version downloaded by Serena + (default: the bundled Serena version). + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None: + """ + Creates a DartServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + executable_path = self._setup_runtime_dependencies(solidlsp_settings) + super().__init__( + config, repository_root_path, ProcessLaunchInfo(cmd=executable_path, cwd=repository_root_path), "dart", solidlsp_settings + ) + + @override + def _document_symbols_cache_fingerprint(self) -> Hashable: + normalize_symbol_name_version = 1 + return normalize_symbol_name_version + + @override + def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: + return symbol["name"].rsplit(".", 1)[-1] + + @classmethod + def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str: + dart_settings = solidlsp_settings.get_ls_specific_settings(Language.DART) + dart_sdk_version = dart_settings.get("dart_sdk_version", DEFAULT_DART_SDK_VERSION) + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="DartLanguageServer", + description="Dart Language Server for Linux (x64)", + url=f"https://storage.googleapis.com/dart-archive/channels/stable/release/{dart_sdk_version}/sdk/dartsdk-linux-x64-release.zip", + platform_id="linux-x64", + archive_type="zip", + binary_name="dart-sdk/bin/dart", + sha256=_dart_sdk_sha(dart_sdk_version, "linux-x64"), + allowed_hosts=DART_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="DartLanguageServer", + description="Dart Language Server for Windows (x64)", + url=f"https://storage.googleapis.com/dart-archive/channels/stable/release/{dart_sdk_version}/sdk/dartsdk-windows-x64-release.zip", + platform_id="win-x64", + archive_type="zip", + binary_name="dart-sdk/bin/dart.exe", + sha256=_dart_sdk_sha(dart_sdk_version, "win-x64"), + allowed_hosts=DART_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="DartLanguageServer", + description="Dart Language Server for Windows (arm64)", + url=f"https://storage.googleapis.com/dart-archive/channels/stable/release/{dart_sdk_version}/sdk/dartsdk-windows-arm64-release.zip", + platform_id="win-arm64", + archive_type="zip", + binary_name="dart-sdk/bin/dart.exe", + sha256=_dart_sdk_sha(dart_sdk_version, "win-arm64"), + allowed_hosts=DART_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="DartLanguageServer", + description="Dart Language Server for macOS (x64)", + url=f"https://storage.googleapis.com/dart-archive/channels/stable/release/{dart_sdk_version}/sdk/dartsdk-macos-x64-release.zip", + platform_id="osx-x64", + archive_type="zip", + binary_name="dart-sdk/bin/dart", + sha256=_dart_sdk_sha(dart_sdk_version, "osx-x64"), + allowed_hosts=DART_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="DartLanguageServer", + description="Dart Language Server for macOS (arm64)", + url=f"https://storage.googleapis.com/dart-archive/channels/stable/release/{dart_sdk_version}/sdk/dartsdk-macos-arm64-release.zip", + platform_id="osx-arm64", + archive_type="zip", + binary_name="dart-sdk/bin/dart", + sha256=_dart_sdk_sha(dart_sdk_version, "osx-arm64"), + allowed_hosts=DART_ALLOWED_HOSTS, + ), + ] + ) + + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + dart_ls_dir = ( + cls.ls_resources_dir(solidlsp_settings) + if dart_sdk_version == INITIAL_DART_SDK_VERSION + else os.path.join(cls.ls_resources_dir(solidlsp_settings), f"dart-sdk-{dart_sdk_version}") + ) + dart_executable_path = deps.binary_path(dart_ls_dir) + + if not os.path.exists(dart_executable_path): + deps.install(dart_ls_dir) + + assert os.path.exists(dart_executable_path) + os.chmod(dart_executable_path, 0o755) + + return f"{dart_executable_path} language-server --client-id multilspy.dart --client-version 1.2" + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Dart Language Server. + """ + initialize_params = { + "capabilities": { + "textDocument": { + "documentSymbol": { + "hierarchicalDocumentSymbolSupport": True, + } + } + }, + "initializationOptions": { + "onlyAnalyzeProjectsWithOpenFiles": False, + "closingLabels": False, + "outline": False, + "flutterOutline": False, + "allowOpenUri": False, + }, + "trace": "verbose", + } + + return initialize_params + + def _start_server(self) -> None: + """ + Start the language server and yield when the server is ready. + """ + + def execute_client_command_handler(params: dict) -> list: + return [] + + def do_nothing(params: dict) -> None: + return + + def check_experimental_status(params: dict) -> None: + pass + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_notification("language/status", do_nothing) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + self.server.on_notification("experimental/serverStatus", check_experimental_status) + + log.info("Starting dart-language-server server process") + self.server.start() + initialize_params = self._create_initialize_params() + log.debug("Sending initialize request to dart-language-server") + init_response = self.server.send_request("initialize", initialize_params) # type: ignore + log.info(f"Received initialize response from dart-language-server: {init_response}") + + self.server.notify.initialized({}) diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py new file mode 100644 index 0000000..1539002 --- /dev/null +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -0,0 +1,1487 @@ +""" +Provides Java specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Java. +""" + +import dataclasses +import hashlib +import json +import logging +import os +import pathlib +import platform +import re +import shutil +import subprocess +import threading +from pathlib import Path, PurePath +from time import sleep +from typing import cast + +from overrides import override + +from solidlsp import ls_types +from solidlsp.ls import LanguageServerDependencyProvider, LSPFileBuffer, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.ls_types import UnifiedSymbolInformation +from solidlsp.ls_utils import FileUtils, PlatformUtils +from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, SymbolInformation +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +GRADLE_ALLOWED_HOSTS = ("services.gradle.org", "github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") +GRADLE_SHA256 = "7197a12f450794931532469d4ff21a59ea2c1cd59a3ec3f89c035c3c420a6999" +VSCODE_JAVA_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") +INTELLICODE_ALLOWED_HOSTS = ( + "visualstudioexptteam.gallery.vsassets.io", + "marketplace.visualstudio.com", + "download.visualstudio.microsoft.com", +) + + +# Version pinning convention (read this before bumping anything below): +# +# INITIAL_* — the very first version we shipped runtime-dependency support for, paired with +# its SHA. NEVER edited. The legacy unversioned install directory (e.g. "vscode-java/") +# is reserved exclusively for this version, so users who installed under the original +# layout keep their existing cache forever. +# +# DEFAULT_* — the version (and matching SHA) used when the user does not override via +# custom_settings. THIS is what we edit to upgrade: copy the new version + new SHA over +# the existing DEFAULT_* literals, leave INITIAL_* alone. +# +# Resolution at install time: +# resolved_version = custom_settings.get("..._version", DEFAULT_*_VERSION) +# - resolved == INITIAL_* -> legacy unversioned dir, SHA = INITIAL_*_SHA256 +# - resolved == DEFAULT_* -> versioned dir "{name}-{resolved}", SHA = DEFAULT_*_SHA256 +# - any other version -> versioned dir "{name}-{resolved}", SHA verification skipped +# +# Consequences of this scheme: +# - Bumping DEFAULT_* makes new installs land in a new versioned subdir => no silent reuse +# of stale binaries (the bug this scheme exists to fix). +# - Previously-downloaded versions are not lost: a user can request any past version via +# custom_settings and the matching cached subdir is picked up without re-downloading. +# - INITIAL_* and DEFAULT_* hold identical literals when first introduced; they diverge on +# the first DEFAULT_* bump and stay independent thereafter. +@dataclasses.dataclass(frozen=True) +class VsixResourcePaths: + """ + Paths to resources inside the vscode-java VSIX archive. These vary between vscode-java + releases (JRE bundle, Lombok jar, Equinox launcher all have versioned filenames inside + the VSIX), so we pin them per-version alongside SHA256 — bumping the VSIX version means + bumping these too. Without this, a user requesting a non-DEFAULT pinned version would + extract the right archive but then crash on FileNotFoundError trying to find the wrong + JRE / Lombok / launcher inside it. + """ + + jre_version: str # e.g. "21.0.7"; expanded to extension/jre/{jre_version}-{platform-suffix} + lombok_jar_basename: str # e.g. "lombok-1.18.36.jar" + equinox_launcher_basename: str # e.g. "org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar" + + +INITIAL_VSCODE_JAVA_VERSION = "1.42.0-561" +INITIAL_VSCODE_JAVA_SHA256_BY_PLATFORM = { + "osx-arm64": "bc00c2699d4b8d478eb9a1621db9d6d3a12ea0dcc247a9cd8040e8ac19c03933", + "osx-x64": "03ae1db1a22c15561a620f1b722d6797d35d4faaa7c4666dbe6ca2715089852f", + "linux-arm64": "e15bc9b2a665d3453203402621b5441062aa41b0ec2d140661f439326fd248c1", + "linux-x64": "7660b7b527be6fda46a917966b34d828e7416d5cc84287b29b88e7b99c1737f9", + "win-x64": "ef195b45bd260976ad2e84618f4044b5d7248deed41d647573f0ee22c4233df3", +} +INITIAL_VSCODE_JAVA_PATHS = VsixResourcePaths( + jre_version="21.0.7", + lombok_jar_basename="lombok-1.18.36.jar", + equinox_launcher_basename="org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", +) +# Bumped from 1.42.0-561 to surface Lombok-generated methods (#1432); brings JDTLS commit b2d8952 +# (java.symbols.includeGeneratedCode), JRE 21.0.10, Lombok 1.18.39 and Equinox launcher 1.7.100. +DEFAULT_VSCODE_JAVA_VERSION = "1.54.0-923" +DEFAULT_VSCODE_JAVA_SHA256_BY_PLATFORM = { + "osx-arm64": "c54c45cb0d2579d8e0a4ddeb24d4a9dd0b460d07d9366adea2b38a1da22a463c", + "osx-x64": "dfc98abc4e54165a78372e280242a039671729b1b03420608df3b10c6b629fb6", + "linux-arm64": "e2bb22c427d90da8dbb1afff72ff1e2dce38d50b76deb02d7bc313a330a1330c", + "linux-x64": "9d4b15da54e25a0192f9bac073f086c015397d3676623b68dbf83a5dbaf5132b", + "win-x64": "66f3914987edeccfee8a2558470e0fde4f8c4154232ff4baa5d73373ebc819d4", +} +DEFAULT_VSCODE_JAVA_PATHS = VsixResourcePaths( + jre_version="21.0.10", + lombok_jar_basename="lombok-1.18.39-4050.jar", + equinox_launcher_basename="org.eclipse.equinox.launcher_1.7.100.v20251111-0406.jar", +) + +INITIAL_INTELLICODE_VERSION = "1.2.30" +INITIAL_INTELLICODE_SHA256 = "7f61a7f96d101cdf230f96821be3fddd8f890ebfefb3695d18beee43004ae251" +DEFAULT_INTELLICODE_VERSION = "1.2.30" +DEFAULT_INTELLICODE_SHA256 = "7f61a7f96d101cdf230f96821be3fddd8f890ebfefb3695d18beee43004ae251" + +# Mapping from Serena's platform identifiers to upstream JDTLS config_ directory names +JDTLS_CONFIG_DIR_BY_PLATFORM = { + "osx-arm64": "config_mac_arm", + "darwin-arm64": "config_mac_arm", + "osx-x64": "config_mac", + "linux-arm64": "config_linux_arm", + "linux-x64": "config_linux", + "win-x64": "config_win", +} + +# Minimum supported JDK version for running JDTLS itself +JDTLS_MIN_JDK_VERSION = 21 + + +@dataclasses.dataclass +class RuntimeDependencyPaths: + """ + Stores the paths to the runtime dependencies of EclipseJDTLS. + + In the default mode (vscode-java VSIX), all paths are populated. + In the upstream-jdtls mode (when ``jdtls_path`` and ``lombok_path`` are set), + fields that have no upstream equivalent (gradle distribution, IntelliCode bundle) + are set to None. + """ + + jre_path: str + jre_home_path: str + jdtls_launcher_jar_path: str + jdtls_readonly_config_path: str + lombok_jar_path: str + gradle_path: str | None = None + intellicode_jar_path: str | None = None + intellisense_members_path: str | None = None + + +class EclipseJDTLS(SolidLanguageServer): + r""" + The EclipseJDTLS class provides a Java specific implementation of the LanguageServer class + + Two installation modes are supported: + + 1. **Default vscode-java VSIX mode** (no extra config required) — Serena downloads the platform-specific + vscode-java VSIX bundle (~500 MB: JDTLS + bundled JRE 21 + Lombok + IntelliCode), Gradle distribution + and IntelliCode VSIX from public hosts. Suitable when public network access is available. + + 2. **Upstream JDTLS mode** (activated by setting both ``jdtls_path`` and ``lombok_path``) — uses an + existing JDTLS installation and the system JDK. Nothing is downloaded. Suitable for restricted-network + environments. Requires JDK 21+ available via ``java_home`` setting / ``JAVA_HOME`` env / PATH. + Maven projects work out of the box (m2e bundled in JDTLS uses Maven Embedder); Gradle projects + need ``./gradlew`` in the project or a system-installed Gradle (Buildship default discovery). + + You can configure the following options in ls_specific_settings (in serena_config.yml): + Upstream JDTLS mode (mutually exclusive group — set both to activate): + - jdtls_path: Path to upstream JDTLS root (containing plugins/ and config_/). + Get via 'brew install jdtls' or extract jdt-language-server-*.tar.gz from + https://download.eclipse.org/jdtls/snapshots/. + - lombok_path: Path to lombok jar (e.g. ~/.m2/repository/org/projectlombok/lombok//lombok-.jar + or download from https://projectlombok.org/downloads/). + + Optional in upstream-jdtls mode: + - java_home: Path to JDK 21+ home directory. Falls back to JAVA_HOME env, then 'which java'. + + General settings (apply in both modes): + - maven_user_settings: Path to Maven settings.xml file (default: ~/.m2/settings.xml) + - gradle_user_home: Path to Gradle user home directory (default: ~/.gradle) + - gradle_wrapper_enabled: Whether to use the project's Gradle wrapper (default: false) + - gradle_java_home: Path to JDK for Gradle (default: null, falls back to JAVA_HOME when + use_system_java_home is true, then the bundled JRE) + - use_system_java_home: Whether to use the system's JAVA_HOME for JDTLS itself and, when + gradle_java_home is unset, Gradle import (default: false) + - jdtls_xmx: Maximum heap size for the JDTLS server JVM (default: "3G") + - jdtls_xms: Initial heap size for the JDTLS server JVM (default: "100m") + - intellicode_xmx: Maximum heap size for the IntelliCode embedded JVM (default: "1G") + - intellicode_xms: Initial heap size for the IntelliCode embedded JVM (default: "100m") + - lombok_show_generated: Show Lombok-generated methods (getX/setX/builder()/...) in document + symbols by sending java.symbols.includeGeneratedCode=true to JDTLS (default: true). + Set to false for @Data-heavy projects where the extra getters/setters are noise. + Requires JDTLS commit b2d8952+ (vscode-java >= 1.53.0); older servers ignore the + key silently. See eclipse-jdtls/eclipse.jdt.ls#3706 and serena #1432. + - gradle_version: Override the pinned Gradle distribution version downloaded by Serena + - vscode_java_version: Override the pinned vscode-java runtime bundle version downloaded by Serena. + Pinned versions: "1.54.0-923" (default) and "1.42.0-561" (legacy / initial). Other versions + are not supported in default VSIX mode (the resource paths inside the archive change between + releases); use upstream-jdtls mode for arbitrary versions. + - intellicode_version: Override the pinned IntelliCode VSIX version downloaded by Serena + + Example configuration for upstream JDTLS mode (no downloads, suitable for offline/corporate): + ```yaml + ls_specific_settings: + java: + jdtls_path: "/opt/homebrew/Cellar/jdtls/1.50.0/libexec" # or extracted tar.gz path + lombok_path: "/Users/me/.m2/repository/org/projectlombok/lombok/1.18.36/lombok-1.18.36.jar" + # java_home: "/opt/homebrew/opt/openjdk@21" # optional, JAVA_HOME env / PATH used otherwise + ``` + + Example configuration for default vscode-java VSIX mode (auto-download): + ```yaml + ls_specific_settings: + java: + maven_user_settings: "/home/user/.m2/settings.xml" # Unix/Linux/Mac + # maven_user_settings: 'C:\\Users\\YourName\\.m2\\settings.xml' # Windows (use single quotes!) + gradle_user_home: "/home/user/.gradle" # Unix/Linux/Mac + # gradle_user_home: 'C:\\Users\\YourName\\.gradle' # Windows (use single quotes!) + gradle_wrapper_enabled: true # set to true for projects with custom plugins/repositories + gradle_java_home: "/path/to/jdk" # set to override Gradle's JDK + use_system_java_home: true # set to true to use system JAVA_HOME for JDTLS and Gradle fallback + jdtls_xmx: "3G" # maximum heap size for the JDTLS server JVM + jdtls_xms: "100m" # initial heap size for the JDTLS server JVM + intellicode_xmx: "1G" # maximum heap size for the IntelliCode embedded JVM + intellicode_xms: "100m" # initial heap size for the IntelliCode embedded JVM + lombok_show_generated: true # show Lombok-generated methods in document symbols (default true) + gradle_version: "8.14.2" + vscode_java_version: "1.54.0-923" # also accepts pinned legacy "1.42.0-561" + intellicode_version: "1.2.30" + ``` + """ + + @classmethod + def supports_implementation_request(cls) -> bool: + return True + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a new EclipseJDTLS instance initializing the language server settings appropriately. + This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + super().__init__(config, repository_root_path, None, "java", solidlsp_settings) + + # Extract runtime_dependency_paths from the dependency provider + assert isinstance(self._dependency_provider, self.DependencyProvider) + self.runtime_dependency_paths = self._dependency_provider.runtime_dependency_paths + + self._service_ready_event = threading.Event() + self._project_ready_event = threading.Event() + self._intellicode_enable_command_available = threading.Event() + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + ls_resources_dir = self.ls_resources_dir(self._solidlsp_settings) + return self.DependencyProvider(self._custom_settings, ls_resources_dir, self._solidlsp_settings, self.repository_root_path) + + class DependencyProvider(LanguageServerDependencyProvider): + def __init__( + self, + custom_settings: SolidLSPSettings.CustomLSSettings, + ls_resources_dir: str, + solidlsp_settings: SolidLSPSettings, + repository_root_path: str, + ): + super().__init__(custom_settings, ls_resources_dir) + self._solidlsp_settings = solidlsp_settings + self._repository_root_path = repository_root_path + self.runtime_dependency_paths = self._setup_runtime_dependencies(ls_resources_dir, custom_settings) + + @staticmethod + def _setup_runtime_dependencies( + ls_resources_dir: str, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> RuntimeDependencyPaths: + """ + Setup runtime dependencies for EclipseJDTLS and return the paths. + + Two modes are supported: + + * **Upstream JDTLS mode** (activated when both ``jdtls_path`` and ``lombok_path`` are set + in ``ls_specific_settings.java``): uses an existing JDTLS installation (e.g. via + ``brew install jdtls`` or extracted ``jdt-language-server-*.tar.gz``) and the system JDK. + Nothing is downloaded by Serena. Suitable for restricted-network/corporate environments. + * **Default vscode-java VSIX mode** (activated otherwise): downloads the platform-specific + vscode-java VSIX bundle (containing JDTLS, bundled JRE 21, Lombok, IntelliCode), Gradle + distribution and IntelliCode VSIX from public hosts. Original behaviour, unchanged. + """ + jdtls_path = custom_settings.get("jdtls_path") + lombok_path = custom_settings.get("lombok_path") + if jdtls_path or lombok_path: + # both must be set together to activate upstream mode + if not (jdtls_path and lombok_path): + raise SolidLSPException( + "Both 'jdtls_path' and 'lombok_path' must be set together in " + "ls_specific_settings.java to use the upstream JDTLS mode. " + "Set both, or remove both to use the default vscode-java VSIX mode." + ) + return EclipseJDTLS.DependencyProvider._setup_from_existing_install(str(jdtls_path), str(lombok_path), custom_settings) + + platformId = PlatformUtils.get_platform_id() + gradle_version = custom_settings.get("gradle_version", "8.14.2") + vscode_java_version = custom_settings.get("vscode_java_version", DEFAULT_VSCODE_JAVA_VERSION) + vscode_java_tag = f"v{vscode_java_version.rsplit('-', 1)[0]}" + intellicode_version = custom_settings.get("intellicode_version", DEFAULT_INTELLICODE_VERSION) + default_gradle_version = gradle_version == "8.14.2" + + # install-dir name per the version-pinning convention (see module-level block): + # INITIAL -> legacy unversioned dir; everything else -> "{name}-{resolved}" subdir + vscode_java_dirname = ( + "vscode-java" if vscode_java_version == INITIAL_VSCODE_JAVA_VERSION else f"vscode-java-{vscode_java_version}" + ) + intellicode_dirname = ( + "intellicode" if intellicode_version == INITIAL_INTELLICODE_VERSION else f"intellicode-{intellicode_version}" + ) + + # SHA is only known for our two pinned versions (INITIAL and current DEFAULT); + # for any other user-supplied version we skip verification (returns None) + def vscode_java_sha(platform_key: str) -> str | None: + if vscode_java_version == INITIAL_VSCODE_JAVA_VERSION: + return INITIAL_VSCODE_JAVA_SHA256_BY_PLATFORM[platform_key] + if vscode_java_version == DEFAULT_VSCODE_JAVA_VERSION: + return DEFAULT_VSCODE_JAVA_SHA256_BY_PLATFORM[platform_key] + return None + + def intellicode_sha() -> str | None: + if intellicode_version == INITIAL_INTELLICODE_VERSION: + return INITIAL_INTELLICODE_SHA256 + if intellicode_version == DEFAULT_INTELLICODE_VERSION: + return DEFAULT_INTELLICODE_SHA256 + return None + + # Resolve internal VSIX paths (JRE / Lombok / launcher filenames). For pinned versions + # these are known; for any other user-supplied version we bail out — guessing would + # silently produce broken paths at JDTLS launch time, which is a worse UX than failing + # fast here with a pointer to upstream-JDTLS mode (which doesn't need pinned paths). + if vscode_java_version == INITIAL_VSCODE_JAVA_VERSION: + vsix_paths = INITIAL_VSCODE_JAVA_PATHS + elif vscode_java_version == DEFAULT_VSCODE_JAVA_VERSION: + vsix_paths = DEFAULT_VSCODE_JAVA_PATHS + else: + raise SolidLSPException( + f"Resource paths inside the vscode-java {vscode_java_version} VSIX are not pinned in serena " + f"(known: {INITIAL_VSCODE_JAVA_VERSION}, {DEFAULT_VSCODE_JAVA_VERSION}). " + f"Either remove the 'vscode_java_version' override (defaults to {DEFAULT_VSCODE_JAVA_VERSION}), " + f"or use upstream JDTLS mode by setting both 'jdtls_path' and 'lombok_path' in " + f"ls_specific_settings.java (no pinning required)." + ) + + runtime_dependencies: dict[str, dict[str, dict[str, object]]] = { + "gradle": { + "platform-agnostic": { + "url": f"https://services.gradle.org/distributions/gradle-{gradle_version}-bin.zip", + "archiveType": "zip", + "relative_extraction_path": ".", + "sha256": GRADLE_SHA256 if default_gradle_version else None, + "allowed_hosts": GRADLE_ALLOWED_HOSTS, + } + }, + "vscode-java": { + "darwin-arm64": { + "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-arm64-{vscode_java_version}.vsix", + "archiveType": "zip", + "relative_extraction_path": vscode_java_dirname, + "sha256": vscode_java_sha("osx-arm64"), + "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, + }, + "osx-arm64": { + "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-arm64-{vscode_java_version}.vsix", + "archiveType": "zip", + "relative_extraction_path": vscode_java_dirname, + "sha256": vscode_java_sha("osx-arm64"), + "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, + "jre_home_path": f"extension/jre/{vsix_paths.jre_version}-macosx-aarch64", + "jre_path": f"extension/jre/{vsix_paths.jre_version}-macosx-aarch64/bin/java", + "lombok_jar_path": f"extension/lombok/{vsix_paths.lombok_jar_basename}", + "jdtls_launcher_jar_path": f"extension/server/plugins/{vsix_paths.equinox_launcher_basename}", + "jdtls_readonly_config_path": "extension/server/config_mac_arm", + }, + "osx-x64": { + "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-x64-{vscode_java_version}.vsix", + "archiveType": "zip", + "relative_extraction_path": vscode_java_dirname, + "sha256": vscode_java_sha("osx-x64"), + "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, + "jre_home_path": f"extension/jre/{vsix_paths.jre_version}-macosx-x86_64", + "jre_path": f"extension/jre/{vsix_paths.jre_version}-macosx-x86_64/bin/java", + "lombok_jar_path": f"extension/lombok/{vsix_paths.lombok_jar_basename}", + "jdtls_launcher_jar_path": f"extension/server/plugins/{vsix_paths.equinox_launcher_basename}", + "jdtls_readonly_config_path": "extension/server/config_mac", + }, + "linux-arm64": { + "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-linux-arm64-{vscode_java_version}.vsix", + "archiveType": "zip", + "relative_extraction_path": vscode_java_dirname, + "sha256": vscode_java_sha("linux-arm64"), + "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, + "jre_home_path": f"extension/jre/{vsix_paths.jre_version}-linux-aarch64", + "jre_path": f"extension/jre/{vsix_paths.jre_version}-linux-aarch64/bin/java", + "lombok_jar_path": f"extension/lombok/{vsix_paths.lombok_jar_basename}", + "jdtls_launcher_jar_path": f"extension/server/plugins/{vsix_paths.equinox_launcher_basename}", + "jdtls_readonly_config_path": "extension/server/config_linux_arm", + }, + "linux-x64": { + "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-linux-x64-{vscode_java_version}.vsix", + "archiveType": "zip", + "relative_extraction_path": vscode_java_dirname, + "sha256": vscode_java_sha("linux-x64"), + "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, + "jre_home_path": f"extension/jre/{vsix_paths.jre_version}-linux-x86_64", + "jre_path": f"extension/jre/{vsix_paths.jre_version}-linux-x86_64/bin/java", + "lombok_jar_path": f"extension/lombok/{vsix_paths.lombok_jar_basename}", + "jdtls_launcher_jar_path": f"extension/server/plugins/{vsix_paths.equinox_launcher_basename}", + "jdtls_readonly_config_path": "extension/server/config_linux", + }, + "win-x64": { + "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-win32-x64-{vscode_java_version}.vsix", + "archiveType": "zip", + "relative_extraction_path": vscode_java_dirname, + "sha256": vscode_java_sha("win-x64"), + "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, + "jre_home_path": f"extension/jre/{vsix_paths.jre_version}-win32-x86_64", + "jre_path": f"extension/jre/{vsix_paths.jre_version}-win32-x86_64/bin/java.exe", + "lombok_jar_path": f"extension/lombok/{vsix_paths.lombok_jar_basename}", + "jdtls_launcher_jar_path": f"extension/server/plugins/{vsix_paths.equinox_launcher_basename}", + "jdtls_readonly_config_path": "extension/server/config_win", + }, + }, + "intellicode": { + "platform-agnostic": { + "url": f"https://VisualStudioExptTeam.gallery.vsassets.io/_apis/public/gallery/publisher/VisualStudioExptTeam/extension/vscodeintellicode/{intellicode_version}/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage", + "alternate_url": f"https://marketplace.visualstudio.com/_apis/public/gallery/publishers/VisualStudioExptTeam/vsextensions/vscodeintellicode/{intellicode_version}/vspackage", + "archiveType": "zip", + "relative_extraction_path": intellicode_dirname, + "sha256": intellicode_sha(), + "allowed_hosts": INTELLICODE_ALLOWED_HOSTS, + "intellicode_jar_path": "extension/dist/com.microsoft.jdtls.intellicode.core-0.7.0.jar", + "intellisense_members_path": "extension/dist/bundledModels/java_intellisense-members", + } + }, + } + + gradle_path = str( + PurePath( + ls_resources_dir, + f"gradle-{gradle_version}", + ) + ) + + if not os.path.exists(gradle_path): + gradle_dependency = runtime_dependencies["gradle"]["platform-agnostic"] + FileUtils.download_and_extract_archive_verified( + cast(str, gradle_dependency["url"]), + str(PurePath(gradle_path).parent), + cast(str, gradle_dependency["archiveType"]), + expected_sha256=cast(str | None, gradle_dependency["sha256"]), + allowed_hosts=cast(tuple[str, ...], gradle_dependency["allowed_hosts"]), + ) + + assert os.path.exists(gradle_path) + + dependency = runtime_dependencies["vscode-java"][platformId.value] + vscode_java_path = str(PurePath(ls_resources_dir, cast(str, dependency["relative_extraction_path"]))) + os.makedirs(vscode_java_path, exist_ok=True) + jre_home_path = str(PurePath(vscode_java_path, cast(str, dependency["jre_home_path"]))) + jre_path = str(PurePath(vscode_java_path, cast(str, dependency["jre_path"]))) + lombok_jar_path = str(PurePath(vscode_java_path, cast(str, dependency["lombok_jar_path"]))) + jdtls_launcher_jar_path = str(PurePath(vscode_java_path, cast(str, dependency["jdtls_launcher_jar_path"]))) + jdtls_readonly_config_path = str(PurePath(vscode_java_path, cast(str, dependency["jdtls_readonly_config_path"]))) + if not all( + [ + os.path.exists(vscode_java_path), + os.path.exists(jre_home_path), + os.path.exists(jre_path), + os.path.exists(lombok_jar_path), + os.path.exists(jdtls_launcher_jar_path), + os.path.exists(jdtls_readonly_config_path), + ] + ): + FileUtils.download_and_extract_archive_verified( + cast(str, dependency["url"]), + vscode_java_path, + cast(str, dependency["archiveType"]), + expected_sha256=cast(str | None, dependency["sha256"]), + allowed_hosts=cast(tuple[str, ...], dependency["allowed_hosts"]), + ) + + os.chmod(jre_path, 0o755) + + assert os.path.exists(vscode_java_path) + assert os.path.exists(jre_home_path) + assert os.path.exists(jre_path) + assert os.path.exists(lombok_jar_path) + assert os.path.exists(jdtls_launcher_jar_path) + assert os.path.exists(jdtls_readonly_config_path) + + dependency = runtime_dependencies["intellicode"]["platform-agnostic"] + intellicode_directory_path = str(PurePath(ls_resources_dir, cast(str, dependency["relative_extraction_path"]))) + os.makedirs(intellicode_directory_path, exist_ok=True) + intellicode_jar_path = str(PurePath(intellicode_directory_path, cast(str, dependency["intellicode_jar_path"]))) + intellisense_members_path = str(PurePath(intellicode_directory_path, cast(str, dependency["intellisense_members_path"]))) + if not all( + [ + os.path.exists(intellicode_directory_path), + os.path.exists(intellicode_jar_path), + os.path.exists(intellisense_members_path), + ] + ): + FileUtils.download_and_extract_archive_verified( + cast(str, dependency["url"]), + intellicode_directory_path, + cast(str, dependency["archiveType"]), + expected_sha256=cast(str | None, dependency["sha256"]), + allowed_hosts=cast(tuple[str, ...], dependency["allowed_hosts"]), + ) + + assert os.path.exists(intellicode_directory_path) + assert os.path.exists(intellicode_jar_path) + assert os.path.exists(intellisense_members_path) + + return RuntimeDependencyPaths( + gradle_path=gradle_path, + lombok_jar_path=lombok_jar_path, + jre_path=jre_path, + jre_home_path=jre_home_path, + jdtls_launcher_jar_path=jdtls_launcher_jar_path, + jdtls_readonly_config_path=jdtls_readonly_config_path, + intellicode_jar_path=intellicode_jar_path, + intellisense_members_path=intellisense_members_path, + ) + + @staticmethod + def _setup_from_existing_install( + jdtls_path: str, lombok_path: str, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> RuntimeDependencyPaths: + """ + Builds RuntimeDependencyPaths from an already-installed upstream JDTLS distribution + and the system-installed JDK. No downloads are performed. + + :param jdtls_path: absolute path to the JDTLS root (containing ``plugins/`` and ``config_/``) + :param lombok_path: absolute path to the Lombok jar (mandatory; agent is always attached) + :param custom_settings: language-server-specific settings from ls_specific_settings.java + :return: populated RuntimeDependencyPaths with gradle/intellicode fields set to None + """ + # validate jdtls_path structure (root + plugins dir) + jdtls_root = Path(jdtls_path) + if not jdtls_root.is_dir(): + raise SolidLSPException( + f"Provided jdtls_path '{jdtls_path}' is not an existing directory.\n" + f"Fix: extract jdt-language-server-*.tar.gz from " + f"https://download.eclipse.org/jdtls/snapshots/ or run 'brew install jdtls', " + f"then set ls_specific_settings.java.jdtls_path to the extracted directory." + ) + plugins_dir = jdtls_root / "plugins" + if not plugins_dir.is_dir(): + raise SolidLSPException( + f"Invalid jdtls_path '{jdtls_path}': 'plugins/' directory not found.\n" + f"Expected upstream JDTLS layout (plugins/, config_/, features/) at the root. " + f"If you pointed at a vscode-java extension, use '/server' instead." + ) + + # resolve the main equinox launcher jar (excluding platform-specific native fragments) + launcher_jar = EclipseJDTLS.DependencyProvider._resolve_launcher_jar(plugins_dir) + + # resolve the platform-specific config directory under jdtls_root + config_dir = EclipseJDTLS.DependencyProvider._resolve_config_dir(jdtls_root) + + # validate lombok jar exists + if not Path(lombok_path).is_file(): + raise SolidLSPException( + f"Provided lombok_path '{lombok_path}' does not exist or is not a file.\n" + f"Fix: download lombok jar from https://projectlombok.org/downloads/ or use the one " + f"from your local Maven cache (~/.m2/repository/org/projectlombok/lombok//lombok-.jar)." + ) + + # resolve system JDK (priority: java_home setting -> JAVA_HOME env -> which java), + # interrogate the JVM for its real java.home and validate version >= 21 + jre_home_path, jre_path = EclipseJDTLS.DependencyProvider._resolve_system_jdk(custom_settings) + + log.info( + f"Using upstream JDTLS at '{jdtls_path}' with system JDK '{jre_home_path}'. " + f"Launcher: {launcher_jar.name}; config: {config_dir.name}; lombok: {lombok_path}" + ) + + return RuntimeDependencyPaths( + jre_path=jre_path, + jre_home_path=jre_home_path, + jdtls_launcher_jar_path=str(launcher_jar), + jdtls_readonly_config_path=str(config_dir), + lombok_jar_path=lombok_path, + gradle_path=None, + intellicode_jar_path=None, + intellisense_members_path=None, + ) + + @staticmethod + def _resolve_launcher_jar(plugins_dir: Path) -> Path: + """ + Locates the main Equinox launcher jar in JDTLS's ``plugins/`` directory, excluding + platform-specific native fragments like ``org.eclipse.equinox.launcher.cocoa.macosx.*``. + + :return: path to the main launcher jar (e.g. ``org.eclipse.equinox.launcher_1.7.0.v....jar``) + """ + # main launcher matches: org.eclipse.equinox.launcher_...jar (single underscore, + # immediately followed by version digits — fragments have additional dotted segments). + pattern = re.compile(r"^org\.eclipse\.equinox\.launcher_\d.*\.jar$") + matches = sorted(p for p in plugins_dir.glob("org.eclipse.equinox.launcher_*.jar") if pattern.match(p.name)) + if not matches: + raise SolidLSPException( + f"No main Equinox launcher jar found in '{plugins_dir}'. " + f"Expected file like 'org.eclipse.equinox.launcher_.jar'. " + f"Verify the JDTLS extraction is complete and not corrupted." + ) + # if multiple versions are present (rare), pick the highest by name + return matches[-1] + + @staticmethod + def _resolve_config_dir(jdtls_root: Path) -> Path: + """ + Locates the platform-specific OSGi configuration directory inside the JDTLS root. + + :return: path to ``config_/`` directory matching the current OS/arch + """ + platform_id = PlatformUtils.get_platform_id().value + config_dir_name = JDTLS_CONFIG_DIR_BY_PLATFORM.get(platform_id) + if config_dir_name is None: + raise SolidLSPException( + f"Unsupported platform '{platform_id}' for upstream JDTLS mode. " + f"Supported platforms: {sorted(set(JDTLS_CONFIG_DIR_BY_PLATFORM.values()))}." + ) + config_dir = jdtls_root / config_dir_name + if not config_dir.is_dir(): + raise SolidLSPException( + f"Config directory '{config_dir}' not found. " + f"This JDTLS distribution does not support platform '{platform_id}'. " + f"Verify you downloaded the correct tar.gz for your OS/architecture." + ) + return config_dir + + @staticmethod + def _resolve_system_jdk(custom_settings: SolidLSPSettings.CustomLSSettings) -> tuple[str, str]: + """ + Resolves the system-installed JDK home and ``java`` executable, validates the version. + + The ``java`` executable is located by priority: ``java_home`` setting -> + ``JAVA_HOME`` env var -> ``java`` in PATH. The actual JDK home directory and + major version are then discovered by querying the JVM itself via + ``java -XshowSettings:properties -version`` — this is the single source of truth + and works correctly even when the locator is a system stub (e.g. ``/usr/bin/java`` + on macOS, which delegates to ``/usr/libexec/java_home`` under the hood and does not + resolve to the real JDK home via simple path traversal). + + :return: (jdk_home_directory, java_executable_path) + """ + # locate a java executable to interrogate + java_exe_name = "java.exe" if platform.system() == "Windows" else "java" + java_exe: str | None = None + source: str + + if explicit_home := custom_settings.get("java_home"): + candidate = str(Path(explicit_home) / "bin" / java_exe_name) + if not os.path.exists(candidate): + raise SolidLSPException( + f"java_home='{explicit_home}' is invalid: '{candidate}' does not exist. " + f"Set ls_specific_settings.java.java_home to a JDK home that contains bin/{java_exe_name}." + ) + java_exe = candidate + source = f"java_home setting ({explicit_home})" + elif env_home := os.environ.get("JAVA_HOME"): + candidate = str(Path(env_home) / "bin" / java_exe_name) + if os.path.exists(candidate): + java_exe = candidate + source = f"JAVA_HOME env ({env_home})" + else: + log.warning(f"JAVA_HOME='{env_home}' invalid (no '{candidate}'), falling back to PATH.") + + if java_exe is None: + java_in_path = shutil.which("java") + if java_in_path is None: + raise SolidLSPException( + "Could not locate a Java installation for JDTLS. " + "Set ls_specific_settings.java.java_home, set JAVA_HOME environment variable, " + f"or ensure 'java' is on PATH. Required: JDK {JDTLS_MIN_JDK_VERSION}+." + ) + java_exe = java_in_path + source = f"PATH ({java_in_path})" + + # interrogate the JVM for its real java.home and version (single source of truth) + real_jdk_home, major_version = EclipseJDTLS.DependencyProvider._inspect_java(java_exe) + + # validate version + if major_version < JDTLS_MIN_JDK_VERSION: + raise SolidLSPException( + f"JDTLS requires JDK {JDTLS_MIN_JDK_VERSION}+ but '{java_exe}' is JDK {major_version} " + f"(located via {source}, java.home={real_jdk_home}). " + f"Install a newer JDK and update ls_specific_settings.java.java_home or JAVA_HOME." + ) + log.info(f"Resolved JDK {major_version} via {source}; java.home={real_jdk_home}; java_exe={java_exe}.") + + # prefer to use bin/java from the *real* JDK home (so JDTLS subprocesses that read JAVA_HOME + # find a consistent layout); only fall back to the original locator if the real-home variant + # is missing for some reason. + real_java = str(Path(real_jdk_home) / "bin" / java_exe_name) + if os.path.exists(real_java): + java_exe = real_java + + return real_jdk_home, java_exe + + @staticmethod + def _inspect_java(java_exe: str) -> tuple[str, int]: + """ + Runs ``java -XshowSettings:properties -version`` and parses ``java.home`` and the + major version from the output. This is the most reliable cross-platform way to + discover the JDK home (works around macOS ``/usr/bin/java`` stub issue). + + :return: (java_home_directory_reported_by_jvm, major_version) + """ + try: + # both -XshowSettings:properties and -version write to stderr by convention + result = subprocess.run( + [java_exe, "-XshowSettings:properties", "-version"], + capture_output=True, + text=True, + timeout=15, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise SolidLSPException(f"Failed to run '{java_exe} -XshowSettings:properties -version': {exc}") from exc + + output = (result.stderr or "") + "\n" + (result.stdout or "") + + # parse java.home from " java.home = /path/to/jdk" + home_match = re.search(r"java\.home\s*=\s*(.+)", output) + if not home_match: + raise SolidLSPException( + f"Could not parse java.home from '{java_exe}' output. " + f"This usually means '{java_exe}' is not a valid JDK installation. " + f"First lines of output: {output.strip().splitlines()[:5]}" + ) + real_home = home_match.group(1).strip() + + # parse major version from version line: 'openjdk version "21.0.2"' or 'java version "21.0.2"' + version_match = re.search(r'version "(\d+)(?:\.\d+)*"', output) + if not version_match: + raise SolidLSPException( + f"Could not parse Java version from '{java_exe}' output. " + f"Required: JDK {JDTLS_MIN_JDK_VERSION}+. " + f"First lines of output: {output.strip().splitlines()[:5]}" + ) + major = int(version_match.group(1)) + return real_home, major + + @staticmethod + def _compute_workspace_hash( + repository_root_path: str, + jdtls_launcher_jar_path: str, + custom_settings: SolidLSPSettings.CustomLSSettings, + ) -> str: + """ + Compute the JDTLS workspace directory name. + + The launcher jar path is mixed in so that switching JDTLS versions (default + vscode-java VSIX bump or upstream install change) lands in a separate ws_dir + and avoids stale OSGi configs from the previous version blocking startup. + + Workspace-affecting settings are also mixed in so changing Maven/Gradle/JDK + inputs lands in a fresh workspace instead of silently reusing stale import + state from a previous configuration. + + Exception: legacy default-mode users on INITIAL_VSCODE_JAVA_VERSION keep the + original ``md5(repository_root_path)`` format *until* one of the tracked + workspace-affecting settings is set. This preserves existing caches for the + old default setup while still fixing stale-workspace bugs once users opt into + custom Java import settings. + """ + workspace_setting_keys = ( + "maven_user_settings", + "gradle_user_home", + "gradle_wrapper_enabled", + "gradle_java_home", + "java_home", + "use_system_java_home", + "maven_offline", + ) + workspace_settings = {key: custom_settings.settings[key] for key in workspace_setting_keys if key in custom_settings.settings} + workspace_settings_json = json.dumps(workspace_settings, sort_keys=True, separators=(",", ":")) + + is_legacy_initial = ( + not custom_settings.get("jdtls_path") + and custom_settings.get("vscode_java_version", DEFAULT_VSCODE_JAVA_VERSION) == INITIAL_VSCODE_JAVA_VERSION + and not workspace_settings + ) + if is_legacy_initial: + ws_hash_input = repository_root_path.encode() + else: + ws_hash_input = (repository_root_path + "|" + jdtls_launcher_jar_path + "|" + workspace_settings_json).encode() + return hashlib.md5(ws_hash_input).hexdigest() + + def create_launch_command(self) -> list[str]: + # ws_dir is the workspace directory for the EclipseJDTLS server. + project_hash = EclipseJDTLS.DependencyProvider._compute_workspace_hash( + self._repository_root_path, + self.runtime_dependency_paths.jdtls_launcher_jar_path, + self._custom_settings, + ) + ws_dir = str( + PurePath( + self._solidlsp_settings.ls_resources_dir, + "EclipseJDTLS", + "workspaces", + project_hash, + ) + ) + + # shared_cache_location is the global cache used by Eclipse JDTLS across all workspaces + shared_cache_location = str(PurePath(self._solidlsp_settings.ls_resources_dir, "lsp", "EclipseJDTLS", "sharedIndex")) + os.makedirs(shared_cache_location, exist_ok=True) + os.makedirs(ws_dir, exist_ok=True) + + jre_path = self.runtime_dependency_paths.jre_path + lombok_jar_path = self.runtime_dependency_paths.lombok_jar_path + + jdtls_launcher_jar = self.runtime_dependency_paths.jdtls_launcher_jar_path + jdtls_xmx = self._custom_settings.get("jdtls_xmx", "3G") + jdtls_xms = self._custom_settings.get("jdtls_xms", "100m") + + data_dir = str(PurePath(ws_dir, "data_dir")) + jdtls_config_path = str(PurePath(ws_dir, "config_path")) + + jdtls_readonly_config_path = self.runtime_dependency_paths.jdtls_readonly_config_path + + if not os.path.exists(jdtls_config_path): + shutil.copytree(jdtls_readonly_config_path, jdtls_config_path) + + for static_path in [ + jre_path, + lombok_jar_path, + jdtls_launcher_jar, + jdtls_config_path, + jdtls_readonly_config_path, + ]: + assert os.path.exists(static_path), static_path + + cmd = [ + jre_path, + "--add-modules=ALL-SYSTEM", + "--add-opens", + "java.base/java.util=ALL-UNNAMED", + "--add-opens", + "java.base/java.lang=ALL-UNNAMED", + "--add-opens", + "java.base/sun.nio.fs=ALL-UNNAMED", + "-Declipse.application=org.eclipse.jdt.ls.core.id1", + "-Dosgi.bundles.defaultStartLevel=4", + "-Declipse.product=org.eclipse.jdt.ls.core.product", + "-Djava.import.generatesMetadataFilesAtProjectRoot=false", + "-Dfile.encoding=utf8", + "-noverify", + "-XX:+UseParallelGC", + "-XX:GCTimeRatio=4", + "-XX:AdaptiveSizePolicyWeight=90", + "-Dsun.zip.disableMemoryMapping=true", + "-Djava.lsp.joinOnCompletion=true", + f"-Xmx{jdtls_xmx}", + f"-Xms{jdtls_xms}", + "-Xlog:disable", + "-Dlog.level=ALL", + f"-javaagent:{lombok_jar_path}", + f"-Djdt.core.sharedIndexLocation={shared_cache_location}", + "-jar", + f"{jdtls_launcher_jar}", + "-configuration", + f"{jdtls_config_path}", + "-data", + f"{data_dir}", + ] + + return cmd + + def create_launch_command_env(self) -> dict[str, str]: + use_system_java_home = self._custom_settings.get("use_system_java_home", False) + if use_system_java_home: + system_java_home = os.environ.get("JAVA_HOME") + if system_java_home: + log.info(f"Using system JAVA_HOME for JDTLS: {system_java_home}") + return {"syntaxserver": "false", "JAVA_HOME": system_java_home} + else: + log.warning("use_system_java_home is set but JAVA_HOME is not set in environment, falling back to bundled JRE") + java_home = self.runtime_dependency_paths.jre_home_path + log.info(f"Using bundled JRE for JDTLS: {java_home}") + return {"syntaxserver": "false", "JAVA_HOME": java_home} + + def _resolve_gradle_java_home(self) -> str: + """ + Resolve the Java home configured for JDTLS Gradle import settings. + + :return: Java home selected from explicit Gradle override, system ``JAVA_HOME`` when enabled, or bundled runtime. + :raises FileNotFoundError: If an explicitly configured ``gradle_java_home`` path does not exist. + """ + # explicit Gradle override... + gradle_java_home = self._custom_settings.get("gradle_java_home") + if gradle_java_home is not None: + if not os.path.exists(gradle_java_home): + error_msg = ( + f"Gradle Java home not found: {gradle_java_home}. " + f"Fix: update path in ~/.serena/serena_config.yml (ls_specific_settings -> java -> gradle_java_home), " + f"or remove the setting to use JAVA_HOME or the bundled JRE fallback" + ) + log.error(error_msg) + raise FileNotFoundError(error_msg) + log.info(f"Using Gradle Java home from custom location: {gradle_java_home}") + return gradle_java_home + + # system JAVA_HOME fallback... + use_system_java_home = self._custom_settings.get("use_system_java_home", False) + if use_system_java_home: + system_java_home = os.environ.get("JAVA_HOME") + if system_java_home: + log.info(f"Using system JAVA_HOME for Gradle: {system_java_home}") + return system_java_home + log.warning("use_system_java_home is set but JAVA_HOME is not set in environment, falling back to bundled JRE for Gradle") + + # bundled runtime fallback... + log.info(f"Using bundled JRE for Gradle: {self.runtime_dependency_paths.jre_path}") + return self.runtime_dependency_paths.jre_path + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize parameters for the EclipseJDTLS server. + """ + # Look into https://github.com/eclipse/eclipse.jdt.ls/blob/master/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/Preferences.java to understand all the options available + + repo_uri = pathlib.Path(self.repository_root_path).as_uri() + + # Load user's Maven and Gradle configuration paths from ls_specific_settings["java"] + + # Maven settings: default to ~/.m2/settings.xml + default_maven_settings_path = os.path.join(os.path.expanduser("~"), ".m2", "settings.xml") + custom_maven_settings_path = self._custom_settings.get("maven_user_settings") + if custom_maven_settings_path is not None: + # User explicitly provided a path + if not os.path.exists(custom_maven_settings_path): + error_msg = ( + f"Provided maven settings file not found: {custom_maven_settings_path}. " + f"Fix: create the file, update path in ~/.serena/serena_config.yml (ls_specific_settings -> java -> maven_user_settings), " + f"or remove the setting to use default ({default_maven_settings_path})" + ) + log.error(error_msg) + raise FileNotFoundError(error_msg) + maven_settings_path = custom_maven_settings_path + log.info(f"Using Maven settings from custom location: {maven_settings_path}") + elif os.path.exists(default_maven_settings_path): + maven_settings_path = default_maven_settings_path + log.info(f"Using Maven settings from default location: {maven_settings_path}") + else: + maven_settings_path = None + log.info(f"Maven settings not found at default location ({default_maven_settings_path}), will use JDTLS defaults") + + # Gradle user home: default to ~/.gradle + default_gradle_home = os.path.join(os.path.expanduser("~"), ".gradle") + custom_gradle_home = self._custom_settings.get("gradle_user_home") + if custom_gradle_home is not None: + # User explicitly provided a path + if not os.path.exists(custom_gradle_home): + error_msg = ( + f"Gradle user home directory not found: {custom_gradle_home}. " + f"Fix: create the directory, update path in ~/.serena/serena_config.yml (ls_specific_settings -> java -> gradle_user_home), " + f"or remove the setting to use default (~/.gradle)" + ) + log.error(error_msg) + raise FileNotFoundError(error_msg) + gradle_user_home = custom_gradle_home + log.info(f"Using Gradle user home from custom location: {gradle_user_home}") + elif os.path.exists(default_gradle_home): + gradle_user_home = default_gradle_home + log.info(f"Using Gradle user home from default location: {gradle_user_home}") + else: + gradle_user_home = None + log.info(f"Gradle user home not found at default location ({default_gradle_home}), will use JDTLS defaults") + + # IntelliCode JVM settings (used in vmargs for the embedded JVM) + intellicode_xmx = self._custom_settings.get("intellicode_xmx", "1G") + intellicode_xms = self._custom_settings.get("intellicode_xms", "100m") + + # Lombok-generated symbols (getX/setX/builder()/equals/hashCode/toString/...): JDTLS filters + # these out of documentSymbol results by default. Without them, find_symbol/get_symbols_overview + # return only user-written sources, which breaks navigation around @Data/@Builder/@Getter/@Setter + # classes. Default to True so agentic workflows can find_symbol -> replace_symbol_body on generated + # members. Requires JDTLS commit b2d8952 or newer (vscode-java >= 1.53.0, or upstream JDTLS + # snapshot after 2026-02-24); older servers ignore the unknown preference key silently. + # See eclipse-jdtls/eclipse.jdt.ls#3706. + lombok_show_generated = self._custom_settings.get("lombok_show_generated", True) + + # Gradle wrapper: default to False to preserve existing behaviour + gradle_wrapper_enabled = self._custom_settings.get("gradle_wrapper_enabled", False) + log.info( + f"Gradle wrapper {'enabled' if gradle_wrapper_enabled else 'disabled'} (configurable via ls_specific_settings -> java -> gradle_wrapper_enabled)" + ) + + # Gradle Java home: explicit setting, system JAVA_HOME when requested, then bundled runtime. + gradle_java_home = self._resolve_gradle_java_home() + + initialize_params = { + "locale": "en", + "capabilities": { + "workspace": { + "applyEdit": True, + "workspaceEdit": { + "documentChanges": True, + "resourceOperations": ["create", "rename", "delete"], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": True, + "changeAnnotationSupport": {"groupsOnLabel": True}, + }, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "tagSupport": {"valueSet": [1]}, + "resolveSupport": {"properties": ["location.range"]}, + }, + "codeLens": {"refreshSupport": True}, + "executeCommand": {"dynamicRegistration": True}, + "configuration": True, + "workspaceFolders": True, + "semanticTokens": {"refreshSupport": True}, + "fileOperations": { + "dynamicRegistration": True, + "didCreate": True, + "didRename": True, + "didDelete": True, + "willCreate": True, + "willRename": True, + "willDelete": True, + }, + "inlineValue": {"refreshSupport": True}, + "inlayHint": {"refreshSupport": True}, + "diagnostics": {"refreshSupport": True}, + }, + "textDocument": { + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [1, 2]}, + "codeDescriptionSupport": True, + "dataSupport": True, + }, + "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, + # TODO: we have an assert that completion provider is not included in the capabilities at server startup + # Removing this will cause the assert to fail. Investigate why this is the case, simplify config + "completion": { + "dynamicRegistration": True, + "contextSupport": True, + "completionItem": { + "snippetSupport": False, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + "tagSupport": {"valueSet": [1]}, + "insertReplaceSupport": False, + "resolveSupport": {"properties": ["documentation", "detail", "additionalTextEdits"]}, + "insertTextModeSupport": {"valueSet": [1, 2]}, + "labelDetailsSupport": True, + }, + "insertTextMode": 2, + "completionItemKind": { + "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25] + }, + "completionList": {"itemDefaults": ["commitCharacters", "editRange", "insertTextFormat", "insertTextMode"]}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + "activeParameterSupport": True, + }, + }, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "hierarchicalDocumentSymbolSupport": True, + "tagSupport": {"valueSet": [1]}, + "labelSupport": True, + }, + "rename": { + "dynamicRegistration": True, + "prepareSupport": True, + "prepareSupportDefaultBehavior": 1, + "honorsChangeAnnotations": True, + }, + "documentLink": {"dynamicRegistration": True, "tooltipSupport": True}, + "typeDefinition": {"dynamicRegistration": True, "linkSupport": True}, + "implementation": {"dynamicRegistration": True, "linkSupport": True}, + "colorProvider": {"dynamicRegistration": True}, + "declaration": {"dynamicRegistration": True, "linkSupport": True}, + "selectionRange": {"dynamicRegistration": True}, + "callHierarchy": {"dynamicRegistration": True}, + "semanticTokens": { + "dynamicRegistration": True, + "tokenTypes": [ + "namespace", + "type", + "class", + "enum", + "interface", + "struct", + "typeParameter", + "parameter", + "variable", + "property", + "enumMember", + "event", + "function", + "method", + "macro", + "keyword", + "modifier", + "comment", + "string", + "number", + "regexp", + "operator", + "decorator", + ], + "tokenModifiers": [ + "declaration", + "definition", + "readonly", + "static", + "deprecated", + "abstract", + "async", + "modification", + "documentation", + "defaultLibrary", + ], + "formats": ["relative"], + "requests": {"range": True, "full": {"delta": True}}, + "multilineTokenSupport": False, + "overlappingTokenSupport": False, + "serverCancelSupport": True, + "augmentsSyntaxTokens": True, + }, + "typeHierarchy": {"dynamicRegistration": True}, + "inlineValue": {"dynamicRegistration": True}, + "diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False}, + }, + "general": { + "staleRequestSupport": { + "cancel": True, + "retryOnContentModified": [ + "textDocument/semanticTokens/full", + "textDocument/semanticTokens/range", + "textDocument/semanticTokens/full/delta", + ], + }, + "regularExpressions": {"engine": "ECMAScript", "version": "ES2020"}, + "positionEncodings": ["utf-16"], + }, + "notebookDocument": {"synchronization": {"dynamicRegistration": True, "executionSummarySupport": True}}, + }, + "initializationOptions": { + "bundles": ["intellicode-core.jar"], + "settings": { + "java": { + "home": None, + "jdt": { + "ls": { + "java": {"home": None}, + "vmargs": f"-XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Dsun.zip.disableMemoryMapping=true -Xmx{intellicode_xmx} -Xms{intellicode_xms} -Xlog:disable", + "lombokSupport": {"enabled": True}, + "protobufSupport": {"enabled": True}, + "androidSupport": {"enabled": True}, + } + }, + "errors": {"incompleteClasspath": {"severity": "error"}}, + "configuration": { + "checkProjectSettingsExclusions": False, + "updateBuildConfiguration": "interactive", + "maven": { + "userSettings": maven_settings_path, + "globalSettings": None, + "notCoveredPluginExecutionSeverity": "warning", + "defaultMojoExecutionAction": "ignore", + }, + "workspaceCacheLimit": 90, + # Placeholder; the real `runtimes` array is overwritten below from + # self.runtime_dependency_paths.jre_home_path (works for both default + # VSIX mode and upstream-jdtls mode, which uses the system JDK). + "runtimes": [], + }, + "trace": {"server": "verbose"}, + "import": { + "maven": { + "enabled": True, + "offline": {"enabled": False}, + "disableTestClasspathFlag": False, + }, + "gradle": { + "enabled": True, + "wrapper": {"enabled": gradle_wrapper_enabled}, + "version": None, + "home": "abs(static/gradle-7.3.3)", + "offline": {"enabled": False}, + "arguments": None, + "jvmArguments": None, + "user": {"home": gradle_user_home}, + "annotationProcessing": {"enabled": True}, + }, + "exclusions": [ + "**/node_modules/**", + "**/.metadata/**", + "**/archetype-resources/**", + "**/META-INF/maven/**", + ], + "generatesMetadataFilesAtProjectRoot": False, + }, + # Set updateSnapshots to False to improve performance and avoid unnecessary network calls + # Snapshots will only be updated when explicitly requested by the user + "maven": {"downloadSources": True, "updateSnapshots": False}, + "eclipse": {"downloadSources": True}, + "signatureHelp": {"enabled": True, "description": {"enabled": True}}, + "hover": {"javadoc": {"enabled": True}}, + "implementationsCodeLens": {"enabled": True}, + "format": { + "enabled": True, + "settings": {"url": None, "profile": None}, + "comments": {"enabled": True}, + "onType": {"enabled": True}, + "insertSpaces": True, + "tabSize": 4, + }, + "saveActions": {"organizeImports": False}, + "project": { + "referencedLibraries": ["lib/**/*.jar"], + "importOnFirstTimeStartup": "automatic", + "importHint": True, + "resourceFilters": ["node_modules", "\\.git"], + "encoding": "ignore", + "exportJar": {"targetPath": "${workspaceFolder}/${workspaceFolderBasename}.jar"}, + }, + "contentProvider": {"preferred": None}, + "autobuild": {"enabled": True}, + "maxConcurrentBuilds": 1, + "selectionRange": {"enabled": True}, + "showBuildStatusOnStart": {"enabled": "notification"}, + "server": {"launchMode": "Standard"}, + "sources": {"organizeImports": {"starThreshold": 99, "staticStarThreshold": 99}}, + "imports": {"gradle": {"wrapper": {"checksums": []}}}, + "templates": {"fileHeader": [], "typeComment": []}, + "references": {"includeAccessors": True, "includeDecompiledSources": True}, + "typeHierarchy": {"lazyLoad": False}, + "settings": {"url": None}, + "symbols": { + "includeSourceMethodDeclarations": False, + # Surface Lombok-generated methods in document symbols (#1432). + "includeGeneratedCode": lombok_show_generated, + }, + "inlayHints": {"parameterNames": {"enabled": "literals", "exclusions": []}}, + "codeAction": {"sortMembers": {"avoidVolatileChanges": True}}, + "compile": { + "nullAnalysis": { + "nonnull": [ + "javax.annotation.Nonnull", + "org.eclipse.jdt.annotation.NonNull", + "org.springframework.lang.NonNull", + ], + "nullable": [ + "javax.annotation.Nullable", + "org.eclipse.jdt.annotation.Nullable", + "org.springframework.lang.Nullable", + ], + "mode": "automatic", + } + }, + "sharedIndexes": {"enabled": "auto", "location": ""}, + "silentNotification": False, + "dependency": { + "showMembers": False, + "syncWithFolderExplorer": True, + "autoRefresh": True, + "refreshDelay": 2000, + "packagePresentation": "flat", + }, + "help": {"firstView": "auto", "showReleaseNotes": True, "collectErrorLog": False}, + "test": {"defaultConfig": "", "config": {}}, + } + }, + }, + "trace": "verbose", + } + + initialize_params["initializationOptions"]["workspaceFolders"] = [repo_uri] + + # IntelliCode bundle: only attached in default vscode-java VSIX mode. + # In upstream-jdtls mode (jdtls_path set) we don't ship IntelliCode — agentic Serena workflows + # don't use completion ranking, so the bundle would be inert dead weight. + if self.runtime_dependency_paths.intellicode_jar_path is not None: + initialize_params["initializationOptions"]["bundles"] = [self.runtime_dependency_paths.intellicode_jar_path] + else: + initialize_params["initializationOptions"]["bundles"] = [] + + initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"] = [ + {"name": "JavaSE-21", "path": self.runtime_dependency_paths.jre_home_path, "default": True} + ] + + for runtime in initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"]: + assert "name" in runtime + assert "path" in runtime + assert os.path.exists(runtime["path"]), f"Runtime required for eclipse_jdtls at path {runtime['path']} does not exist" + + gradle_settings = initialize_params["initializationOptions"]["settings"]["java"]["import"]["gradle"] + # In upstream-jdtls mode we don't ship a Gradle distribution — Buildship will use the project's + # ./gradlew wrapper or a system-installed Gradle via its standard discovery rules. + if self.runtime_dependency_paths.gradle_path is not None: + gradle_settings["home"] = self.runtime_dependency_paths.gradle_path + gradle_settings["java"] = {"home": gradle_java_home} + return initialize_params + + def _start_server(self) -> None: + """ + Starts the Eclipse JDTLS Language Server + """ + + def register_capability_handler(params: dict) -> None: + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "textDocument/completion": + assert registration["registerOptions"]["resolveProvider"] == True + assert registration["registerOptions"]["triggerCharacters"] == [ + ".", + "@", + "#", + "*", + " ", + ] + if registration["method"] == "workspace/executeCommand": + if "java.intellicode.enable" in registration["registerOptions"]["commands"]: + self._intellicode_enable_command_available.set() + return + + def lang_status_handler(params: dict) -> None: + log.info("Language status update: %s", params) + if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": + self._service_ready_event.set() + if params["type"] == "ProjectStatus": + if params["message"] == "OK": + self._project_ready_event.set() + + def execute_client_command_handler(params: dict) -> list: + assert params["command"] == "_java.reloadBundles.command" + assert params["arguments"] == [] + return [] + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("language/status", lang_status_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + + log.info("Starting EclipseJDTLS server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 # type: ignore + assert "completionProvider" not in init_response["capabilities"] + assert "executeCommandProvider" not in init_response["capabilities"] + + self.server.notify.initialized({}) + + self.server.notify.workspace_did_change_configuration({"settings": initialize_params["initializationOptions"]["settings"]}) # type: ignore + + # IntelliCode enablement is only relevant in the default vscode-java VSIX mode where the + # IntelliCode bundle is shipped. In upstream-jdtls mode it's absent and the + # 'java.intellicode.enable' command will never be registered, so we skip the wait/call. + if self.runtime_dependency_paths.intellicode_jar_path is not None: + self._intellicode_enable_command_available.wait() + + java_intellisense_members_path = self.runtime_dependency_paths.intellisense_members_path + assert java_intellisense_members_path is not None + assert os.path.exists(java_intellisense_members_path) + intellicode_enable_result = self.server.send.execute_command( + { + "command": "java.intellicode.enable", + "arguments": [True, java_intellisense_members_path], + } + ) + assert intellicode_enable_result + + if not self._service_ready_event.is_set(): + log.info("Waiting for service to be ready ...") + self._service_ready_event.wait() + log.info("Service is ready") + + if not self._project_ready_event.is_set(): + log.info("Waiting for project to be ready ...") + project_ready_timeout = 20 # Hotfix: Using timeout until we figure out why sometimes we don't get the project ready event + if self._project_ready_event.wait(timeout=project_ready_timeout): + log.info("Project is ready") + else: + log.warning("Did not receive project ready status within %d seconds; proceeding anyway", project_ready_timeout) + else: + log.info("Project is ready") + + log.info("Startup complete") + + @override + def _request_hover(self, file_buffer: LSPFileBuffer, line: int, column: int) -> ls_types.Hover | None: + # Eclipse JDTLS lazily loads javadocs on first hover request, then caches them. + # This means the first request often returns incomplete info (just the signature), + # while subsequent requests return the full javadoc. + # + # The response format also differs based on javadoc presence: + # - contents: list[...] when javadoc IS present (preferred, richer format) + # - contents: {value: info} when javadoc is NOT present + # + # There's no LSP signal for "javadoc fully loaded" and no way to request + # hover with "wait for complete info". The retry approach is the only viable + # workaround - we keep requesting until we get the richer list format or + # the content stops growing. + # + # The file is kept open by the caller (request_hover), so retries are cheap + # and don't cause repeated didOpen/didClose cycles. + + def content_score(result: ls_types.Hover | None) -> tuple[int, int]: + """Return (format_priority, length) for comparison. Higher is better.""" + if result is None: + return (0, 0) + contents = result["contents"] + if isinstance(contents, list): + return (2, len(contents)) # List format (has javadoc) is best + elif isinstance(contents, dict): + return (1, len(contents.get("value", ""))) + else: + return (1, len(contents)) + + max_retries = 5 + best_result = super()._request_hover(file_buffer, line, column) + best_score = content_score(best_result) + + for _ in range(max_retries): + sleep(0.05) + new_result = super()._request_hover(file_buffer, line, column) + new_score = content_score(new_result) + if new_score > best_score: + best_result = new_result + best_score = new_score + + return best_result + + def _request_document_symbols( + self, relative_file_path: str, file_data: LSPFileBuffer | None + ) -> list[SymbolInformation] | list[DocumentSymbol] | None: + result = super()._request_document_symbols(relative_file_path, file_data=file_data) + if result is None: + return None + + # JDTLS sometimes returns symbol names with type information to handle overloads, + # e.g. "myMethod(int) ", but we want overloads to be handled via overload_idx, + # which requires the name to be just "myMethod". + + def fix_name(symbol: SymbolInformation | DocumentSymbol | UnifiedSymbolInformation) -> None: + if "(" in symbol["name"]: + symbol["name"] = symbol["name"][: symbol["name"].index("(")] + children = symbol.get("children") + if children: + for child in children: + fix_name(child) + + for root_symbol in result: + fix_name(root_symbol) + + return result diff --git a/src/solidlsp/language_servers/elixir_tools/README.md b/src/solidlsp/language_servers/elixir_tools/README.md new file mode 100644 index 0000000..56f2e03 --- /dev/null +++ b/src/solidlsp/language_servers/elixir_tools/README.md @@ -0,0 +1,87 @@ +# Elixir Language Server Integration + +This directory contains the integration for Elixir language support using [Expert](https://github.com/elixir-lang/expert), the official Elixir language server. + +## Prerequisites + +Before using the Elixir language server integration, you need to have: + +1. **Elixir** installed and available in your PATH + - Install from: https://elixir-lang.org/install.html + - Verify with: `elixir --version` + +2. **Expert** (optional - will be downloaded automatically if not found) + - Expert binaries are automatically downloaded from GitHub releases + - Manual installation: https://github.com/elixir-lang/expert#installation + - If installed manually, ensure `expert` is in your PATH + +## Features + +The Elixir integration provides: + +- **Language Server Protocol (LSP) support** via Next LS +- **File extension recognition** for `.ex` and `.exs` files +- **Project structure awareness** with proper handling of Elixir-specific directories: + - `_build/` - Compiled artifacts (ignored) + - `deps/` - Dependencies (ignored) + - `.elixir_ls/` - ElixirLS artifacts (ignored) + - `cover/` - Coverage reports (ignored) + - `lib/` - Source code (not ignored) + - `test/` - Test files (not ignored) + +## Configuration + +The integration uses the default Expert configuration with: + +- **MIX_ENV**: `dev` +- **MIX_TARGET**: `host` +- **Experimental completions**: Disabled by default +- **Credo extension**: Enabled by default + +### Version Management (asdf) + +Expert automatically respects project-specific Elixir versions when using asdf: +- If a `.tool-versions` file exists in the project root, Expert will use the specified Elixir version +- Expert is launched from the project directory, allowing it to pick up project configuration +- No additional configuration needed - just ensure asdf is installed and the project has a `.tool-versions` file + +## Usage + +The Elixir language server is automatically selected when working with Elixir projects. It will be used for: + +- Code completion +- Go to definition +- Find references +- Document symbols +- Hover information +- Code formatting +- Diagnostics (via Credo integration) + +### Important: Project Compilation + +Expert requires your Elixir project to be **compiled** for optimal performance, especially for: +- Cross-file reference resolution +- Complete symbol information +- Accurate go-to-definition + +**For production use**: Ensure your project is compiled with `mix compile` before using the language server. + +**For testing**: The test suite automatically compiles the test repositories before running tests to ensure optimal Expert performance. + +## Testing + +Run the Elixir-specific tests with: + +```bash +pytest test/solidlsp/elixir/ -m elixir +``` + +## Implementation Details + +- **Main class**: `ElixirTools` in `elixir_tools.py` +- **Language identifier**: `"elixir"` +- **Command**: `expert --stdio` +- **Supported platforms**: Linux (x64, arm64), macOS (x64, arm64), Windows (x64, arm64) +- **Binary distribution**: Downloaded from [GitHub releases](https://github.com/elixir-lang/expert/releases) + +The implementation follows the same patterns as other language servers in this project, inheriting from `SolidLanguageServer` and providing Elixir-specific configuration and behavior. diff --git a/src/solidlsp/language_servers/elixir_tools/__init__.py b/src/solidlsp/language_servers/elixir_tools/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/solidlsp/language_servers/elixir_tools/__init__.py @@ -0,0 +1 @@ + diff --git a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py new file mode 100644 index 0000000..d80e43e --- /dev/null +++ b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py @@ -0,0 +1,421 @@ +import logging +import os +import pathlib +import stat +import subprocess +import threading +from collections.abc import Hashable +from typing import Any + +from overrides import override + +from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_utils import FileUtils, PlatformId, PlatformUtils +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +from ..common import RuntimeDependency + +log = logging.getLogger(__name__) + +EXPERT_VERSION = "v0.1.0-rc.6" +EXPERT_ALLOWED_HOSTS = ( + "github.com", + "release-assets.githubusercontent.com", + "objects.githubusercontent.com", +) + + +class ElixirTools(SolidLanguageServer): + """ + Provides Elixir specific instantiation of the LanguageServer class using Expert, the official Elixir language server. + + You can pass the following entries in ``ls_specific_settings["elixir"]``: + - expert_version: Override the pinned Expert version downloaded by Serena + (default: the bundled Serena version). + """ + + @override + def _get_wait_time_for_cross_file_referencing(self) -> float: + return 10.0 # Elixir projects need time to compile and index before cross-file references work + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # For Elixir projects, we should ignore: + # - _build: compiled artifacts + # - deps: dependencies + # - node_modules: if the project has JavaScript components + # - .elixir_ls: ElixirLS artifacts (in case both are present) + # - cover: coverage reports + # - .expert: Expert artifacts + return super().is_ignored_dirname(dirname) or dirname in ["_build", "deps", "node_modules", ".elixir_ls", ".expert", "cover"] + + @override + def is_ignored_path(self, relative_path: str, ignore_unsupported_files: bool = True) -> bool: + """Check if a path should be ignored for symbol indexing.""" + if relative_path.endswith("mix.exs"): + # These are project configuration files, not source code with symbols to index + return True + + return super().is_ignored_path(relative_path, ignore_unsupported_files) + + @classmethod + def _get_elixir_version(cls) -> str | None: + """Get the installed Elixir version or None if not found.""" + try: + result = subprocess.run(["elixir", "--version"], capture_output=True, text=True, check=False) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + return None + return None + + @classmethod + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str: + """ + Setup runtime dependencies for Expert. + Downloads the Expert binary for the current platform and returns the path to the executable. + """ + elixir_settings = solidlsp_settings.get_ls_specific_settings(Language.ELIXIR) + expert_version = elixir_settings.get("expert_version", EXPERT_VERSION) + # Check if Elixir is available first + elixir_version = cls._get_elixir_version() + if not elixir_version: + raise RuntimeError( + "Elixir is not installed. Please install Elixir from https://elixir-lang.org/install.html and make sure it is added to your PATH." + ) + + log.info(f"Found Elixir: {elixir_version}") + + # First, check if expert is already in PATH (user may have installed it manually) + import shutil + + expert_in_path = shutil.which("expert") + if expert_in_path: + log.info(f"Found Expert in PATH: {expert_in_path}") + return expert_in_path + + platform_id = PlatformUtils.get_platform_id() + + valid_platforms = [ + PlatformId.LINUX_x64, + PlatformId.LINUX_arm64, + PlatformId.OSX_x64, + PlatformId.OSX_arm64, + PlatformId.WIN_x64, + ] + assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for Expert at the moment" + + expert_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "expert") + + # Define runtime dependencies inline + runtime_deps = { + PlatformId.LINUX_x64: RuntimeDependency( + id="expert_linux_amd64", + platform_id="linux-x64", + url=f"https://github.com/elixir-lang/expert/releases/download/{expert_version}/expert_linux_amd64", + archive_type="binary", + binary_name="expert_linux_amd64", + extract_path="expert", + sha256="643a492ff972246668b0ca356a84c3d0a0f5feeae0ab5dc1b9a126876ed460e4" if expert_version == EXPERT_VERSION else None, + allowed_hosts=EXPERT_ALLOWED_HOSTS, + ), + PlatformId.LINUX_arm64: RuntimeDependency( + id="expert_linux_arm64", + platform_id="linux-arm64", + url=f"https://github.com/elixir-lang/expert/releases/download/{expert_version}/expert_linux_arm64", + archive_type="binary", + binary_name="expert_linux_arm64", + extract_path="expert", + sha256="d8b830bdaa8991d7ebf255dacbb3674f3ea335c87d0bfba4b7f907ded4a8f014" if expert_version == EXPERT_VERSION else None, + allowed_hosts=EXPERT_ALLOWED_HOSTS, + ), + PlatformId.OSX_x64: RuntimeDependency( + id="expert_darwin_amd64", + platform_id="osx-x64", + url=f"https://github.com/elixir-lang/expert/releases/download/{expert_version}/expert_darwin_amd64", + archive_type="binary", + binary_name="expert_darwin_amd64", + extract_path="expert", + sha256="964f316f1633090b33aab392b6b85fb778c5fb3c0db862671424458da34b1d4d" if expert_version == EXPERT_VERSION else None, + allowed_hosts=EXPERT_ALLOWED_HOSTS, + ), + PlatformId.OSX_arm64: RuntimeDependency( + id="expert_darwin_arm64", + platform_id="osx-arm64", + url=f"https://github.com/elixir-lang/expert/releases/download/{expert_version}/expert_darwin_arm64", + archive_type="binary", + binary_name="expert_darwin_arm64", + extract_path="expert", + sha256="5fb5be151baedd635d99835cf3f9986afc9af6ae7b07bd001a1962f4298e45da" if expert_version == EXPERT_VERSION else None, + allowed_hosts=EXPERT_ALLOWED_HOSTS, + ), + PlatformId.WIN_x64: RuntimeDependency( + id="expert_windows_amd64", + platform_id="win-x64", + url=f"https://github.com/elixir-lang/expert/releases/download/{expert_version}/expert_windows_amd64.exe", + archive_type="binary", + binary_name="expert_windows_amd64.exe", + extract_path="expert.exe", + sha256="babee77d2653679021600b99c68d984d4463290cb221e0fc0d1093b3afdeb3b0" if expert_version == EXPERT_VERSION else None, + allowed_hosts=EXPERT_ALLOWED_HOSTS, + ), + } + + dependency = runtime_deps[platform_id] + # On Windows, use .exe extension + executable_name = "expert.exe" if platform_id.value.startswith("win") else "expert" + executable_path = os.path.join(expert_dir, executable_name) + assert dependency.binary_name is not None + binary_path = os.path.join(expert_dir, dependency.binary_name) + + if not os.path.exists(executable_path): + log.info(f"Downloading Expert binary from {dependency.url}") + assert dependency.url is not None + FileUtils.download_file_verified( + dependency.url, + binary_path, + expected_sha256=dependency.sha256, + allowed_hosts=dependency.allowed_hosts, + ) + + # Make the binary executable on Unix-like systems + if not platform_id.value.startswith("win"): + os.chmod(binary_path, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) + + # Create a symlink with the expected name on Unix-like systems + if binary_path != executable_path and not platform_id.value.startswith("win"): + if os.path.exists(executable_path): + os.remove(executable_path) + os.symlink(os.path.basename(binary_path), executable_path) + + # normalizing the executable name on Windows + if binary_path != executable_path and platform_id.value.startswith("win"): + shutil.copy2(binary_path, executable_path) + + assert os.path.exists(executable_path), f"Expert executable not found at {executable_path}" + + log.info(f"Expert binary ready at: {executable_path}") + return executable_path + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + expert_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) + + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=f"{expert_executable_path} --stdio", cwd=repository_root_path), + "elixir", + solidlsp_settings, + ) + self.server_ready = threading.Event() + self.request_id = 0 + self._building_project = False + + # Set generous timeout for Expert which can be slow to initialize and respond + self.set_request_timeout(180.0) + + @override + def _document_symbols_cache_fingerprint(self) -> Hashable: + normalize_symbol_name_version = 1 + return normalize_symbol_name_version + + @override + def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: + return symbol["name"].removeprefix("defp ").removeprefix("def ").split("(", 1)[0].strip() + + def _find_mix_exs(self) -> str | None: + """ + Find mix.exs in the repository. + + Checks {repository_root_path}/mix.exs first (standard layout), then scans + immediate subdirectories for mix.exs (monorepo layout, e.g. server/mix.exs). + Returns the absolute path to the first match found, or None if not found. + + Note: subdirectory iteration order depends on the filesystem (os.listdir). + In practice this is fine — monorepos typically have one Elixir app. + """ + # Check root first + root_mix_exs = os.path.join(self.repository_root_path, "mix.exs") + if os.path.exists(root_mix_exs): + return root_mix_exs + + # Search immediate subdirectories + try: + for entry in os.listdir(self.repository_root_path): + subdir_path = os.path.join(self.repository_root_path, entry) + if os.path.isdir(subdir_path): + subdir_mix_exs = os.path.join(subdir_path, "mix.exs") + if os.path.exists(subdir_mix_exs): + log.info(f"Found mix.exs in subdirectory: {entry}/mix.exs") + return subdir_mix_exs + except OSError as e: + log.warning(f"Error scanning for mix.exs in subdirectories: {e}") + + return None + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Expert Language Server. + """ + initialize_params = { + "locale": "en", + "initializationOptions": { + "mix_env": "dev", + "mix_target": "host", + "experimental": {"completions": {"enable": False}}, + "extensions": {"credo": {"enable": True, "cli_options": []}}, + }, + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": { + "dynamicRegistration": True, + "completionItem": {"snippetSupport": True, "documentationFormat": ["markdown", "plaintext"]}, + }, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "formatting": {"dynamicRegistration": True}, + "codeAction": { + "dynamicRegistration": True, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + ] + } + }, + }, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "executeCommand": {"dynamicRegistration": True}, + }, + "window": { + "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, + "showDocument": {"support": True}, + "workDoneProgress": True, + }, + }, + } + + return initialize_params + + def _start_server(self) -> None: + """Start Expert server process""" + + def register_capability_handler(params: Any) -> None: + log.debug(f"LSP: client/registerCapability: {params}") + return + + def window_log_message(msg: Any) -> None: + """Handle window/logMessage notifications from Expert""" + message_type = msg.get("type", 4) # 1=Error, 2=Warning, 3=Info, 4=Log + message_text = msg.get("message", "") + + # Log at appropriate level based on message type + if message_type == 1: + log.error(f"Expert: {message_text}") + elif message_type == 2: + log.warning(f"Expert: {message_text}") + else: + log.debug(f"Expert: {message_text}") + + def check_server_ready(params: Any) -> None: + """ + Handle $/progress notifications from Expert. + Expert sends progress updates during compilation and indexing. + The server is considered ready when project build completes. + """ + value = params.get("value", {}) + kind = value.get("kind", "") + title = value.get("title", "") + + if kind == "begin": + # Track when building the project starts (not "Building engine") + if title.startswith("Building ") and not title.startswith("Building engine"): + self._building_project = True + elif kind == "end": + # Project build completion is the main readiness signal + if getattr(self, "_building_project", False): + log.debug("Expert project build completed - server is ready") + self._building_project = False + self.server_ready.set() + + def work_done_progress_create(params: Any) -> None: + """Handle window/workDoneProgress/create requests from Expert.""" + return + + def publish_diagnostics(params: Any) -> None: + """Handle textDocument/publishDiagnostics notifications.""" + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", check_server_ready) + self.server.on_request("window/workDoneProgress/create", work_done_progress_create) + self.server.on_notification("textDocument/publishDiagnostics", publish_diagnostics) + + log.debug("Starting Expert server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.debug("Sending initialize request to Expert") + init_response = self.server.send.initialize(initialize_params) + + # Verify basic server capabilities + assert "textDocumentSync" in init_response["capabilities"], f"Missing textDocumentSync in {init_response['capabilities']}" + + self.server.notify.initialized({}) + + # Expert's build pipeline is triggered by a textDocument/didOpen notification. + # Without it Expert sits idle and never emits the $/progress signals we wait for, + # causing a deadlock. Opening mix.exs is the minimal trigger; we close it again + # once the server is ready so it does not linger in the open-file set. + mix_exs_path = self._find_mix_exs() + mix_exs_uri: str | None = None + + if mix_exs_path is not None: + mix_exs_uri = pathlib.Path(mix_exs_path).as_uri() + with open(mix_exs_path, encoding="utf-8") as f: + mix_exs_content = f.read() + self.server.notify.did_open_text_document( + { + "textDocument": { + "uri": mix_exs_uri, + "languageId": "elixir", + "version": 1, + "text": mix_exs_content, + } + } + ) + log.debug("Opened mix.exs to trigger Expert's build pipeline") + + # Expert needs time to compile the project and build indexes on first run. + # This can take 2-3+ minutes for mid-sized codebases. + # After the first run, subsequent startups are much faster. + ready_timeout = 300.0 # 5 minutes + log.debug(f"Waiting up to {ready_timeout}s for Expert to compile and index...") + if self.server_ready.wait(timeout=ready_timeout): + log.debug("Expert is ready for requests") + else: + log.warning(f"Expert did not signal readiness within {ready_timeout}s. Proceeding with requests anyway.") + self.server_ready.set() # Mark as ready anyway to allow requests + + if mix_exs_uri is not None: + self.server.notify.did_close_text_document({"textDocument": {"uri": mix_exs_uri}}) + log.debug("Closed mix.exs after Expert is ready") diff --git a/src/solidlsp/language_servers/elm_language_server.py b/src/solidlsp/language_servers/elm_language_server.py new file mode 100644 index 0000000..bd48850 --- /dev/null +++ b/src/solidlsp/language_servers/elm_language_server.py @@ -0,0 +1,241 @@ +""" +Provides Elm specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Elm. +""" + +import logging +import os +import shutil +import threading + +from overrides import override +from sensai.util.logging import LogTime + +from solidlsp import ls_types +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +from .common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command + +log = logging.getLogger(__name__) + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_ELM_LANGUAGE_SERVER_VERSION = "2.8.0" +DEFAULT_ELM_LANGUAGE_SERVER_VERSION = "2.8.0" +INITIAL_ELM_COMPILER_VERSION = "0.19.1-6" +DEFAULT_ELM_COMPILER_VERSION = "0.19.1-6" + + +class ElmLanguageServer(SolidLanguageServer): + """ + Provides Elm specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Elm. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates an ElmLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + elm_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) + managed_bin_dir = os.path.join(self.ls_resources_dir(solidlsp_settings), "elm-lsp", "node_modules", ".bin") + + # Resolve ELM_HOME to absolute path if it's set to a relative path + env = {"PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", "")} + elm_home = os.environ.get("ELM_HOME") + if elm_home: + if not os.path.isabs(elm_home): + # Convert relative ELM_HOME to absolute based on repository root + elm_home = os.path.abspath(os.path.join(repository_root_path, elm_home)) + env["ELM_HOME"] = elm_home + log.info(f"Using ELM_HOME: {elm_home}") + + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=elm_lsp_executable_path, cwd=repository_root_path, env=env), + "elm", + solidlsp_settings, + ) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [ + "elm-stuff", + "node_modules", + "dist", + "build", + ] + + @classmethod + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> list[str]: + """ + Setup runtime dependencies for Elm Language Server and return the command to start the server. + """ + elm_config = solidlsp_settings.get_ls_specific_settings(Language.ELM) + elm_language_server_version = elm_config.get("elm_language_server_version", DEFAULT_ELM_LANGUAGE_SERVER_VERSION) + elm_compiler_version = elm_config.get("elm_compiler_version", DEFAULT_ELM_COMPILER_VERSION) + npm_registry = elm_config.get("npm_registry") + + # Check if elm-language-server and elm are already installed globally + system_elm_ls = shutil.which("elm-language-server") + system_elm = shutil.which("elm") + if system_elm_ls and system_elm: + log.info(f"Found system-installed elm-language-server at {system_elm_ls}") + return [system_elm_ls, "--stdio"] + + # Verify node and npm are installed + is_node_installed = shutil.which("node") is not None + assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." + is_npm_installed = shutil.which("npm") is not None + assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="elm-language-server", + description="@elm-tooling/elm-language-server package", + command=build_npm_install_command("@elm-tooling/elm-language-server", elm_language_server_version, npm_registry), + platform_id="any", + ), + RuntimeDependency( + id="elm", + description="Elm compiler package", + command=build_npm_install_command("elm", elm_compiler_version, npm_registry), + platform_id="any", + ), + ] + ) + + # legacy unversioned dir reserved for INITIAL pair; any other version combination goes into a versioned subdir + is_initial = ( + elm_language_server_version == INITIAL_ELM_LANGUAGE_SERVER_VERSION and elm_compiler_version == INITIAL_ELM_COMPILER_VERSION + ) + ls_dirname = "elm-lsp" if is_initial else f"elm-lsp-{elm_language_server_version}-{elm_compiler_version}" + elm_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), ls_dirname) + elm_ls_executable_path = os.path.join(elm_ls_dir, "node_modules", ".bin", "elm-language-server") + if not os.path.exists(elm_ls_executable_path): + log.info(f"Elm Language Server executable not found at {elm_ls_executable_path}. Installing...") + with LogTime("Installation of Elm language server dependencies", logger=log): + deps.install(elm_ls_dir) + elif not system_elm: + log.info("Elm compiler not found on PATH. Installing a managed Elm compiler...") + with LogTime("Installation of Elm compiler dependency", logger=log): + deps.install(elm_ls_dir) + + if not os.path.exists(elm_ls_executable_path): + raise FileNotFoundError( + f"elm-language-server executable not found at {elm_ls_executable_path}, something went wrong with the installation." + ) + return [elm_ls_executable_path, "--stdio"] + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Elm Language Server. + """ + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "codeAction": {"dynamicRegistration": True}, + "rename": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + "initializationOptions": { + "elmPath": shutil.which("elm") or "elm", + "elmFormatPath": shutil.which("elm-format") or "elm-format", + "elmTestPath": shutil.which("elm-test") or "elm-test", + "skipInstallPackageConfirmation": True, + "onlyUpdateDiagnosticsOnSave": False, + }, + } + return initialize_params + + def _start_server(self) -> None: + """ + Starts the Elm Language Server, waits for the server to be ready and yields the LanguageServer instance. + """ + workspace_ready = threading.Event() + + def do_nothing(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def on_diagnostics(params: dict) -> None: + # Receiving diagnostics indicates the workspace has been scanned + log.info("LSP: Received diagnostics notification, workspace is ready") + workspace_ready.set() + + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", on_diagnostics) + + log.info("Starting Elm server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + # Elm-specific capability checks + assert "textDocumentSync" in init_response["capabilities"] + assert "completionProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + assert "referencesProvider" in init_response["capabilities"] + assert "documentSymbolProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + log.info("Elm server initialized, waiting for workspace scan...") + + # Wait for workspace to be scanned (indicated by receiving diagnostics) + if workspace_ready.wait(timeout=30.0): + log.info("Elm server workspace scan completed") + else: + log.warning("Timeout waiting for Elm workspace scan, proceeding anyway") + + log.info("Elm server ready") + + @override + def _get_wait_time_for_cross_file_referencing(self) -> float: + return 1.0 + + @override + def request_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + + with self.open_file(relative_file_path): + diagnostics = self._wait_for_relevant_published_diagnostics( + uri=uri, + after_generation=-1, + timeout=10.0, + allow_cached=True, + ) + + if diagnostics is None: + return [] + + return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) diff --git a/src/solidlsp/language_servers/erlang_language_server.py b/src/solidlsp/language_servers/erlang_language_server.py new file mode 100644 index 0000000..ebf9008 --- /dev/null +++ b/src/solidlsp/language_servers/erlang_language_server.py @@ -0,0 +1,222 @@ +"""Erlang Language Server implementation using Erlang LS.""" + +import logging +import os +import pathlib +import shutil +import subprocess +import threading +import time + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class ErlangLanguageServer(SolidLanguageServer): + """Language server for Erlang using Erlang LS.""" + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates an ErlangLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + self.erlang_ls_path = shutil.which("erlang_ls") + if not self.erlang_ls_path: + raise RuntimeError("Erlang LS not found. Install from: https://github.com/erlang-ls/erlang_ls") + + if not self._check_erlang_installation(): + raise RuntimeError("Erlang/OTP not found. Install from: https://www.erlang.org/downloads") + + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=[self.erlang_ls_path, "--transport", "stdio"], cwd=repository_root_path), + "erlang", + solidlsp_settings, + ) + + # Add server readiness tracking like Elixir + self.server_ready = threading.Event() + + # Set generous timeout for Erlang LS initialization + self.set_request_timeout(120.0) + + def _check_erlang_installation(self) -> bool: + """Check if Erlang/OTP is available.""" + try: + result = subprocess.run(["erl", "-version"], check=False, capture_output=True, text=True, timeout=10) + return result.returncode == 0 + except (subprocess.SubprocessError, FileNotFoundError): + return False + + @classmethod + def _get_erlang_version(cls) -> str | None: + """Get the installed Erlang/OTP version or None if not found.""" + try: + result = subprocess.run(["erl", "-version"], check=False, capture_output=True, text=True, timeout=10) + if result.returncode == 0: + return result.stderr.strip() # erl -version outputs to stderr + except (subprocess.SubprocessError, FileNotFoundError): + return None + return None + + @classmethod + def _check_rebar3_available(cls) -> bool: + """Check if rebar3 build tool is available.""" + try: + result = subprocess.run(["rebar3", "version"], check=False, capture_output=True, text=True, timeout=10) + return result.returncode == 0 + except (subprocess.SubprocessError, FileNotFoundError): + return False + + def _start_server(self) -> None: + """Start Erlang LS server process with proper initialization waiting.""" + + def register_capability_handler(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + """Handle window/logMessage notifications from Erlang LS""" + message_text = msg.get("message", "") + log.info(f"LSP: window/logMessage: {message_text}") + + # Look for Erlang LS readiness signals + # Common patterns: "Started Erlang LS", "initialized", "ready" + readiness_signals = [ + "Started Erlang LS", + "server started", + "initialized", + "ready to serve requests", + "compilation finished", + "indexing complete", + ] + + message_lower = message_text.lower() + for signal in readiness_signals: + if signal.lower() in message_lower: + log.info(f"Erlang LS readiness signal detected: {message_text}") + self.server_ready.set() + break + + def do_nothing(params: dict) -> None: + return + + def check_server_ready(params: dict) -> None: + """Handle $/progress notifications from Erlang LS as fallback.""" + value = params.get("value", {}) + + # Check for initialization completion progress + if value.get("kind") == "end": + message = value.get("message", "") + if any(word in message.lower() for word in ["initialized", "ready", "complete"]): + log.info("Erlang LS initialization progress completed") + # Set as fallback if no window/logMessage was received + if not self.server_ready.is_set(): + self.server_ready.set() + + # Set up notification handlers + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", check_server_ready) + self.server.on_notification("window/workDoneProgress/create", do_nothing) + self.server.on_notification("$/workDoneProgress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting Erlang LS server process") + self.server.start() + + # Send initialize request + initialize_params = { + "processId": None, + "rootPath": self.repository_root_path, + "rootUri": pathlib.Path(self.repository_root_path).as_uri(), + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True}, + "completion": {"dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": {"dynamicRegistration": True}, + "hover": {"dynamicRegistration": True}, + } + }, + } + + log.info("Sending initialize request to Erlang LS") + init_response = self.server.send.initialize(initialize_params) # type: ignore[arg-type] + + # Verify server capabilities + if "capabilities" in init_response: + log.info(f"Erlang LS capabilities: {list(init_response['capabilities'].keys())}") + + self.server.notify.initialized({}) + + # Wait for Erlang LS to be ready - adjust timeout based on environment + is_ci = os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true" + is_macos = os.uname().sysname == "Darwin" if hasattr(os, "uname") else False + + # macOS in CI can be particularly slow for language server startup + if is_ci and is_macos: + ready_timeout = 240.0 # 4 minutes for macOS CI + env_desc = "macOS CI" + elif is_ci: + ready_timeout = 180.0 # 3 minutes for other CI + env_desc = "CI" + else: + ready_timeout = 60.0 # 1 minute for local + env_desc = "local" + + log.info(f"Waiting up to {ready_timeout} seconds for Erlang LS readiness ({env_desc} environment)...") + + if self.server_ready.wait(timeout=ready_timeout): + log.info("Erlang LS is ready and available for requests") + + # Add settling period for indexing - adjust based on environment + settling_time = 15.0 if is_ci else 5.0 + log.info(f"Allowing {settling_time} seconds for Erlang LS indexing to complete...") + time.sleep(settling_time) + log.info("Erlang LS settling period complete") + else: + # Set ready anyway and continue - Erlang LS might not send explicit ready messages + log.warning(f"Erlang LS readiness timeout reached after {ready_timeout}s, proceeding anyway (common in CI)") + self.server_ready.set() + + # Still give some time for basic initialization even without explicit readiness signal + basic_settling_time = 20.0 if is_ci else 10.0 + log.info(f"Allowing {basic_settling_time} seconds for basic Erlang LS initialization...") + time.sleep(basic_settling_time) + log.info("Basic Erlang LS initialization period complete") + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # For Erlang projects, we should ignore: + # - _build: rebar3 build artifacts + # - deps: dependencies + # - ebin: compiled beam files + # - .rebar3: rebar3 cache + # - logs: log files + # - node_modules: if the project has JavaScript components + return super().is_ignored_dirname(dirname) or dirname in [ + "_build", + "deps", + "ebin", + ".rebar3", + "logs", + "node_modules", + "_checkouts", + "cover", + ] + + def is_ignored_filename(self, filename: str) -> bool: + """Check if a filename should be ignored.""" + # Ignore compiled BEAM files + if filename.endswith(".beam"): + return True + # Don't ignore Erlang source files, header files, or configuration files + return False diff --git a/src/solidlsp/language_servers/fortran_language_server.py b/src/solidlsp/language_servers/fortran_language_server.py new file mode 100644 index 0000000..134460a --- /dev/null +++ b/src/solidlsp/language_servers/fortran_language_server.py @@ -0,0 +1,304 @@ +""" +Fortran Language Server implementation using fortls. +""" + +import logging +import re + +from overrides import override + +from solidlsp import ls_types +from solidlsp.ls import ( + DocumentSymbols, + LanguageServerDependencyProvider, + LanguageServerDependencyProviderUvx, + LSPConstants, + LSPFileBuffer, + SolidLanguageServer, +) +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +FORTLS_VERSION = "3.2.2" + + +class FortranLanguageServer(SolidLanguageServer): + """Fortran Language Server implementation using fortls.""" + + @override + def _get_wait_time_for_cross_file_referencing(self) -> float: + return 3.0 # fortls needs time for workspace indexing + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # For Fortran projects, ignore common build directories + return super().is_ignored_dirname(dirname) or dirname in [ + "build", + "Build", + "BUILD", + "bin", + "lib", + "mod", # Module files directory + "obj", # Object files directory + ".cmake", + "CMakeFiles", + ] + + def _fix_fortls_selection_range( + self, symbol: ls_types.UnifiedSymbolInformation, file_content: str + ) -> ls_types.UnifiedSymbolInformation: + """ + Fix fortls's incorrect selectionRange that points to line start instead of identifier name. + + fortls bug: selectionRange.start.character is 0 (line start) but should point to the + function/subroutine/module/program name position. This breaks MCP server features that + rely on the exact identifier position for finding references. + + Args: + symbol: The symbol with potentially incorrect selectionRange + file_content: Full file content to parse the line + + Returns: + Symbol with corrected selectionRange pointing to the identifier name + + """ + if "selectionRange" not in symbol: + return symbol + + sel_range = symbol["selectionRange"] + start_line = sel_range["start"]["line"] + start_char = sel_range["start"]["character"] + + # Split file content into lines + lines = file_content.split("\n") + if start_line >= len(lines): + return symbol + + line = lines[start_line] + + # Fortran keywords that define named constructs + # Match patterns: + # Standard keywords: + # " function add_numbers(a, b) result(sum)" -> keyword="function", name="add_numbers" + # "subroutine print_result(value)" -> keyword="subroutine", name="print_result" + # "module math_utils" -> keyword="module", name="math_utils" + # "program test_program" -> keyword="program", name="test_program" + # "interface distance" -> keyword="interface", name="distance" + # + # Type definitions (can have :: syntax): + # "type point" -> keyword="type", name="point" + # "type :: point" -> keyword="type", name="point" + # "type, extends(base) :: derived" -> keyword="type", name="derived" + # + # Submodules (have parent module in parentheses): + # "submodule (parent_mod) child_mod" -> keyword="submodule", name="child_mod" + + # Try type pattern first (has complex syntax with optional comma and ::) + type_pattern = r"^\s*type\s*(?:,.*?)?\s*(?:::)?\s*([a-zA-Z_]\w*)" + match = re.match(type_pattern, line, re.IGNORECASE) + + if match: + # For type pattern, identifier is in group 1 + identifier_name = match.group(1) + identifier_start = match.start(1) + else: + # Try standard keywords pattern + standard_pattern = r"^\s*(function|subroutine|module|program|interface)\s+([a-zA-Z_]\w*)" + match = re.match(standard_pattern, line, re.IGNORECASE) + + if not match: + # Try submodule pattern + submodule_pattern = r"^\s*submodule\s*\([^)]+\)\s+([a-zA-Z_]\w*)" + match = re.match(submodule_pattern, line, re.IGNORECASE) + + if match: + identifier_name = match.group(1) + identifier_start = match.start(1) + else: + identifier_name = match.group(2) + identifier_start = match.start(2) + + if match: + # Create corrected selectionRange + new_sel_range = { + "start": {"line": start_line, "character": identifier_start}, + "end": {"line": start_line, "character": identifier_start + len(identifier_name)}, + } + + # Create modified symbol with corrected selectionRange + corrected_symbol = symbol.copy() + corrected_symbol["selectionRange"] = new_sel_range # type: ignore[typeddict-item] + + log.debug(f"Fixed fortls selectionRange for {identifier_name}: char {start_char} -> {identifier_start}") + + return corrected_symbol + + # If no match, return symbol unchanged (e.g., for variables, which don't have this pattern) + return symbol + + @override + def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: + # Override to fix fortls's incorrect selectionRange bug. + # + # fortls returns selectionRange pointing to line start (character 0) instead of the + # identifier name position. This breaks MCP server features that rely on exact positions. + # + # This override: + # 1. Gets symbols from fortls via parent implementation + # 2. Parses each symbol's line to find the correct identifier position + # 3. Fixes selectionRange for all symbols recursively + # 4. Returns corrected symbols + + # Get symbols from fortls (with incorrect selectionRange) + document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer) + + # Get file content for parsing + with self.open_file(relative_file_path) as file_data: + file_content = file_data.contents + + # Fix selectionRange recursively for all symbols + def fix_symbol_and_children(symbol: ls_types.UnifiedSymbolInformation) -> ls_types.UnifiedSymbolInformation: + # Fix this symbol's selectionRange + fixed = self._fix_fortls_selection_range(symbol, file_content) + + # Fix children recursively + if fixed.get("children"): + fixed["children"] = [fix_symbol_and_children(child) for child in fixed["children"]] + + return fixed + + # Apply fix to all symbols + fixed_root_symbols = [fix_symbol_and_children(sym) for sym in document_symbols.root_symbols] + + return DocumentSymbols(fixed_root_symbols) + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + super().__init__(config, repository_root_path, None, "fortran", solidlsp_settings) + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return LanguageServerDependencyProviderUvx( + self._custom_settings, + self._ls_resources_dir, + package="fortls", + entrypoint="fortls", + default_version=FORTLS_VERSION, + version_setting_key="fortls_version", + ) + + def _create_base_initialize_params(self) -> dict: + """Initialize params for Fortran Language Server.""" + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": { + "dynamicRegistration": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + }, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "formatting": {"dynamicRegistration": True}, + "rangeFormatting": {"dynamicRegistration": True}, + "codeAction": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """Start Fortran Language Server process.""" + + def window_log_message(msg: dict) -> None: + log.info(f"Fortran LSP: window/logMessage: {msg}") + + def do_nothing(params: dict) -> None: + return + + def register_capability_handler(params: dict) -> None: + return + + # Register LSP message handlers + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting Fortran Language Server (fortls) process") + self.server.start() + + initialize_params = self._create_initialize_params() + log.info("Sending initialize request to Fortran Language Server") + + init_response = self.server.send.initialize(initialize_params) + + # Verify server capabilities + capabilities = init_response.get("capabilities", {}) + assert "textDocumentSync" in capabilities + if "completionProvider" in capabilities: + log.info("Fortran LSP completion provider available") + if "definitionProvider" in capabilities: + log.info("Fortran LSP definition provider available") + if "referencesProvider" in capabilities: + log.info("Fortran LSP references provider available") + if "documentSymbolProvider" in capabilities: + log.info("Fortran LSP document symbol provider available") + + self.server.notify.initialized({}) + + # Fortran Language Server is ready after initialization + log.info("Fortran Language Server initialization complete") + + @override + def request_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + diagnostics_before_request = self._get_published_diagnostics_generation(uri) + + with self.open_file(relative_file_path): + self.server.notify.did_save_text_document( + { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict + LSPConstants.TEXT_DOCUMENT: { + LSPConstants.URI: uri, + } + } + ) + diagnostics = self._wait_for_relevant_published_diagnostics( + uri=uri, + after_generation=diagnostics_before_request, + timeout=self._get_published_diagnostics_wait_timeout(True), + allow_cached=True, + ) + + if diagnostics is None: + return [] + + return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) diff --git a/src/solidlsp/language_servers/fsharp_language_server.py b/src/solidlsp/language_servers/fsharp_language_server.py new file mode 100644 index 0000000..4b4fc82 --- /dev/null +++ b/src/solidlsp/language_servers/fsharp_language_server.py @@ -0,0 +1,376 @@ +""" +Provides F# specific instantiation of the LanguageServer class. +""" + +import logging +import os +import shutil +import threading +from pathlib import Path + +from overrides import override + +from serena.util.dotnet import DotNETUtil +from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_FSAUTOCOMPLETE_VERSION = "0.83.0" +DEFAULT_FSAUTOCOMPLETE_VERSION = "0.83.0" +FSAUTOCOMPLETE_VERSION = DEFAULT_FSAUTOCOMPLETE_VERSION + + +class FSharpLanguageServer(SolidLanguageServer): + """ + Provides F# specific instantiation of the LanguageServer class using Ionide LSP (FsAutoComplete). + Contains various configurations and settings specific to F# development. + + You can pass the following entries in ``ls_specific_settings["fsharp"]``: + - fsautocomplete_version: Override the pinned FsAutoComplete version + installed by Serena (default: the bundled Serena version). + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates an FSharpLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + fsharp_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=fsharp_lsp_executable_path, cwd=repository_root_path), + "fsharp", + solidlsp_settings, + ) + self.server_ready = threading.Event() + self.initialize_searcher_command_available = threading.Event() + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [ + "bin", + "obj", + "packages", + ".paket", + "paket-files", + ".fake", + ".ionide", + ] + + @classmethod + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str: + """ + Setup runtime dependencies for F# Language Server and return the command to start the server. + """ + fsharp_settings = solidlsp_settings.get_ls_specific_settings(Language.FSHARP) + fsautocomplete_version = fsharp_settings.get("fsautocomplete_version", DEFAULT_FSAUTOCOMPLETE_VERSION) + dotnet_exe = DotNETUtil("8.0", allow_higher_version=True).get_dotnet_path_or_raise() + + RuntimeDependencyCollection( + [ + RuntimeDependency( + id="fsautocomplete", + description="FsAutoComplete (Ionide F# Language Server)", + command=f"dotnet tool install --tool-path ./ fsautocomplete --version {fsautocomplete_version}", + platform_id="any", + ), + ] + ) + + # Install FsAutoComplete if not already installed + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = "fsharp-lsp" if fsautocomplete_version == INITIAL_FSAUTOCOMPLETE_VERSION else f"fsharp-lsp-{fsautocomplete_version}" + fsharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), ls_dirname) + fsautocomplete_path = os.path.join(fsharp_ls_dir, "fsautocomplete") + + # Handle Windows executable extension + if os.name == "nt": + fsautocomplete_path += ".exe" + + if not os.path.exists(fsautocomplete_path): + log.info(f"FsAutoComplete executable not found at {fsautocomplete_path}. Installing...") + + # Ensure the directory exists + os.makedirs(fsharp_ls_dir, exist_ok=True) + + # Install FsAutoComplete using dotnet tool install + try: + import subprocess + + result = subprocess.run( + [dotnet_exe, "tool", "install", "--tool-path", fsharp_ls_dir, "fsautocomplete", "--version", fsautocomplete_version], + cwd=fsharp_ls_dir, + capture_output=True, + text=True, + check=True, + ) + log.info("FsAutoComplete installed successfully") + log.debug(f"Installation output: {result.stdout}") + except subprocess.CalledProcessError as e: + log.error(f"Failed to install FsAutoComplete: {e.stderr}") + raise RuntimeError(f"Failed to install FsAutoComplete: {e.stderr}") + + if not os.path.exists(fsautocomplete_path): + raise FileNotFoundError( + f"FsAutoComplete executable not found at {fsautocomplete_path}, something went wrong with the installation." + ) + + # FsAutoComplete uses --lsp flag for LSP mode + return f"{fsautocomplete_path} --adaptive-lsp-server-enabled --project-graph-enabled --use-fcs-transparent-compiler" + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the F# Language Server. + """ + initialize_params = { + "capabilities": { + "workspace": { + "applyEdit": True, + "workspaceEdit": {"documentChanges": True}, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + "executeCommand": {"dynamicRegistration": True}, + "configuration": True, + "workspaceFolders": True, + }, + "textDocument": { + "synchronization": { + "dynamicRegistration": True, + "willSave": True, + "willSaveWaitUntil": True, + "didSave": True, + }, + "completion": { + "dynamicRegistration": True, + "contextSupport": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + }, + }, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": {"documentationFormat": ["markdown", "plaintext"]}, + }, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentHighlight": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, # All SymbolKind values (1-26) + "hierarchicalDocumentSymbolSupport": True, + }, + "codeAction": { + "dynamicRegistration": True, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + ] + } + }, + }, + "codeLens": {"dynamicRegistration": True}, + "formatting": {"dynamicRegistration": True}, + "rangeFormatting": {"dynamicRegistration": True}, + "onTypeFormatting": {"dynamicRegistration": True}, + "rename": {"dynamicRegistration": True}, + "documentLink": {"dynamicRegistration": True}, + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [1, 2]}, + }, + "implementation": {"dynamicRegistration": True}, + "typeDefinition": {"dynamicRegistration": True}, + "colorProvider": {"dynamicRegistration": True}, + "foldingRange": { + "dynamicRegistration": True, + "rangeLimit": 5000, + "lineFoldingOnly": True, + }, + "declaration": {"dynamicRegistration": True}, + "selectionRange": {"dynamicRegistration": True}, + }, + "window": { + "workDoneProgress": True, + }, + }, + "initializationOptions": { + # F# specific initialization options + "automaticWorkspaceInit": True, + "abstractClassStubGeneration": True, + "abstractClassStubGenerationObjectIdentifier": "this", + "abstractClassStubGenerationMethodBody": 'failwith "Not Implemented"', + "addFsiWatcher": False, + "codeLenses": {"signature": {"enabled": True}, "references": {"enabled": True}}, + "disableInMemoryProjectReferences": False, + "dotNetRoot": self._get_dotnet_root(), + "enableMSBuildProjectGraph": False, + "excludeProjectDirectories": ["paket-files"], + "externalAutocomplete": False, + "fsac": {"attachDebugger": False, "silencedLogs": [], "conserveMemory": False, "netCoreDllPath": ""}, + "fsiExtraParameters": [], + "generateBinlog": False, + "interfaceStubGeneration": True, + "interfaceStubGenerationObjectIdentifier": "this", + "interfaceStubGenerationMethodBody": 'failwith "Not Implemented"', + "keywordsAutocomplete": True, + "linter": True, + "pipelineHints": {"enabled": True}, + "recordStubGeneration": True, + "recordStubGenerationBody": 'failwith "Not Implemented"', + "resolveNamespaces": True, + "saveOnlyOpenFiles": False, + "showProjectExplorerIn": ["ionide", "solution"], + "simplifyNameAnalyzer": True, + "smartIndent": False, + "suggestGitignore": True, + "suggestSdkScripts": True, + "unionCaseStubGeneration": True, + "unionCaseStubGenerationBody": 'failwith "Not Implemented"', + "unusedDeclarationsAnalyzer": True, + "unusedOpensAnalyzer": True, + "verboseLogging": False, + "workspaceModePeekDeepLevel": 2, + "workspacePath": self.repository_root_path, + }, + "trace": "off", + } + + return initialize_params + + def _get_dotnet_root(self) -> str: + """ + Get the .NET root directory. + """ + dotnet_exe = shutil.which("dotnet") + if dotnet_exe: + # Try to get the installation path + try: + import subprocess + + result = subprocess.run([dotnet_exe, "--info"], capture_output=True, text=True, check=True) + lines = result.stdout.split("\n") + for line in lines: + if "Base Path:" in line or "Base path:" in line: + base_path = line.split(":", 1)[1].strip() + # Get the parent directory (remove 'sdk/version' part) + return str(Path(base_path).parent.parent) + except (subprocess.CalledProcessError, Exception): + pass + + # Fallback: use the directory containing dotnet executable + if dotnet_exe: + return str(Path(dotnet_exe).parent) + + return "" + + def _start_server(self) -> None: + """ + Start the F# Language Server with custom handlers. + """ + + def handle_window_log_message(params: dict) -> None: + """Handle window/logMessage from the LSP server.""" + message = params.get("message", "") + message_type = params.get("type", 1) + + # Map LSP log levels to Python logging levels + level_map = {1: logging.ERROR, 2: logging.WARNING, 3: logging.INFO, 4: logging.DEBUG} + level = level_map.get(message_type, logging.INFO) + + log.log(level, f"FsAutoComplete: {message}") + + def handle_window_show_message(params: dict) -> None: + """Handle window/showMessage from the LSP server.""" + message = params.get("message", "") + message_type = params.get("type", 1) + + # Map LSP message types to Python logging levels + level_map = {1: logging.ERROR, 2: logging.WARNING, 3: logging.INFO, 4: logging.DEBUG} + level = level_map.get(message_type, logging.INFO) + + log.log(level, f"FsAutoComplete Message: {message}") + + def handle_workspace_configuration(params: dict) -> list: + """Handle workspace/configuration requests from the LSP server.""" + # Return empty configuration for now + items = params.get("items", []) + return [None] * len(items) + + def handle_client_register_capability(params: dict) -> None: + """Handle client/registerCapability requests from the LSP server.""" + # For now, just acknowledge the registration + return + + def handle_client_unregister_capability(params: dict) -> None: + """Handle client/unregisterCapability requests from the LSP server.""" + # For now, just acknowledge the unregistration + return + + def handle_work_done_progress_create(params: dict) -> None: + """Handle window/workDoneProgress/create requests from the LSP server.""" + # Just acknowledge the request - we don't need to track progress for now + return + + # Register custom handlers + self.server.on_notification("window/logMessage", handle_window_log_message) + self.server.on_notification("window/showMessage", handle_window_show_message) + self.server.on_request("workspace/configuration", handle_workspace_configuration) + self.server.on_request("client/registerCapability", handle_client_register_capability) + self.server.on_request("client/unregisterCapability", handle_client_unregister_capability) + self.server.on_request("window/workDoneProgress/create", handle_work_done_progress_create) + + log.info("Starting FsAutoComplete F# language server process") + + try: + self.server.start() + except Exception as e: + log.error(f"Failed to start F# language server process: {e}") + raise SolidLSPException(f"Failed to start F# language server: {e}") + + # Send initialization + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request to F# language server") + try: + self.server.send.initialize(initialize_params) + log.debug("Received initialize response from F# language server") + except Exception as e: + raise SolidLSPException(f"Failed to initialize F# language server for {self.repository_root_path}: {e}") from e + + # Complete initialization + self.server.notify.initialized({}) + + log.info("F# language server initialized successfully") + + @override + def _get_wait_time_for_cross_file_referencing(self) -> float: + """ + F# projects can be large and may need more time for cross-file analysis. + """ + return 15.0 # 15 seconds should be sufficient for most F# projects diff --git a/src/solidlsp/language_servers/godot_language_server.py b/src/solidlsp/language_servers/godot_language_server.py new file mode 100644 index 0000000..d0ea8c3 --- /dev/null +++ b/src/solidlsp/language_servers/godot_language_server.py @@ -0,0 +1,142 @@ +"""GDScript language server for Godot Engine projects. + +Connects to an already-running Godot editor via TCP on port 6008. +Both Godot 3 and Godot 4 (tested through 4.6.x) use this port. + +The editor must be open with its built-in language server enabled (default). +""" + +import logging +import os +from collections.abc import Callable + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_process import LanguageServerInterface, TCPConnectionInfo, TCPLanguageServer +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo, StringDict +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +# Maps config_version in project.godot to the Godot major version +_CONFIG_VERSION_TO_GODOT_MAJOR: dict[int, int] = {4: 3, 5: 4} + +DEFAULT_GODOT_LS_PORT = 6008 +DEFAULT_GODOT_REQUEST_TIMEOUT = 30.0 + + +class GodotLanguageServer(SolidLanguageServer): + """GDScript language server that connects to a running Godot editor. + + Both Godot 3 and Godot 4 expose an LSP server on TCP port 6008. + The Godot editor must already be running — this class connects to it + rather than launching it. + + ls_specific_settings for ``gdscript``: + - ``port`` (int): TCP port the Godot editor's LSP listens on (default: 6008). + - ``request_timeout`` (float): seconds to wait for an LSP response (default: 30.0). + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None: + self._godot_version = self._detect_godot_version(repository_root_path) + if self._godot_version is not None: + log.info("Detected Godot version %d for project at %s", self._godot_version, repository_root_path) + else: + log.warning("Could not detect Godot version for project at %s", repository_root_path) + + self._configured_request_timeout: float | None = None + + # Dummy ProcessLaunchInfo — _create_language_server_interface() ignores it + super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=""), "gdscript", solidlsp_settings) + + def set_request_timeout(self, timeout: float | None) -> None: + """Cap the timeout at the value configured in ls_specific_settings, if set.""" + if timeout is not None and self._configured_request_timeout is not None: + timeout = min(timeout, self._configured_request_timeout) + super().set_request_timeout(timeout) + + @staticmethod + def _detect_godot_version(repo_path: str) -> int | None: + """Read project.godot to determine the major Godot version. + + Returns None if detection fails or the config_version is unrecognized. + """ + project_file = os.path.join(repo_path, "project.godot") + try: + with open(project_file, encoding="utf-8") as f: + for line in f: + line = line.strip() + if line.startswith("config_version="): + config_version = int(line.split("=", 1)[1]) + return _CONFIG_VERSION_TO_GODOT_MAJOR.get(config_version) + except (FileNotFoundError, ValueError, OSError): + pass + return None + + def _create_language_server_interface( + self, + process_launch_info: ProcessLaunchInfo, + logging_fn: Callable[[str, str, StringDict | str], None] | None, + ) -> LanguageServerInterface: + settings = self._custom_settings or {} + port = settings.get("port", DEFAULT_GODOT_LS_PORT) + request_timeout = settings.get("request_timeout", DEFAULT_GODOT_REQUEST_TIMEOUT) + self._configured_request_timeout = settings.get("request_timeout") + self._conn_info = TCPConnectionInfo(host="127.0.0.1", port=port) + return TCPLanguageServer( + connection_info=self._conn_info, + language=self.language, + determine_log_level=self._determine_log_level, + logger=logging_fn, + request_timeout=request_timeout, + ) + + def _create_base_initialize_params(self) -> dict: + params = { + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "declaration": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "completion": { + "dynamicRegistration": True, + "completionItem": {"snippetSupport": True}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "publishDiagnostics": {"relatedInformation": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + }, + }, + } + return params + + def _start_server(self) -> None: + def do_nothing(params: dict) -> None: + return + + # Godot sends this notification immediately on connect to report the open project path. + self.server.on_notification("gdscript_client/changeWorkspace", do_nothing) + # Godot-specific capability advertisement (not standard LSP). + self.server.on_notification("gdscript/capabilities", do_nothing) + self.server.on_notification("window/logMessage", lambda msg: log.info("LSP: window/logMessage: %s", msg)) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_request("client/registerCapability", lambda params: None) + + log.info("Connecting to Godot LSP at %s:%d", self._conn_info.host, self._conn_info.port) + self.server.start() + + initialize_params = self._create_initialize_params() + log.info("Sending LSP initialize request to Godot") + self.server.send.initialize(initialize_params) + self.server.notify.initialized({}) + log.info("Godot LSP initialized") diff --git a/src/solidlsp/language_servers/gopls.py b/src/solidlsp/language_servers/gopls.py new file mode 100644 index 0000000..4416492 --- /dev/null +++ b/src/solidlsp/language_servers/gopls.py @@ -0,0 +1,237 @@ +import hashlib +import json +import logging +import os +import subprocess +from collections.abc import Hashable +from typing import Any + +from overrides import override + +from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class Gopls(SolidLanguageServer): + """ + Provides Go specific instantiation of the LanguageServer class using gopls. + """ + + @classmethod + def supports_implementation_request(cls) -> bool: + return True + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # For Go projects, we should ignore: + # - vendor: third-party dependencies vendored into the project + # - node_modules: if the project has JavaScript components + # - dist/build: common output directories + return super().is_ignored_dirname(dirname) or dirname in ["vendor", "node_modules", "dist", "build"] + + @staticmethod + def _determine_log_level(line: str) -> int: + """Classify gopls stderr output to avoid false-positive errors.""" + line_lower = line.lower() + + # File discovery messages that are not actual errors + if any( + [ + "discover.go:" in line_lower, + "walker.go:" in line_lower, + "walking of {file://" in line_lower, + "bus: -> discover" in line_lower, + ] + ): + return logging.DEBUG + + return SolidLanguageServer._determine_log_level(line) + + @staticmethod + def _get_go_version() -> str | None: + """Get the installed Go version or None if not found.""" + try: + result = subprocess.run(["go", "version"], capture_output=True, text=True, check=False) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + return None + return None + + @staticmethod + def _get_gopls_version() -> str | None: + """Get the installed gopls version or None if not found.""" + try: + result = subprocess.run(["gopls", "version"], capture_output=True, text=True, check=False) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + return None + return None + + @staticmethod + def _setup_runtime_dependency() -> bool: + """ + Check if required Go runtime dependencies are available. + Raises RuntimeError with helpful message if dependencies are missing. + """ + go_version = Gopls._get_go_version() + if not go_version: + raise RuntimeError( + "Go is not installed. Please install Go from https://golang.org/doc/install and make sure it is added to your PATH." + ) + + gopls_version = Gopls._get_gopls_version() + if not gopls_version: + raise RuntimeError( + "Found a Go version but gopls is not installed.\n" + "Please install gopls as described in https://pkg.go.dev/golang.org/x/tools/gopls#section-readme\n\n" + "After installation, make sure it is added to your PATH (it might be installed in a different location than Go)." + ) + + return True + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + self._setup_runtime_dependency() + + super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd="gopls", cwd=repository_root_path), "go", solidlsp_settings) + self.request_id = 0 + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Go Language Server. + """ + initialize_params: dict = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + }, + "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, + }, + } + + # Apply gopls-specific settings via initializationOptions + # Serena applies gopls settings at initialization time via initializationOptions + # (Access settings directly to avoid extra INFO logging from CustomLSSettings.get.) + gopls_settings = self._custom_settings.settings.get("gopls_settings") + if gopls_settings: + gopls_settings = self._validate_gopls_settings_dict(gopls_settings) + + # Validate JSON-serializability early: initializationOptions is sent over JSON-RPC. + import json + + self._canonical_json_or_raise(json, gopls_settings) + + # Log keys only (and at DEBUG) to avoid leaking sensitive values and to reduce startup noise. + log.debug("Applying gopls settings via initializationOptions: keys=%s", list(gopls_settings.keys())) + initialize_params["initializationOptions"] = gopls_settings + + return initialize_params + + def _validate_gopls_settings_dict(self, gopls_settings: object) -> dict: + if not isinstance(gopls_settings, dict): + raise TypeError( + f"gopls_settings must be a dict, got {type(gopls_settings).__name__}. " + "Expected structure: {'buildFlags': ['-tags=foo'], 'env': {...}, ...}" + ) + + return gopls_settings + + def _canonical_json_or_raise(self, json_module: Any, data: object) -> str: + try: + return json_module.dumps(data, sort_keys=True, separators=(",", ":")) + except (TypeError, ValueError) as exc: + raise TypeError( + "gopls_settings must be JSON-serializable (json.dumps). Use JSON-compatible values (dict/list/str/int/float/bool/null) and prefer string keys." + ) from exc + + # Environment variables that influence Go build context and affect cached symbols. + _CACHE_CONTEXT_ENV_KEYS = ("GOFLAGS", "GOOS", "GOARCH", "CGO_ENABLED") + + @override + def _document_symbols_cache_fingerprint(self) -> Hashable: + """ + Compute a deterministic fingerprint of the Go build context. + + The fingerprint includes gopls_settings and selected env vars that affect symbol discovery. + """ + normalize_symbol_name_version = 1 + gopls_settings_raw = self._custom_settings.settings.get("gopls_settings") + + gopls_settings: dict | None + if gopls_settings_raw is None: + gopls_settings = None + else: + # Treat an explicitly empty dict the same as not providing settings at all. + gopls_settings = self._validate_gopls_settings_dict(gopls_settings_raw) or None + + # Only include env vars that are set to a non-empty value. + env_subset: dict[str, str] = {} + for key in self._CACHE_CONTEXT_ENV_KEYS: + value = os.environ.get(key) + if value: + env_subset[key] = value + + # Version processed symbols even when the build context itself is empty. + if gopls_settings is None and not env_subset: + return normalize_symbol_name_version + + fingerprint_data: dict[str, object] = { + "env": env_subset, + "normalize_symbol_name_version": normalize_symbol_name_version, + } + if gopls_settings is not None: + fingerprint_data["gopls_settings"] = gopls_settings + + canonical_json = self._canonical_json_or_raise(json, fingerprint_data) + + return hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()[:16] + + @override + def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: + return symbol["name"].rsplit(".", 1)[-1] + + def _start_server(self) -> None: + """Start gopls server process""" + + def register_capability_handler(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting gopls server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "completionProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + + # gopls server is typically ready immediately after initialization + # (no need to wait for events) diff --git a/src/solidlsp/language_servers/groovy_language_server.py b/src/solidlsp/language_servers/groovy_language_server.py new file mode 100644 index 0000000..f653073 --- /dev/null +++ b/src/solidlsp/language_servers/groovy_language_server.py @@ -0,0 +1,317 @@ +""" +Provides Groovy specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Groovy. +""" + +import dataclasses +import logging +import os +import shlex + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_utils import FileUtils, PlatformUtils +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +VSCODE_JAVA_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_VSCODE_JAVA_VERSION = "1.42.0-561" +INITIAL_VSCODE_JAVA_SHA256_BY_PLATFORM = { + "win-x64": "ef195b45bd260976ad2e84618f4044b5d7248deed41d647573f0ee22c4233df3", + "linux-x64": "7660b7b527be6fda46a917966b34d828e7416d5cc84287b29b88e7b99c1737f9", + "linux-arm64": "e15bc9b2a665d3453203402621b5441062aa41b0ec2d140661f439326fd248c1", + "osx-x64": "03ae1db1a22c15561a620f1b722d6797d35d4faaa7c4666dbe6ca2715089852f", + "osx-arm64": "bc00c2699d4b8d478eb9a1621db9d6d3a12ea0dcc247a9cd8040e8ac19c03933", +} +DEFAULT_VSCODE_JAVA_VERSION = "1.42.0-561" +DEFAULT_VSCODE_JAVA_SHA256_BY_PLATFORM = { + "win-x64": "ef195b45bd260976ad2e84618f4044b5d7248deed41d647573f0ee22c4233df3", + "linux-x64": "7660b7b527be6fda46a917966b34d828e7416d5cc84287b29b88e7b99c1737f9", + "linux-arm64": "e15bc9b2a665d3453203402621b5441062aa41b0ec2d140661f439326fd248c1", + "osx-x64": "03ae1db1a22c15561a620f1b722d6797d35d4faaa7c4666dbe6ca2715089852f", + "osx-arm64": "bc00c2699d4b8d478eb9a1621db9d6d3a12ea0dcc247a9cd8040e8ac19c03933", +} + + +def _vscode_java_sha(version: str, platform_key: str) -> str | None: + if version == INITIAL_VSCODE_JAVA_VERSION: + return INITIAL_VSCODE_JAVA_SHA256_BY_PLATFORM[platform_key] + if version == DEFAULT_VSCODE_JAVA_VERSION: + return DEFAULT_VSCODE_JAVA_SHA256_BY_PLATFORM[platform_key] + return None + + +@dataclasses.dataclass +class GroovyRuntimeDependencyPaths: + """ + Stores the paths to the runtime dependencies of Groovy Language Server + """ + + java_path: str + java_home_path: str + ls_jar_path: str + groovy_home_path: str | None = None + + +class GroovyLanguageServer(SolidLanguageServer): + """ + Provides Groovy specific instantiation of the LanguageServer class. + Contains various configurations and settings specific to Groovy. + + You can pass the following entries in ``ls_specific_settings["groovy"]``: + - ls_jar_path: Path to the Groovy Language Server JAR. + - ls_java_home_path: Optional Java home to use instead of Serena's managed JRE. + - ls_jar_options: Additional JVM/JAR options passed to the Groovy LS. + - vscode_java_version: Override the pinned vscode-java runtime bundle version + downloaded by Serena when it manages Java itself (default: the bundled Serena version). + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a Groovy Language Server instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + runtime_dependency_paths = self._setup_runtime_dependencies(solidlsp_settings) + self.runtime_dependency_paths = runtime_dependency_paths + + # Get jar options from configuration + ls_jar_options = [] + + if solidlsp_settings.ls_specific_settings: + groovy_settings = solidlsp_settings.get_ls_specific_settings(Language.GROOVY) + jar_options_str = groovy_settings.get("ls_jar_options", "") + if jar_options_str: + ls_jar_options = shlex.split(jar_options_str) + log.info(f"Using Groovy LS JAR options from configuration: {jar_options_str}") + + # Create command to execute the Groovy Language Server + cmd = [self.runtime_dependency_paths.java_path, "-jar", self.runtime_dependency_paths.ls_jar_path] + cmd.extend(ls_jar_options) + + # Set environment variables including JAVA_HOME + proc_env = {"JAVA_HOME": self.runtime_dependency_paths.java_home_path} + + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=cmd, env=proc_env, cwd=repository_root_path), + "groovy", + solidlsp_settings, + ) + + log.info(f"Starting Groovy Language Server with jar options: {ls_jar_options}") + + @classmethod + def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> GroovyRuntimeDependencyPaths: + """ + Setup runtime dependencies for Groovy Language Server and return paths. + """ + platform_id = PlatformUtils.get_platform_id() + groovy_settings = solidlsp_settings.get_ls_specific_settings(Language.GROOVY) + vscode_java_version = groovy_settings.get("vscode_java_version", DEFAULT_VSCODE_JAVA_VERSION) + vscode_java_tag = f"v{vscode_java_version.rsplit('-', 1)[0]}" + + # Verify platform support + assert platform_id.value.startswith("win-") or platform_id.value.startswith("linux-") or platform_id.value.startswith("osx-"), ( + "Only Windows, Linux and macOS platforms are supported for Groovy in multilspy at the moment" + ) + + # Check if user specified custom Java home path + java_home_path = None + java_path = None + + if solidlsp_settings and solidlsp_settings.ls_specific_settings: + groovy_settings = solidlsp_settings.get_ls_specific_settings(Language.GROOVY) + custom_java_home = groovy_settings.get("ls_java_home_path") + if custom_java_home: + log.info(f"Using custom Java home path from configuration: {custom_java_home}") + java_home_path = custom_java_home + + # Determine java executable path based on platform + if platform_id.value.startswith("win-"): + java_path = os.path.join(java_home_path, "bin", "java.exe") + else: + java_path = os.path.join(java_home_path, "bin", "java") + + # If no custom Java home path, download and use bundled Java + if java_home_path is None: + # Runtime dependency information + runtime_dependencies = { + "java": { + "win-x64": { + "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-win32-x64-{vscode_java_version}.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/21.0.7-win32-x86_64", + "java_path": "extension/jre/21.0.7-win32-x86_64/bin/java.exe", + "sha256": _vscode_java_sha(vscode_java_version, "win-x64"), + }, + "linux-x64": { + "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-linux-x64-{vscode_java_version}.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/21.0.7-linux-x86_64", + "java_path": "extension/jre/21.0.7-linux-x86_64/bin/java", + "sha256": _vscode_java_sha(vscode_java_version, "linux-x64"), + }, + "linux-arm64": { + "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-linux-arm64-{vscode_java_version}.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/21.0.7-linux-aarch64", + "java_path": "extension/jre/21.0.7-linux-aarch64/bin/java", + "sha256": _vscode_java_sha(vscode_java_version, "linux-arm64"), + }, + "osx-x64": { + "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-x64-{vscode_java_version}.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/21.0.7-macosx-x86_64", + "java_path": "extension/jre/21.0.7-macosx-x86_64/bin/java", + "sha256": _vscode_java_sha(vscode_java_version, "osx-x64"), + }, + "osx-arm64": { + "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-arm64-{vscode_java_version}.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/21.0.7-macosx-aarch64", + "java_path": "extension/jre/21.0.7-macosx-aarch64/bin/java", + "sha256": _vscode_java_sha(vscode_java_version, "osx-arm64"), + }, + }, + } + + java_dependency = runtime_dependencies["java"][platform_id.value] + java_home_relative_path = java_dependency["java_home_path"] + java_relative_path = java_dependency["java_path"] + java_download_url = java_dependency["url"] + java_archive_type = java_dependency["archiveType"] + assert java_home_relative_path is not None + assert java_relative_path is not None + assert java_download_url is not None + assert java_archive_type is not None + + static_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "groovy_language_server") + os.makedirs(static_dir, exist_ok=True) + + # legacy unversioned "java" dir reserved for INITIAL; every other version goes into a versioned subdir + java_dirname = "java" if vscode_java_version == INITIAL_VSCODE_JAVA_VERSION else f"java-{vscode_java_version}" + java_dir = os.path.join(static_dir, java_dirname) + os.makedirs(java_dir, exist_ok=True) + + java_home_path = os.path.join(java_dir, java_home_relative_path) + java_path = os.path.join(java_dir, java_relative_path) + + if not os.path.exists(java_path): + log.info(f"Downloading Java for {platform_id.value}...") + FileUtils.download_and_extract_archive_verified( + java_download_url, + java_dir, + java_archive_type, + expected_sha256=java_dependency["sha256"], + allowed_hosts=VSCODE_JAVA_ALLOWED_HOSTS, + ) + + if not platform_id.value.startswith("win-"): + os.chmod(java_path, 0o755) + + assert java_path and os.path.exists(java_path), f"Java executable not found at {java_path}" + + ls_jar_path = cls._find_groovy_ls_jar(solidlsp_settings) + + return GroovyRuntimeDependencyPaths(java_path=java_path, java_home_path=java_home_path, ls_jar_path=ls_jar_path) + + @classmethod + def _find_groovy_ls_jar(cls, solidlsp_settings: SolidLSPSettings) -> str: + """ + Find Groovy Language Server JAR file + """ + if solidlsp_settings and solidlsp_settings.ls_specific_settings: + groovy_settings = solidlsp_settings.get_ls_specific_settings(Language.GROOVY) + config_jar_path = groovy_settings.get("ls_jar_path") + if config_jar_path and os.path.exists(config_jar_path): + log.info(f"Using Groovy LS JAR from configuration: {config_jar_path}") + return config_jar_path + + # if JAR not found + raise RuntimeError( + "Groovy Language Server JAR not found. To use Groovy language support:\n" + "Set 'ls_jar_path' in groovy settings in serena_config.yml:\n" + " ls_specific_settings:\n" + " groovy:\n" + " ls_jar_path: '/path/to/groovy-language-server.jar'\n" + " Ensure the JAR file is available at the configured path\n" + ) + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Groovy Language Server. + """ + initialize_params = { + "capabilities": { + "textDocument": { + "synchronization": {"dynamicRegistration": True, "didSave": True}, + "completion": {"dynamicRegistration": True}, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": {"dynamicRegistration": True}, + "workspaceSymbol": {"dynamicRegistration": True}, + "signatureHelp": {"dynamicRegistration": True}, + "rename": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + }, + }, + "initializationOptions": { + "settings": { + "groovy": { + "classpath": [], + "diagnostics": {"enabled": True}, + "completion": {"enabled": True}, + } + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """ + Starts the Groovy Language Server + """ + + def execute_client_command_handler(params: dict) -> list: + return [] + + def do_nothing(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_notification("language/status", do_nothing) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + + log.info("Starting Groovy server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + capabilities = init_response["capabilities"] + assert "textDocumentSync" in capabilities, "Server must support textDocumentSync" + assert "hoverProvider" in capabilities, "Server must support hover" + assert "completionProvider" in capabilities, "Server must support code completion" + assert "signatureHelpProvider" in capabilities, "Server must support signature help" + assert "definitionProvider" in capabilities, "Server must support go to definition" + assert "referencesProvider" in capabilities, "Server must support find references" + assert "documentSymbolProvider" in capabilities, "Server must support document symbols" + assert "workspaceSymbolProvider" in capabilities, "Server must support workspace symbols" + + self.server.notify.initialized({}) diff --git a/src/solidlsp/language_servers/haskell_language_server.py b/src/solidlsp/language_servers/haskell_language_server.py new file mode 100644 index 0000000..c732c4b --- /dev/null +++ b/src/solidlsp/language_servers/haskell_language_server.py @@ -0,0 +1,369 @@ +""" +Provides Haskell specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Haskell. +""" + +import logging +import os +import shutil +import time +from typing import Any + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class HaskellLanguageServer(SolidLanguageServer): + """ + Provides Haskell specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Haskell. + Uses Haskell Language Server (HLS) for LSP functionality. + """ + + @staticmethod + def _ensure_hls_installed() -> str: + """Ensure haskell-language-server-wrapper is available.""" + # Try common locations + common_paths = [ + shutil.which("haskell-language-server-wrapper"), + "/opt/homebrew/bin/haskell-language-server-wrapper", + "/usr/local/bin/haskell-language-server-wrapper", + os.path.expanduser("~/.ghcup/bin/haskell-language-server-wrapper"), + os.path.expanduser("~/.cabal/bin/haskell-language-server-wrapper"), + os.path.expanduser("~/.local/bin/haskell-language-server-wrapper"), + ] + + # Check Stack programs directory + stack_programs = os.path.expanduser("~/.local/share/stack/programs") + if os.path.exists(stack_programs): + try: + for arch_dir in os.listdir(stack_programs): + arch_path = os.path.join(stack_programs, arch_dir) + if os.path.isdir(arch_path): + try: + for ghc_dir in os.listdir(arch_path): + hls_path = os.path.join(arch_path, ghc_dir, "bin", "haskell-language-server-wrapper") + if os.path.isfile(hls_path) and os.access(hls_path, os.X_OK): + common_paths.append(hls_path) + except (PermissionError, OSError): + # Skip directories we can't read + continue + except (PermissionError, OSError): + # Stack programs directory not accessible + pass + + for path in common_paths: + if path and os.path.isfile(path) and os.access(path, os.X_OK): + return path + + raise RuntimeError( + "haskell-language-server-wrapper is not installed or not in PATH.\n" + "Searched locations:\n" + "\n".join(f" - {p}" for p in common_paths if p) + "\n" + "Please install HLS via:\n" + " - GHCup: https://www.haskell.org/ghcup/\n" + " - Stack: stack install haskell-language-server\n" + " - Cabal: cabal install haskell-language-server\n" + " - Homebrew (macOS): brew install haskell-language-server" + ) + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a HaskellLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + hls_executable_path = self._ensure_hls_installed() + log.info(f"Using haskell-language-server at: {hls_executable_path}") + + # Check if there's a haskell subdirectory with Stack/Cabal project + haskell_subdir = os.path.join(repository_root_path, "haskell") + if os.path.exists(haskell_subdir) and ( + os.path.exists(os.path.join(haskell_subdir, "stack.yaml")) or os.path.exists(os.path.join(haskell_subdir, "cabal.project")) + ): + working_dir = haskell_subdir + log.info(f"Using Haskell project directory: {working_dir}") + else: + working_dir = repository_root_path + + # Set up environment with GHCup bin in PATH + env = dict(os.environ) + ghcup_bin = os.path.expanduser("~/.ghcup/bin") + if ghcup_bin not in env.get("PATH", ""): + env["PATH"] = f"{ghcup_bin}{os.pathsep}{env.get('PATH', '')}" + + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=[hls_executable_path, "--lsp", "--cwd", working_dir], cwd=working_dir, env=env), + "haskell", + solidlsp_settings, + ) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["dist", "dist-newstyle", ".stack-work", ".cabal-sandbox"] + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Haskell Language Server. + """ + initialize_params = { + "locale": "en", + "capabilities": { + "workspace": { + "applyEdit": True, + "workspaceEdit": { + "documentChanges": True, + "resourceOperations": ["create", "rename", "delete"], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": True, + "changeAnnotationSupport": {"groupsOnLabel": True}, + }, + "configuration": True, + "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "tagSupport": {"valueSet": [1]}, + "resolveSupport": {"properties": ["location.range"]}, + }, + "executeCommand": {"dynamicRegistration": True}, + "didChangeConfiguration": {"dynamicRegistration": True}, + "workspaceFolders": True, + "semanticTokens": {"refreshSupport": True}, + }, + "textDocument": { + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [1, 2]}, + "codeDescriptionSupport": True, + "dataSupport": True, + }, + "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, + "completion": { + "dynamicRegistration": True, + "contextSupport": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + "tagSupport": {"valueSet": [1]}, + "insertReplaceSupport": True, + "resolveSupport": {"properties": ["documentation", "detail", "additionalTextEdits"]}, + "insertTextModeSupport": {"valueSet": [1, 2]}, + "labelDetailsSupport": True, + }, + "insertTextMode": 2, + "completionItemKind": { + "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25] + }, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + "activeParameterSupport": True, + }, + "contextSupport": True, + }, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentHighlight": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "hierarchicalDocumentSymbolSupport": True, + "tagSupport": {"valueSet": [1]}, + "labelSupport": True, + }, + "codeAction": { + "dynamicRegistration": True, + "isPreferredSupport": True, + "disabledSupport": True, + "dataSupport": True, + "resolveSupport": {"properties": ["edit"]}, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + ] + } + }, + "honorsChangeAnnotations": False, + }, + "formatting": {"dynamicRegistration": True}, + "rangeFormatting": {"dynamicRegistration": True}, + "onTypeFormatting": {"dynamicRegistration": True}, + "rename": { + "dynamicRegistration": True, + "prepareSupport": True, + "prepareSupportDefaultBehavior": 1, + "honorsChangeAnnotations": True, + }, + "documentLink": {"dynamicRegistration": True, "tooltipSupport": True}, + "typeDefinition": {"dynamicRegistration": True, "linkSupport": True}, + "implementation": {"dynamicRegistration": True, "linkSupport": True}, + "colorProvider": {"dynamicRegistration": True}, + "foldingRange": { + "dynamicRegistration": True, + "rangeLimit": 5000, + "lineFoldingOnly": True, + "foldingRangeKind": {"valueSet": ["comment", "imports", "region"]}, + }, + "declaration": {"dynamicRegistration": True, "linkSupport": True}, + "selectionRange": {"dynamicRegistration": True}, + "callHierarchy": {"dynamicRegistration": True}, + "semanticTokens": { + "dynamicRegistration": True, + "tokenTypes": [ + "namespace", + "type", + "class", + "enum", + "interface", + "struct", + "typeParameter", + "parameter", + "variable", + "property", + "enumMember", + "event", + "function", + "method", + "macro", + "keyword", + "modifier", + "comment", + "string", + "number", + "regexp", + "operator", + ], + "tokenModifiers": [ + "declaration", + "definition", + "readonly", + "static", + "deprecated", + "abstract", + "async", + "modification", + "documentation", + "defaultLibrary", + ], + "formats": ["relative"], + "requests": {"range": True, "full": {"delta": True}}, + "multilineTokenSupport": False, + "overlappingTokenSupport": False, + }, + "linkedEditingRange": {"dynamicRegistration": True}, + }, + "window": { + "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, + "showDocument": {"support": True}, + "workDoneProgress": True, + }, + "general": { + "staleRequestSupport": {"cancel": True, "retryOnContentModified": []}, + "regularExpressions": {"engine": "ECMAScript", "version": "ES2020"}, + "markdown": { + "parser": "marked", + "version": "1.1.0", + }, + "positionEncodings": ["utf-16"], + }, + }, + "initializationOptions": { + "haskell": { + "formattingProvider": "ormolu", + "checkProject": True, + } + }, + "trace": "verbose", + } + return initialize_params + + def _start_server(self) -> None: + """ + Starts the Haskell Language Server + """ + + def do_nothing(params: Any) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def register_capability_handler(params: dict) -> None: + """Handle dynamic capability registration from HLS""" + if "registrations" in params: + for registration in params.get("registrations", []): + method = registration.get("method", "") + log.info(f"HLS registered capability: {method}") + return + + def workspace_configuration_handler(params: dict) -> Any: + """Handle workspace/configuration requests from HLS""" + log.info(f"HLS requesting configuration: {params}") + + # Configuration matching VS Code settings and initialization options + haskell_config = { + "formattingProvider": "ormolu", + "checkProject": True, + "plugin": {"importLens": {"codeActionsOn": False, "codeLensOn": False}, "hlint": {"codeActionsOn": False}}, + } + + # HLS expects array of config items matching requested sections + if isinstance(params, dict) and "items" in params: + result = [] + for item in params["items"]: + section = item.get("section", "") + if section == "haskell": + result.append(haskell_config) + else: + result.append({}) + log.info(f"Returning configuration: {result}") + return result + + # Fallback: return single config + return [haskell_config] + + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_request("workspace/configuration", workspace_configuration_handler) + + log.info("Starting Haskell Language Server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + # Log capabilities returned by HLS + capabilities = init_response.get("capabilities", {}) + log.info(f"HLS capabilities: {list(capabilities.keys())}") + + self.server.notify.initialized({}) + + # Give HLS time to index the project + # HLS can be slow to index, especially on first run + log.info("Waiting for HLS to index project...") + time.sleep(5) + + log.info("Haskell Language Server initialized successfully") diff --git a/src/solidlsp/language_servers/haxe_language_server.py b/src/solidlsp/language_servers/haxe_language_server.py new file mode 100644 index 0000000..3d4a154 --- /dev/null +++ b/src/solidlsp/language_servers/haxe_language_server.py @@ -0,0 +1,408 @@ +"""Haxe language server integration using vshaxe/haxe-language-server.""" + +import glob +import hashlib +import logging +import os +import pathlib +import shutil +import tempfile +import threading +import urllib.request +import zipfile + +from overrides import override + +from solidlsp.ls import ( + LanguageServerDependencyProvider, + LanguageServerDependencyProviderSinglePath, + LSPFileBuffer, + SolidLanguageServer, +) +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.ls_types import Hover +from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_VSHAXE_VERSION = "2.34.2" +INITIAL_VSHAXE_SHA256 = "104d785e3f7b57a7f3debf520d9751f7e7abf3a7e78d203db1a8ff3dc7ca30e2" +DEFAULT_VSHAXE_VERSION = "2.34.2" +DEFAULT_VSHAXE_SHA256 = "104d785e3f7b57a7f3debf520d9751f7e7abf3a7e78d203db1a8ff3dc7ca30e2" + + +def _vshaxe_sha(version: str) -> str | None: + if version == INITIAL_VSHAXE_VERSION: + return INITIAL_VSHAXE_SHA256 + if version == DEFAULT_VSHAXE_VERSION: + return DEFAULT_VSHAXE_SHA256 + return None + + +class HaxeLanguageServer(SolidLanguageServer): + """Haxe language server integration using vshaxe/haxe-language-server. + + Requires Haxe compiler (3.4.0+) and Node.js. + """ + + _COMPILATION_TIMEOUT = 60.0 + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """Creates a HaxeLanguageServer instance. Use LanguageServer.create() instead.""" + super().__init__( + config, + repository_root_path, + None, + "haxe", + solidlsp_settings, + ) + + self._server_ready = threading.Event() + self._server_ready.set() + self._active_progress_tokens: set[str] = set() + self._progress_lock = threading.Lock() + + @override + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + # Downloaded from Open VSX (not the VS Code Marketplace) because Open VSX + # provides stable versioned URLs and SHA256 checksums for integrity verification. + + @override + def _get_or_install_core_dependency(self) -> str: + """Find the Haxe Language Server binary.""" + # 1. Check for haxe-language-server in PATH + system_haxe_ls = shutil.which("haxe-language-server") + if system_haxe_ls: + log.info(f"Found system-installed haxe-language-server at {system_haxe_ls}") + return system_haxe_ls + + # 2. Check VSCode extension locations + vscode_server_path = self._find_vscode_extension_server() + if vscode_server_path: + log.info(f"Found Haxe Language Server in VSCode extension at {vscode_server_path}") + return vscode_server_path + + # 3. Check resource dir / download from Open VSX + version = self._custom_settings.get("version", DEFAULT_VSHAXE_VERSION) + ls_dirname = "haxe-lsp" if version == INITIAL_VSHAXE_VERSION else f"haxe-lsp-{version}" + haxe_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname) + server_js_path = os.path.join(haxe_ls_dir, "bin", "server.js") + if os.path.exists(server_js_path): + log.info(f"Found Haxe Language Server at {server_js_path}") + return server_js_path + + if shutil.which("node") is None: + raise FileNotFoundError( + "Haxe Language Server not found and Node.js is not installed (required to run it).\n" + "Install options:\n" + " 1. Install Node.js and re-run (auto-download will proceed)\n" + " 2. Install the vshaxe VSCode extension: code --install-extension nadako.vshaxe\n" + " 3. Set ls_path in serena_config.yml under ls_specific_settings.haxe" + ) + + downloaded_path = self._download_from_open_vsx(haxe_ls_dir, version) + if downloaded_path: + return downloaded_path + + raise FileNotFoundError( + "Haxe Language Server not found. Install options:\n" + " 1. Install the vshaxe VSCode extension: code --install-extension nadako.vshaxe\n" + " 2. Set ls_path in serena_config.yml under ls_specific_settings.haxe" + ) + + @staticmethod + def _find_vscode_extension_server() -> str | None: + """Search for the Haxe language server in VSCode extension directories.""" + search_paths = [ + os.path.expanduser("~/.vscode/extensions/nadako.vshaxe-*/bin/server.js"), + os.path.expanduser("~/.vscode-server/extensions/nadako.vshaxe-*/bin/server.js"), + os.path.expanduser("~/.vscode-insiders/extensions/nadako.vshaxe-*/bin/server.js"), + ] + for pattern in search_paths: + matches = sorted(glob.glob(pattern), reverse=True) # newest version first + for match in matches: + if os.path.isfile(match): + return match + return None + + @classmethod + def _download_from_open_vsx(cls, target_dir: str, version: str) -> str | None: + """Download a vshaxe VSIX from Open VSX and extract server.js. + Verifies the download against a hardcoded SHA256 checksum when using the default version. + """ + try: + download_url = f"https://open-vsx.org/api/nadako/vshaxe/{version}/file/nadako.vshaxe-{version}.vsix" + log.info("Downloading Haxe Language Server v%s from Open VSX...", version) + vsix_path = os.path.join(tempfile.gettempdir(), "vshaxe.vsix") + urllib.request.urlretrieve(download_url, vsix_path) + + # Verify SHA256 checksum only when the resolved version is one of our pinned ones (INITIAL or current DEFAULT) + expected_sha = _vshaxe_sha(version) + if expected_sha is not None: + sha256 = hashlib.sha256() + with open(vsix_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + sha256.update(chunk) + if sha256.hexdigest().lower() != expected_sha: + os.remove(vsix_path) + raise RuntimeError( + f"SHA256 checksum mismatch for vshaxe VSIX. Expected {expected_sha}, " + f"got {sha256.hexdigest()}. The file may be corrupted or tampered with." + ) + log.info("SHA256 checksum verified") + else: + log.info("Using custom version %s — skipping SHA256 verification", version) + + # VSIX files are ZIP archives — extract bin/ contents + bin_dir = os.path.join(target_dir, "bin") + os.makedirs(bin_dir, exist_ok=True) + with zipfile.ZipFile(vsix_path, "r") as zf: + for entry in zf.namelist(): + if "/bin/" in entry: + filename = entry.split("/bin/", 1)[-1] + if filename and ".." not in filename: + dest_path = os.path.join(bin_dir, filename) + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + with zf.open(entry) as src, open(dest_path, "wb") as dst: + dst.write(src.read()) + + os.remove(vsix_path) + + server_js_path = os.path.join(bin_dir, "server.js") + if os.path.exists(server_js_path): + log.info(f"Haxe Language Server v{version} installed to {server_js_path}") + return server_js_path + + log.error("Downloaded VSIX but server.js not found after extraction") + return None + + except Exception: + log.exception("Failed to download Haxe Language Server from Open VSX") + return None + + @override + def _create_launch_command(self, core_path: str) -> list[str]: + if core_path.endswith(".js"): + return ["node", core_path] + return [core_path, "--stdio"] + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [ + "node_modules", + "export", + "dump", + ] + + def _create_base_initialize_params(self) -> dict: + """Return initialize params for the Haxe Language Server. + + displayArguments are resolved from user-configured buildFile or auto-discovered .hxml files. + """ + # 1. Check for user-configured .hxml path + configured_build_file = self._custom_settings.get("buildFile") + if configured_build_file: + log.info(f"Using user-configured Haxe build file: {configured_build_file}") + display_arguments = [configured_build_file] + else: + # 2. Auto-discover .hxml files recursively + display_arguments = self._discover_hxml_file(self.repository_root_path) + + init_options: dict = {"displayArguments": display_arguments} + rename_source_folders = self._custom_settings.get("renameSourceFolders") + if rename_source_folders: + init_options["renameSourceFolders"] = rename_source_folders + + haxe_path = self._custom_settings.get("haxePath") + if haxe_path: + init_options["haxePath"] = haxe_path + + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [1, 2]}, + "codeDescriptionSupport": True, + "dataSupport": True, + }, + "synchronization": {"dynamicRegistration": True, "didSave": True}, + "completion": {"completionItem": {"snippetSupport": True}}, + "definition": {}, + "references": {}, + "documentSymbol": { + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"contentFormat": ["markdown", "plaintext"]}, + "codeAction": {}, + "rename": {}, + "signatureHelp": {}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {}, + "symbol": {}, + }, + }, + "initializationOptions": init_options, + } + return initialize_params + + @staticmethod + def _discover_hxml_file(repository_absolute_path: str) -> list[str]: + """Return the first .hxml file found, filtering out dependency directories. + + For more control, set ``ls_specific_settings.haxe.buildFile`` in ``serena_config.yml``. + """ + max_depth = 5 + skip_dirs = {"node_modules", "haxe_libraries", ".haxelib", "export", "dump", "bin", ".git", "build"} + + for root, dirs, files in os.walk(repository_absolute_path): + # Skip dependency/build output directories + dirs[:] = [d for d in dirs if d not in skip_dirs] + depth = len(pathlib.Path(root).relative_to(repository_absolute_path).parts) + if depth > max_depth: + dirs.clear() + continue + for f in files: + if f.endswith(".hxml") and "haxe_libraries" not in root: + hxml_path = os.path.relpath(os.path.join(root, f), repository_absolute_path) + log.info( + "Auto-discovered Haxe build file: %s. To use a different file, set " + "ls_specific_settings.haxe.buildFile in serena_config.yml.", + hxml_path, + ) + return [hxml_path] + + log.info("No .hxml file found in project") + return [] + + @override + def _start_server(self) -> None: + """Start the Haxe Language Server and wait for initial compilation. + + Uses textDocument/publishDiagnostics as the primary compilation-complete signal + and $/progress tokens as a secondary signal. + """ + + def diagnostics_handler(params: dict) -> None: + """Signal compilation complete when diagnostics arrive. + Defers if progress tokens are still active to avoid a race condition. + """ + uri = params.get("uri", "unknown") + diags = params.get("diagnostics", []) + errors = [d for d in diags if d.get("severity") == DiagnosticSeverity.Error] + if errors: + log.warning("Haxe LSP diagnostics for %s: %d errors: %s", uri, len(errors), errors) + else: + log.info("Haxe LSP diagnostics for %s: clean (%d total)", uri, len(diags)) + + with self._progress_lock: + if not self._active_progress_tokens: + log.info("Haxe LSP: no active progress tokens — signalling compilation complete") + self._server_ready.set() + else: + log.info( + "Haxe LSP: diagnostics received but %d progress tokens still active — deferring", + len(self._active_progress_tokens), + ) + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def register_capability_handler(params: dict) -> None: + # Haxe LS sends this but we don't need dynamic capability registration. + return + + def work_done_progress_create(params: dict) -> dict: + """Handle window/workDoneProgress/create — clear compilation event until tokens finish.""" + token = str(params.get("token", "")) + log.debug(f"Haxe LSP workDoneProgress/create: token={token!r}") + with self._progress_lock: + self._active_progress_tokens.add(token) + self._server_ready.clear() + return {} + + def progress_handler(params: dict) -> None: + """Track $/progress begin/end to detect when all async compilation work finishes.""" + token = str(params.get("token", "")) + value = params.get("value", {}) + kind = value.get("kind") + if kind == "begin": + title = value.get("title", "") + log.info(f"Haxe LSP progress [{token}]: started - {title}") + with self._progress_lock: + self._active_progress_tokens.add(token) + self._server_ready.clear() + elif kind == "report": + pct = value.get("percentage") + msg = value.get("message", "") + pct_str = f" ({pct}%)" if pct is not None else "" + log.debug(f"Haxe LSP progress [{token}]: {msg}{pct_str}") + elif kind == "end": + msg = value.get("message", "") + log.info(f"Haxe LSP progress [{token}]: ended - {msg}") + with self._progress_lock: + self._active_progress_tokens.discard(token) + if not self._active_progress_tokens: + self._server_ready.set() + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_request("window/workDoneProgress/create", work_done_progress_create) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", progress_handler) + self.server.on_notification("textDocument/publishDiagnostics", diagnostics_handler) + + log.info("Starting Haxe server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + self.server.send.initialize(initialize_params) + + self._server_ready.clear() + self.server.notify.initialized({}) + + # LS doesn't properly initialise without a workspace_did_change_configuration notification here. + config_settings: dict = {} + haxe_path = self._custom_settings.get("haxePath") + if haxe_path: + config_settings["haxePath"] = haxe_path + self.server.notify.workspace_did_change_configuration({"settings": config_settings}) + + log.info("Waiting for Haxe LSP compilation to complete...") + if self._server_ready.wait(timeout=self._COMPILATION_TIMEOUT): + log.info("Haxe server compilation completed, server ready") + else: + log.warning( + "Haxe LSP did not signal compilation completion within %.0fs — responses may be incomplete", + self._COMPILATION_TIMEOUT, + ) + + @override + def request_hover(self, relative_file_path: str, line: int, column: int, file_buffer: LSPFileBuffer | None = None) -> Hover | None: + """Request hover info, returning None instead of raising on failure. + + The Haxe language server does not provide hover for all symbol types (e.g. class + declarations), and may raise errors instead of returning None in those cases. + """ + try: + return super().request_hover(relative_file_path, line, column, file_buffer=file_buffer) + except SolidLSPException: + log.warning("Hover request failed for %s:%d:%d", relative_file_path, line, column, exc_info=True) + return None + + @override + def _get_wait_time_for_cross_file_referencing(self) -> float: + return 5 diff --git a/src/solidlsp/language_servers/hlsl_language_server.py b/src/solidlsp/language_servers/hlsl_language_server.py new file mode 100644 index 0000000..a41713e --- /dev/null +++ b/src/solidlsp/language_servers/hlsl_language_server.py @@ -0,0 +1,250 @@ +""" +Shader language server using shader-language-server (antaalt/shader-sense). +Supports HLSL, GLSL, and WGSL shader file formats. +""" + +import logging +import os +import shutil +from typing import Any + +from overrides import override + +from solidlsp.ls import ( + LanguageServerDependencyProvider, + LanguageServerDependencyProviderSinglePath, + SolidLanguageServer, +) +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +from .common import RuntimeDependency, RuntimeDependencyCollection + +log = logging.getLogger(__name__) + +_GITHUB_RELEASE_BASE = "https://github.com/antaalt/shader-sense/releases/download" +_HLSL_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# _INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# _DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +_INITIAL_VERSION = "1.3.1" +_INITIAL_HLSL_SHA256_BY_ASSET = { + "shader-language-server-x86_64-pc-windows-msvc.zip": "49081c5547ddde1b8b3b17295282a80ddacbca1d6f5dcd834e2788c02bafa997", + "shader-language-server-x86_64-unknown-linux-gnu.zip": "61710df7ca17a2d063b598936c57c56c49fbf837707a1aa886f9b0193a35be3c", + "shader-language-server-aarch64-pc-windows-msvc.zip": "a3b3799affe2cad27652e788376b46fe76e1a6c2ce45946a486dcb26c9091412", +} +_DEFAULT_VERSION = "1.3.1" +_DEFAULT_HLSL_SHA256_BY_ASSET = { + "shader-language-server-x86_64-pc-windows-msvc.zip": "49081c5547ddde1b8b3b17295282a80ddacbca1d6f5dcd834e2788c02bafa997", + "shader-language-server-x86_64-unknown-linux-gnu.zip": "61710df7ca17a2d063b598936c57c56c49fbf837707a1aa886f9b0193a35be3c", + "shader-language-server-aarch64-pc-windows-msvc.zip": "a3b3799affe2cad27652e788376b46fe76e1a6c2ce45946a486dcb26c9091412", +} + + +def _hlsl_sha(version: str, asset_name: str) -> str | None: + if version == _INITIAL_VERSION: + return _INITIAL_HLSL_SHA256_BY_ASSET.get(asset_name) + if version == _DEFAULT_VERSION: + return _DEFAULT_HLSL_SHA256_BY_ASSET.get(asset_name) + return None + + +class HlslLanguageServer(SolidLanguageServer): + """ + Shader language server using shader-language-server. + Supports .hlsl, .hlsli, .fx, .fxh, .cginc, .compute, .shader, .glsl, .vert, .frag, .geom, .tesc, .tese, .comp, .wgsl files. + + You can pass the following entries in ``ls_specific_settings["hlsl"]``: + - version: Override the pinned shader-language-server version downloaded + or built by Serena (default: the bundled Serena version). + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None: + super().__init__(config, repository_root_path, None, "hlsl", solidlsp_settings) + + @override + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + # 1. Check PATH for system-installed binary + system_binary = shutil.which("shader-language-server") + if system_binary: + log.info(f"Using system-installed shader-language-server at {system_binary}") + return system_binary + + # 2. Try to download pre-built binary from GitHub releases + version = self._custom_settings.get("version", _DEFAULT_VERSION) + tag = f"v{version}" + base_url = f"{_GITHUB_RELEASE_BASE}/{tag}" + + # macOS has no pre-built binaries; build from source via cargo install + cargo_install_cmd = ["cargo", "install", "shader_language_server", "--version", version, "--locked", "--root", "."] + + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="shader-language-server", + description="shader-language-server for Windows (x64)", + url=f"{base_url}/shader-language-server-x86_64-pc-windows-msvc.zip", + platform_id="win-x64", + archive_type="zip", + binary_name="shader-language-server.exe", + sha256=_hlsl_sha(version, "shader-language-server-x86_64-pc-windows-msvc.zip"), + allowed_hosts=_HLSL_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="shader-language-server", + description="shader-language-server for Linux (x64)", + url=f"{base_url}/shader-language-server-x86_64-unknown-linux-gnu.zip", + platform_id="linux-x64", + archive_type="zip", + binary_name="shader-language-server", + sha256=_hlsl_sha(version, "shader-language-server-x86_64-unknown-linux-gnu.zip"), + allowed_hosts=_HLSL_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="shader-language-server", + description="shader-language-server for Windows (ARM64)", + url=f"{base_url}/shader-language-server-aarch64-pc-windows-msvc.zip", + platform_id="win-arm64", + archive_type="zip", + binary_name="shader-language-server.exe", + sha256=_hlsl_sha(version, "shader-language-server-aarch64-pc-windows-msvc.zip"), + allowed_hosts=_HLSL_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="shader-language-server", + description="shader-language-server for macOS (x64) - built from source", + command=cargo_install_cmd, + platform_id="osx-x64", + binary_name="bin/shader-language-server", + ), + RuntimeDependency( + id="shader-language-server", + description="shader-language-server for macOS (ARM64) - built from source", + command=cargo_install_cmd, + platform_id="osx-arm64", + binary_name="bin/shader-language-server", + ), + ] + ) + + try: + dep = deps.get_single_dep_for_current_platform() + except RuntimeError: + dep = None + + if dep is None: + raise FileNotFoundError( + "shader-language-server is not installed and no auto-install is available for your platform.\n" + "Please install it using one of the following methods:\n" + " cargo: cargo install shader_language_server --locked\n" + " GitHub: Download from https://github.com/antaalt/shader-sense/releases\n" + "On macOS, install the Rust toolchain (https://rustup.rs) and Serena will build from source automatically.\n" + "See https://github.com/antaalt/shader-sense for more details." + ) + + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = "shader-language-server" if version == _INITIAL_VERSION else f"shader-language-server-{version}" + install_dir = os.path.join(self._ls_resources_dir, ls_dirname) + executable_path = deps.binary_path(install_dir) + + if not os.path.exists(executable_path): + log.info(f"shader-language-server not found. Downloading from {dep.url}") + _ = deps.install(install_dir) + + if not os.path.exists(executable_path): + raise FileNotFoundError(f"shader-language-server not found at {executable_path}") + + os.chmod(executable_path, 0o755) + return executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "--stdio"] + + def _create_base_initialize_params(self) -> dict: + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": { + "dynamicRegistration": True, + "completionItem": {"snippetSupport": True}, + }, + "definition": {"dynamicRegistration": True}, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "parameterInformation": {"labelOffsetSupport": True}, + }, + }, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "formatting": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "configuration": True, + }, + }, + } + return initialize_params + + @override + def _start_server(self) -> None: + def do_nothing(params: Any) -> None: + return + + def on_log_message(params: Any) -> None: + message = params.get("message", "") if isinstance(params, dict) else str(params) + log.info(f"shader-language-server: {message}") + + def on_configuration_request(params: Any) -> list[dict]: + """Respond to workspace/configuration requests. + + shader-language-server requests config with section 'shader-validator'. + Return empty config to use defaults. + """ + items = params.get("items", []) if isinstance(params, dict) else [] + return [{}] * len(items) + + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_request("workspace/configuration", on_configuration_request) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("window/logMessage", on_log_message) + + log.info("Starting shader-language-server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request") + init_response = self.server.send.initialize(initialize_params) + + capabilities = init_response.get("capabilities", {}) + log.info(f"Initialize response capabilities: {list(capabilities.keys())}") + assert "textDocumentSync" in capabilities, "shader-language-server must support textDocumentSync" + if "documentSymbolProvider" not in capabilities: + log.warning("shader-language-server does not advertise documentSymbolProvider") + if "definitionProvider" not in capabilities: + log.warning("shader-language-server does not advertise definitionProvider") + + self.server.notify.initialized({}) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + """Ignore Unity-specific directories that contain no user-authored shaders.""" + return super().is_ignored_dirname(dirname) or dirname in {"Library", "Temp", "Logs", "obj", "Packages"} diff --git a/src/solidlsp/language_servers/intelephense.py b/src/solidlsp/language_servers/intelephense.py new file mode 100644 index 0000000..6f68c33 --- /dev/null +++ b/src/solidlsp/language_servers/intelephense.py @@ -0,0 +1,205 @@ +""" +Provides PHP specific instantiation of the LanguageServer class using Intelephense. +""" + +import logging +import os +import shutil +from time import sleep + +from overrides import override + +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_utils import PlatformId, PlatformUtils +from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink +from solidlsp.settings import SolidLSPSettings + +from ..lsp_protocol_handler import lsp_types +from .common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command + +log = logging.getLogger(__name__) + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_INTELEPHENSE_VERSION = "1.14.4" +DEFAULT_INTELEPHENSE_VERSION = "1.14.4" + + +class Intelephense(SolidLanguageServer): + """ + Provides PHP specific instantiation of the LanguageServer class using Intelephense. + + You can pass the following entries in ls_specific_settings["php"]: + - maxMemory: sets intelephense.maxMemory + - maxFileSize: sets intelephense.files.maxSize + - ignore_vendor: whether or ignore directories named "vendor" (default: true) + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in self._ignored_dirnames + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """ + Setup runtime dependencies for Intelephense and return the path to the executable. + """ + platform_id = PlatformUtils.get_platform_id() + + valid_platforms = [ + PlatformId.LINUX_x64, + PlatformId.LINUX_arm64, + PlatformId.OSX, + PlatformId.OSX_x64, + PlatformId.OSX_arm64, + PlatformId.WIN_x64, + PlatformId.WIN_arm64, + ] + assert platform_id in valid_platforms, f"Platform {platform_id} is not supported by Intelephense at the moment" + + # Verify both node and npm are installed + is_node_installed = shutil.which("node") is not None + assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." + is_npm_installed = shutil.which("npm") is not None + assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." + intelephense_version = self._custom_settings.get("intelephense_version", DEFAULT_INTELEPHENSE_VERSION) + npm_registry = self._custom_settings.get("npm_registry") + + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = "php-lsp" if intelephense_version == INITIAL_INTELEPHENSE_VERSION else f"php-lsp-{intelephense_version}" + intelephense_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname) + os.makedirs(intelephense_ls_dir, exist_ok=True) + intelephense_executable_path = os.path.join(intelephense_ls_dir, "node_modules", ".bin", "intelephense") + if not os.path.exists(intelephense_executable_path): + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="intelephense", + command=build_npm_install_command("intelephense", intelephense_version, npm_registry), + platform_id="any", + ) + ] + ) + deps.install(intelephense_ls_dir) + + assert os.path.exists(intelephense_executable_path), ( + f"intelephense executable not found at {intelephense_executable_path}, something went wrong." + ) + + return intelephense_executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "--stdio"] + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + super().__init__(config, repository_root_path, None, "php", solidlsp_settings) + self.request_id = 0 + + # For PHP projects, we should ignore: + # - node_modules: if the project has JavaScript components + # - cache: commonly used for caching + # - (configurable) vendor: third-party dependencies managed by Composer + self._ignored_dirnames = {"node_modules", "cache"} + if self._custom_settings.get("ignore_vendor", True): + self._ignored_dirnames.add("vendor") + log.info(f"Ignoring the following directories for PHP projects: {', '.join(sorted(self._ignored_dirnames))}") + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialization params for the Intelephense Language Server. + """ + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + } + initialization_options = {} + # Add license key if provided via environment variable + license_key = os.environ.get("INTELEPHENSE_LICENSE_KEY") + if license_key: + initialization_options["licenceKey"] = license_key + + max_memory = self._custom_settings.get("maxMemory") + max_file_size = self._custom_settings.get("maxFileSize") + if max_memory is not None: + initialization_options["intelephense.maxMemory"] = max_memory + if max_file_size is not None: + initialization_options["intelephense.files.maxSize"] = max_file_size + + initialize_params["initializationOptions"] = initialization_options + return initialize_params + + def _start_server(self) -> None: + """Start Intelephense server process""" + + def register_capability_handler(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting Intelephense server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.info("After sent initialize params") + + # Verify server capabilities + capabilities = init_response["capabilities"] + assert "textDocumentSync" in capabilities + assert "completionProvider" in capabilities + assert "definitionProvider" in capabilities + assert "documentSymbolProvider" in capabilities, "Server must support document symbols" + + self.server.notify.initialized({}) + + # Intelephense server is typically ready immediately after initialization + # TODO: This is probably incorrect; the server does send an initialized notification, which we could wait for! + + @override + # For some reason, the LS may need longer to process this, so we just retry + def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None: + # TODO: The LS doesn't return references contained in other files if it doesn't sleep. This is + # despite the LS having processed requests already. I don't know what causes this, but sleeping + # one second helps. It may be that sleeping only once is enough but that's hard to reliably test. + # May be related to the time it takes to read the files or something like that. + # The sleeping doesn't seem to be needed on all systems + sleep(1) + return super()._send_references_request(relative_file_path, line, column) + + @override + def _send_definition_request(self, definition_params: DefinitionParams) -> Definition | list[LocationLink] | None: + # TODO: same as above, also only a problem if the definition is in another file + sleep(1) + return super()._send_definition_request(definition_params) diff --git a/src/solidlsp/language_servers/jedi_server.py b/src/solidlsp/language_servers/jedi_server.py new file mode 100644 index 0000000..7ad3b22 --- /dev/null +++ b/src/solidlsp/language_servers/jedi_server.py @@ -0,0 +1,180 @@ +""" +Provides Python specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Python. +""" + +import logging +import threading + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class JediServer(SolidLanguageServer): + """ + Provides Python specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Python. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a JediServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd="jedi-language-server", cwd=repository_root_path), + "python", + solidlsp_settings, + ) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"] + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Jedi Language Server. + """ + initialize_params = { + "locale": "en", + # Note: this is not necessarily the minimal set of capabilities... + "capabilities": { + "workspace": { + "applyEdit": True, + "workspaceEdit": { + "documentChanges": True, + "resourceOperations": ["create", "rename", "delete"], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": True, + "changeAnnotationSupport": {"groupsOnLabel": True}, + }, + "configuration": True, + "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "tagSupport": {"valueSet": [1]}, + "resolveSupport": {"properties": ["location.range"]}, + }, + "workspaceFolders": True, + "fileOperations": { + "dynamicRegistration": True, + "didCreate": True, + "didRename": True, + "didDelete": True, + "willCreate": True, + "willRename": True, + "willDelete": True, + }, + "inlineValue": {"refreshSupport": True}, + "inlayHint": {"refreshSupport": True}, + "diagnostics": {"refreshSupport": True}, + }, + "textDocument": { + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [1, 2]}, + "codeDescriptionSupport": True, + "dataSupport": True, + }, + "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + "activeParameterSupport": True, + }, + "contextSupport": True, + }, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentHighlight": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "hierarchicalDocumentSymbolSupport": True, + "tagSupport": {"valueSet": [1]}, + "labelSupport": True, + }, + "documentLink": {"dynamicRegistration": True, "tooltipSupport": True}, + "typeDefinition": {"dynamicRegistration": True, "linkSupport": True}, + "implementation": {"dynamicRegistration": True, "linkSupport": True}, + "declaration": {"dynamicRegistration": True, "linkSupport": True}, + "selectionRange": {"dynamicRegistration": True}, + "callHierarchy": {"dynamicRegistration": True}, + "linkedEditingRange": {"dynamicRegistration": True}, + "typeHierarchy": {"dynamicRegistration": True}, + "inlineValue": {"dynamicRegistration": True}, + "inlayHint": { + "dynamicRegistration": True, + "resolveSupport": {"properties": ["tooltip", "textEdits", "label.tooltip", "label.location", "label.command"]}, + }, + "diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False}, + }, + "notebookDocument": {"synchronization": {"dynamicRegistration": True, "executionSummarySupport": True}}, + "experimental": { + "serverStatusNotification": True, + "openServerLogs": True, + }, + }, + # See https://github.com/pappasam/jedi-language-server?tab=readme-ov-file + # We use the default options except for maxSymbols, where 0 means no limit + "initializationOptions": { + "workspace": { + "symbols": {"ignoreFolders": [".nox", ".tox", ".venv", "__pycache__", "venv"], "maxSymbols": 0}, + }, + }, + "trace": "verbose", + } + return initialize_params + + def _start_server(self) -> None: + """ + Starts the JEDI Language Server + """ + completions_available = threading.Event() + + def execute_client_command_handler(params: dict) -> list: + return [] + + def do_nothing(params: dict) -> None: + return + + def check_experimental_status(params: dict) -> None: + if params["quiescent"] == True: + completions_available.set() + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_notification("language/status", do_nothing) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + self.server.on_notification("experimental/serverStatus", check_experimental_status) + + log.info("Starting jedi-language-server server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 # type: ignore + assert "completionProvider" in init_response["capabilities"] + assert init_response["capabilities"]["completionProvider"] == { + "triggerCharacters": [".", "'", '"'], + "resolveProvider": True, + } + + self.server.notify.initialized({}) diff --git a/src/solidlsp/language_servers/json_language_server.py b/src/solidlsp/language_servers/json_language_server.py new file mode 100644 index 0000000..654f2cc --- /dev/null +++ b/src/solidlsp/language_servers/json_language_server.py @@ -0,0 +1,163 @@ +""" +Provides JSON specific instantiation of the LanguageServer class using vscode-json-languageserver. +Contains various configurations and settings specific to JSON files. +""" + +import logging +import os +import shutil +from typing import Any + +from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_JSON_LANGUAGE_SERVER_VERSION = "1.3.4" +DEFAULT_JSON_LANGUAGE_SERVER_VERSION = "1.3.4" + + +class JsonLanguageServer(SolidLanguageServer): + """ + Provides JSON specific instantiation of the LanguageServer class using vscode-json-languageserver. + Contains various configurations and settings specific to JSON files. + + Note: Cross-file references are not supported for JSON (the language server only provides + document symbols and hover). JSON is useful for getting a structured overview of JSON files + and navigating their contents. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a JsonLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + super().__init__( + config, + repository_root_path, + None, + "json", + solidlsp_settings, + ) + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """ + Setup runtime dependencies for JSON Language Server and return the path to the executable. + """ + is_node_installed = shutil.which("node") is not None + assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." + is_npm_installed = shutil.which("npm") is not None + assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." + + json_language_server_version = self._custom_settings.get("json_language_server_version", DEFAULT_JSON_LANGUAGE_SERVER_VERSION) + npm_registry = self._custom_settings.get("npm_registry") + + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="vscode-json-languageserver", + description="vscode-json-languageserver package (Microsoft)", + command=build_npm_install_command("vscode-json-languageserver", json_language_server_version, npm_registry), + platform_id="any", + ), + ] + ) + + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = ( + "json-lsp" + if json_language_server_version == INITIAL_JSON_LANGUAGE_SERVER_VERSION + else f"json-lsp-{json_language_server_version}" + ) + json_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname) + json_executable_path = os.path.join(json_ls_dir, "node_modules", ".bin", "vscode-json-languageserver") + + if os.name == "nt": + json_executable_path += ".cmd" + + if not os.path.exists(json_executable_path): + log.info(f"JSON Language Server executable not found at {json_executable_path}. Installing...") + deps.install(json_ls_dir) + log.info("JSON language server dependencies installed successfully") + + if not os.path.exists(json_executable_path): + raise FileNotFoundError( + f"vscode-json-languageserver executable not found at {json_executable_path}, something went wrong with the installation." + ) + + return json_executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "--stdio"] + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the JSON Language Server. + """ + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """ + Starts the JSON Language Server, waits for the server to be ready and yields the LanguageServer instance. + """ + + def register_capability_handler(params: Any) -> None: + return + + def do_nothing(params: Any) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting JSON server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.debug(f"Received initialize response from JSON server: {init_response}") + + if "documentSymbolProvider" in init_response.get("capabilities", {}): + log.info("JSON server supports document symbols") + else: + log.warning("Warning: JSON server does not report document symbol support") + + self.server.notify.initialized({}) + + log.info("JSON server initialization complete") diff --git a/src/solidlsp/language_servers/julia_server.py b/src/solidlsp/language_servers/julia_server.py new file mode 100644 index 0000000..cde5298 --- /dev/null +++ b/src/solidlsp/language_servers/julia_server.py @@ -0,0 +1,209 @@ +import logging +import os +import platform +import shutil +import subprocess +from typing import Any + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticTag +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class JuliaLanguageServer(SolidLanguageServer): + """ + Language server implementation for Julia using LanguageServer.jl. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + julia_executable = self._setup_runtime_dependency() # PASS LOGGER + julia_code = "using LanguageServer; runserver()" + + julia_ls_cmd: str | list[str] + if platform.system() == "Windows": + # On Windows, pass as list (Serena handles shell=True differently) + julia_ls_cmd = [julia_executable, "--startup-file=no", "--history-file=no", "-e", julia_code, repository_root_path] + else: + # On Linux/macOS, build shell-escaped string + import shlex + + julia_ls_cmd = ( + f"{shlex.quote(julia_executable)} " + f"--startup-file=no " + f"--history-file=no " + f"-e {shlex.quote(julia_code)} " + f"{shlex.quote(repository_root_path)}" + ) + + log.info(f"[JULIA DEBUG] Command: {julia_ls_cmd}") + + super().__init__( + config, repository_root_path, ProcessLaunchInfo(cmd=julia_ls_cmd, cwd=repository_root_path), "julia", solidlsp_settings + ) + + @staticmethod + def _setup_runtime_dependency() -> str: + """ + Check if the Julia runtime is available and return its full path. + Raises RuntimeError with a helpful message if the dependency is missing. + """ + # First check if julia is in PATH + julia_path = shutil.which("julia") + + # If not found in PATH, check common installation locations + if julia_path is None: + common_locations = [ + os.path.expanduser("~/.juliaup/bin/julia"), + os.path.expanduser("~/.julia/bin/julia"), + "/usr/local/bin/julia", + "/usr/bin/julia", + ] + + for location in common_locations: + if os.path.isfile(location) and os.access(location, os.X_OK): + julia_path = location + break + + if julia_path is None: + raise RuntimeError( + "Julia is not installed or not in your PATH. " + "Please install Julia from https://julialang.org/downloads/ and ensure it is accessible. " + f"Checked locations: {common_locations}" + ) + + # Check if LanguageServer.jl is installed. + # stdin=DEVNULL: when Serena runs over the stdio MCP transport, the JSON-RPC + # channel *is* the process stdin/stdout. Without this, the Julia child inherits + # Serena's stdin (the MCP pipe) and clobbers it, killing the server right after + # initialize ("tools fetch failed"). See https://github.com/oraios/serena/issues/1577 + check_cmd = [julia_path, "-e", "using LanguageServer"] + try: + result = subprocess.run(check_cmd, check=False, capture_output=True, text=True, timeout=10, stdin=subprocess.DEVNULL) + if result.returncode != 0: + # LanguageServer.jl not found, install it + JuliaLanguageServer._install_language_server(julia_path) + except subprocess.TimeoutExpired: + # Assume it needs installation + JuliaLanguageServer._install_language_server(julia_path) + + return julia_path + + @staticmethod + def _install_language_server(julia_path: str) -> None: + """Install LanguageServer.jl package.""" + log.info("LanguageServer.jl not found. Installing... (this may take a minute)") + + install_cmd = [julia_path, "-e", 'using Pkg; Pkg.add("LanguageServer")'] + + try: + result = subprocess.run( + install_cmd, check=False, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL + ) # 5 minutes for installation + + if result.returncode == 0: + log.info("LanguageServer.jl installed successfully!") + else: + raise RuntimeError(f"Failed to install LanguageServer.jl: {result.stderr}") + except subprocess.TimeoutExpired: + raise RuntimeError( + "LanguageServer.jl installation timed out. Please install manually: julia -e 'using Pkg; Pkg.add(\"LanguageServer\")'" + ) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + """Define language-specific directories to ignore for Julia projects.""" + return super().is_ignored_dirname(dirname) or dirname in [".julia", "build", "dist"] + + @override + def _supports_pull_diagnostics(self) -> bool: + # LanguageServer.jl raises an unhandled error on textDocument/diagnostic which crashes + # the server process. Force the published-diagnostics path instead. + return False + + @override + def _get_published_diagnostics_wait_timeout(self, pull_diagnostics_failed: bool) -> float: + # LanguageServer.jl needs significant warm-up after init before it emits the first + # publishDiagnostics: workspace/configuration round-trip + initial linter pass typically + # land 13-15s after the file is opened in a cold LS. Module-scoped fixtures usually have + # the cache pre-populated and return immediately, so this ceiling only kicks in for cold + # runs (standalone test invocations, CI cold start). + return 30.0 + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Julia Language Server. + """ + initialize_params: dict = { + "capabilities": { + # workspace.configuration MUST be true: LanguageServer.jl pulls all julia.lint.* + # settings via workspace/configuration after initialize. Without it, the server + # skips that codepath and runlinter stays disabled, so no diagnostics ever arrive. + "workspace": {"workspaceFolders": True, "configuration": True}, + "textDocument": { + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [DiagnosticTag.Unnecessary, DiagnosticTag.Deprecated]}, + "codeDescriptionSupport": True, + "dataSupport": True, + }, + "synchronization": {"dynamicRegistration": True, "didSave": True}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": {"dynamicRegistration": True}, + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """Start the LanguageServer.jl server process.""" + + def do_nothing(params: Any) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def workspace_configuration(params: dict) -> list[Any]: + """ + Respond to LanguageServer.jl's workspace/configuration pull request. + + The server requests a flat list of julia.* settings (in particular julia.lint.run, + position 11 in the response). Returning ``None`` for each entry causes the server to + apply its built-in defaults — including ``runlinter = true`` — which is what we want + for diagnostics to be produced. + """ + items = params.get("items", []) if isinstance(params, dict) else [] + return [None for _ in items] + + self.server.on_request("workspace/configuration", workspace_configuration) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting LanguageServer.jl server process") + self.server.start() + + initialize_params = self._create_initialize_params() + log.info("Sending initialize request to Julia Language Server") + + init_response = self.server.send.initialize(initialize_params) + assert "definitionProvider" in init_response["capabilities"] + assert "referencesProvider" in init_response["capabilities"] + assert "documentSymbolProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + + # nudge the server to pull config: LanguageServer.jl only invokes request_julia_config + # from its workspace/didChangeConfiguration handler, so without this notification the + # server never asks us for julia.lint.run and runlinter stays disabled (no diagnostics). + self.server.notify.workspace_did_change_configuration({"settings": {}}) + + log.info("Julia Language Server is initialized and ready.") diff --git a/src/solidlsp/language_servers/kotlin_language_server.py b/src/solidlsp/language_servers/kotlin_language_server.py new file mode 100644 index 0000000..45901ed --- /dev/null +++ b/src/solidlsp/language_servers/kotlin_language_server.py @@ -0,0 +1,523 @@ +""" +Provides Kotlin specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Kotlin. + +You can configure the following options in ls_specific_settings (in serena_config.yml): + + ls_specific_settings: + kotlin: + ls_path: '/path/to/kotlin-lsp.sh' # Custom path to Kotlin Language Server executable + kotlin_lsp_version: '261.13587.0' # Kotlin Language Server version (default: current bundled version) + jvm_options: '-Xmx2G' # JVM options for Kotlin Language Server (default: -Xmx2G) + +Example configuration for large projects: + + ls_specific_settings: + kotlin: + jvm_options: '-Xmx4G -XX:+UseG1GC' +""" + +import logging +import os +import pathlib +import stat +import threading + +from overrides import override + +from solidlsp.ls import ( + LanguageServerDependencyProvider, + LanguageServerDependencyProviderSinglePath, + SolidLanguageServer, +) +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_utils import FileUtils, PlatformUtils +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +# Default JVM options for Kotlin Language Server +# -Xmx2G: 2GB heap is sufficient for most projects; override via ls_specific_settings for large codebases +DEFAULT_KOTLIN_JVM_OPTIONS = "-Xmx2G" + +KOTLIN_LSP_ALLOWED_HOSTS = ("download-cdn.jetbrains.com",) + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_KOTLIN_LSP_VERSION = "261.13587.0" +INITIAL_KOTLIN_LSP_SHA256_BY_SUFFIX = { + "win-x64": "2806c2bd4810bd8e7ccc27d8c0ca4a5232a1c4f26ea1f4ba40e578b60860ccad", + "linux-x64": "dc0ed2e70cb0d61fdabb26aefce8299b7a75c0dcfffb9413715e92caec6e83ec", + "linux-aarch64": "d1dceb000fe06c5e2c30b95e7f4ab01d05101bd03ed448167feeb544a9f1d651", + "mac-x64": "a3972f27229eba2c226060e54baea1c958c82c326dfc971bf53f72a74d0564a3", + "mac-aarch64": "d4ea28b22b29cf906fe16d23698a8468f11646a6a66dcb15584f306aaefbee6c", +} +DEFAULT_KOTLIN_LSP_VERSION = "261.13587.0" +DEFAULT_KOTLIN_LSP_SHA256_BY_SUFFIX = { + "win-x64": "2806c2bd4810bd8e7ccc27d8c0ca4a5232a1c4f26ea1f4ba40e578b60860ccad", + "linux-x64": "dc0ed2e70cb0d61fdabb26aefce8299b7a75c0dcfffb9413715e92caec6e83ec", + "linux-aarch64": "d1dceb000fe06c5e2c30b95e7f4ab01d05101bd03ed448167feeb544a9f1d651", + "mac-x64": "a3972f27229eba2c226060e54baea1c958c82c326dfc971bf53f72a74d0564a3", + "mac-aarch64": "d4ea28b22b29cf906fe16d23698a8468f11646a6a66dcb15584f306aaefbee6c", +} + + +def _kotlin_lsp_sha(version: str, kotlin_suffix: str) -> str | None: + if version == INITIAL_KOTLIN_LSP_VERSION: + return INITIAL_KOTLIN_LSP_SHA256_BY_SUFFIX.get(kotlin_suffix) + if version == DEFAULT_KOTLIN_LSP_VERSION: + return DEFAULT_KOTLIN_LSP_SHA256_BY_SUFFIX.get(kotlin_suffix) + return None + + +# Platform-specific Kotlin LSP download suffixes +PLATFORM_KOTLIN_SUFFIX = { + "win-x64": "win-x64", + "linux-x64": "linux-x64", + "linux-arm64": "linux-aarch64", + "osx-x64": "mac-x64", + "osx-arm64": "mac-aarch64", +} + + +class KotlinLanguageServer(SolidLanguageServer): + """ + Provides Kotlin specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Kotlin. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a Kotlin Language Server instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + super().__init__( + config, + repository_root_path, + None, + "kotlin", + solidlsp_settings, + ) + + # Indexing synchronisation: starts SET (= already done), cleared if the server + # sends window/workDoneProgress/create (async-indexing servers like KLS v261+), + # set again once all progress tokens have ended. + self._indexing_complete = threading.Event() + self._indexing_complete.set() + self._active_progress_tokens: set[str] = set() + self._progress_lock = threading.Lock() + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def __init__(self, custom_settings: SolidLSPSettings.CustomLSSettings, ls_resources_dir: str): + super().__init__(custom_settings, ls_resources_dir) + + def _get_or_install_core_dependency(self) -> str: + """ + Setup runtime dependencies for Kotlin Language Server and return the path to the executable script. + """ + platform_id = PlatformUtils.get_platform_id() + + # Verify platform support + assert platform_id.value.startswith("win-") or platform_id.value.startswith("linux-") or platform_id.value.startswith("osx-"), ( + "Only Windows, Linux and macOS platforms are supported for Kotlin in multilspy at the moment" + ) + + kotlin_suffix = PLATFORM_KOTLIN_SUFFIX.get(platform_id.value) + assert kotlin_suffix, f"Unsupported platform for Kotlin LSP: {platform_id.value}" + + # Setup paths for dependencies; legacy unversioned dir reserved for INITIAL only + kotlin_lsp_version = self._custom_settings.get("kotlin_lsp_version", DEFAULT_KOTLIN_LSP_VERSION) + ls_dirname = ( + "kotlin_language_server" + if kotlin_lsp_version == INITIAL_KOTLIN_LSP_VERSION + else f"kotlin_language_server-{kotlin_lsp_version}" + ) + static_dir = os.path.join(self._ls_resources_dir, ls_dirname) + os.makedirs(static_dir, exist_ok=True) + + # Setup Kotlin Language Server + kotlin_script_name = "kotlin-lsp.cmd" if platform_id.value.startswith("win-") else "kotlin-lsp.sh" + kotlin_script = os.path.join(static_dir, kotlin_script_name) + + if not os.path.exists(kotlin_script): + kotlin_url = f"https://download-cdn.jetbrains.com/kotlin-lsp/{kotlin_lsp_version}/kotlin-lsp-{kotlin_lsp_version}-{kotlin_suffix}.zip" + expected_sha256 = _kotlin_lsp_sha(kotlin_lsp_version, kotlin_suffix) + log.info("Downloading Kotlin Language Server...") + FileUtils.download_and_extract_archive_verified( + kotlin_url, + static_dir, + "zip", + expected_sha256=expected_sha256, + allowed_hosts=KOTLIN_LSP_ALLOWED_HOSTS, + ) + + if os.path.exists(kotlin_script) and not platform_id.value.startswith("win-"): + os.chmod( + kotlin_script, + stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH, + ) + + if not os.path.exists(kotlin_script): + raise FileNotFoundError(f"Kotlin Language Server script not found at {kotlin_script}") + + log.info(f"Using Kotlin Language Server script at {kotlin_script}") + return kotlin_script + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "--stdio"] + + def create_launch_command_env(self) -> dict[str, str]: + """Provides JAVA_HOME and JVM options for the Kotlin Language Server process.""" + env: dict[str, str] = {} + + # Get JVM options from settings or use default + # Note: an explicit empty string means "no JVM options", which is distinct from not setting the key + _sentinel = object() + custom_jvm_options = self._custom_settings.get("jvm_options", _sentinel) + if custom_jvm_options is not _sentinel: + jvm_options = custom_jvm_options + else: + jvm_options = DEFAULT_KOTLIN_JVM_OPTIONS + + env["JAVA_TOOL_OPTIONS"] = jvm_options + return env + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Kotlin Language Server. + """ + root_uri = pathlib.Path(self.repository_root_path).as_uri() + initialize_params = { + "locale": "en", + "capabilities": { + "workspace": { + "applyEdit": True, + "workspaceEdit": { + "documentChanges": True, + "resourceOperations": ["create", "rename", "delete"], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": True, + "changeAnnotationSupport": {"groupsOnLabel": True}, + }, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "tagSupport": {"valueSet": [1]}, + "resolveSupport": {"properties": ["location.range"]}, + }, + "codeLens": {"refreshSupport": True}, + "executeCommand": {"dynamicRegistration": True}, + "configuration": True, + "workspaceFolders": True, + "semanticTokens": {"refreshSupport": True}, + "fileOperations": { + "dynamicRegistration": True, + "didCreate": True, + "didRename": True, + "didDelete": True, + "willCreate": True, + "willRename": True, + "willDelete": True, + }, + "inlineValue": {"refreshSupport": True}, + "inlayHint": {"refreshSupport": True}, + "diagnostics": {"refreshSupport": True}, + }, + "textDocument": { + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [1, 2]}, + "codeDescriptionSupport": True, + "dataSupport": True, + }, + "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, + "completion": { + "dynamicRegistration": True, + "contextSupport": True, + "completionItem": { + "snippetSupport": False, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + "tagSupport": {"valueSet": [1]}, + "insertReplaceSupport": False, + "resolveSupport": {"properties": ["documentation", "detail", "additionalTextEdits"]}, + "insertTextModeSupport": {"valueSet": [1, 2]}, + "labelDetailsSupport": True, + }, + "insertTextMode": 2, + "completionItemKind": { + "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25] + }, + "completionList": {"itemDefaults": ["commitCharacters", "editRange", "insertTextFormat", "insertTextMode"]}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + "activeParameterSupport": True, + }, + "contextSupport": True, + }, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentHighlight": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "hierarchicalDocumentSymbolSupport": True, + "tagSupport": {"valueSet": [1]}, + "labelSupport": True, + }, + "codeAction": { + "dynamicRegistration": True, + "isPreferredSupport": True, + "disabledSupport": True, + "dataSupport": True, + "resolveSupport": {"properties": ["edit"]}, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + ] + } + }, + "honorsChangeAnnotations": False, + }, + "codeLens": {"dynamicRegistration": True}, + "formatting": {"dynamicRegistration": True}, + "rangeFormatting": {"dynamicRegistration": True}, + "onTypeFormatting": {"dynamicRegistration": True}, + "rename": { + "dynamicRegistration": True, + "prepareSupport": True, + "prepareSupportDefaultBehavior": 1, + "honorsChangeAnnotations": True, + }, + "documentLink": {"dynamicRegistration": True, "tooltipSupport": True}, + "typeDefinition": {"dynamicRegistration": True, "linkSupport": True}, + "implementation": {"dynamicRegistration": True, "linkSupport": True}, + "colorProvider": {"dynamicRegistration": True}, + "foldingRange": { + "dynamicRegistration": True, + "rangeLimit": 5000, + "lineFoldingOnly": True, + "foldingRangeKind": {"valueSet": ["comment", "imports", "region"]}, + "foldingRange": {"collapsedText": False}, + }, + "declaration": {"dynamicRegistration": True, "linkSupport": True}, + "selectionRange": {"dynamicRegistration": True}, + "callHierarchy": {"dynamicRegistration": True}, + "semanticTokens": { + "dynamicRegistration": True, + "tokenTypes": [ + "namespace", + "type", + "class", + "enum", + "interface", + "struct", + "typeParameter", + "parameter", + "variable", + "property", + "enumMember", + "event", + "function", + "method", + "macro", + "keyword", + "modifier", + "comment", + "string", + "number", + "regexp", + "operator", + "decorator", + ], + "tokenModifiers": [ + "declaration", + "definition", + "readonly", + "static", + "deprecated", + "abstract", + "async", + "modification", + "documentation", + "defaultLibrary", + ], + "formats": ["relative"], + "requests": {"range": True, "full": {"delta": True}}, + "multilineTokenSupport": False, + "overlappingTokenSupport": False, + "serverCancelSupport": True, + "augmentsSyntaxTokens": True, + }, + "linkedEditingRange": {"dynamicRegistration": True}, + "typeHierarchy": {"dynamicRegistration": True}, + "inlineValue": {"dynamicRegistration": True}, + "inlayHint": { + "dynamicRegistration": True, + "resolveSupport": {"properties": ["tooltip", "textEdits", "label.tooltip", "label.location", "label.command"]}, + }, + "diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False}, + }, + "window": { + "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, + "showDocument": {"support": True}, + "workDoneProgress": True, + }, + "general": { + "staleRequestSupport": { + "cancel": True, + "retryOnContentModified": [ + "textDocument/semanticTokens/full", + "textDocument/semanticTokens/range", + "textDocument/semanticTokens/full/delta", + ], + }, + "regularExpressions": {"engine": "ECMAScript", "version": "ES2020"}, + "markdown": {"parser": "marked", "version": "1.1.0"}, + "positionEncodings": ["utf-16"], + }, + "notebookDocument": {"synchronization": {"dynamicRegistration": True, "executionSummarySupport": True}}, + }, + "initializationOptions": { + "workspaceFolders": [root_uri], + "storagePath": None, + "codegen": {"enabled": False}, + "compiler": {"jvm": {"target": "default"}}, + "completion": {"snippets": {"enabled": True}}, + "diagnostics": {"enabled": True, "level": 4, "debounceTime": 250}, + "scripts": {"enabled": True, "buildScriptsEnabled": True}, + "indexing": {"enabled": True}, + "externalSources": {"useKlsScheme": False, "autoConvertToKotlin": False}, + "inlayHints": {"typeHints": False, "parameterHints": False, "chainedHints": False}, + "formatting": { + "formatter": "ktfmt", + "ktfmt": { + "style": "google", + "indent": 4, + "maxWidth": 100, + "continuationIndent": 8, + "removeUnusedImports": True, + }, + }, + }, + "trace": "off", + } + return initialize_params + + def _start_server(self) -> None: + """ + Starts the Kotlin Language Server + """ + + def execute_client_command_handler(params: dict) -> list: + return [] + + def do_nothing(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def work_done_progress_create(params: dict) -> dict: + """Handle window/workDoneProgress/create: the server is about to report async progress. + Clear the indexing-complete event so _start_server waits until all tokens finish. + This is triggered by newer KLS versions (261+) that index asynchronously after initialized. + Older versions (0.253.x) never send this, so _indexing_complete stays set and wait() returns instantly. + """ + token = str(params.get("token", "")) + log.debug(f"Kotlin LSP workDoneProgress/create: token={token!r}") + with self._progress_lock: + self._active_progress_tokens.add(token) + self._indexing_complete.clear() + return {} + + def progress_handler(params: dict) -> None: + """Track $/progress begin/end to detect when all async indexing work finishes.""" + token = str(params.get("token", "")) + value = params.get("value", {}) + kind = value.get("kind") + if kind == "begin": + title = value.get("title", "") + log.info(f"Kotlin LSP progress [{token}]: started - {title}") + with self._progress_lock: + self._active_progress_tokens.add(token) + self._indexing_complete.clear() + elif kind == "report": + pct = value.get("percentage") + msg = value.get("message", "") + pct_str = f" ({pct}%)" if pct is not None else "" + log.debug(f"Kotlin LSP progress [{token}]: {msg}{pct_str}") + elif kind == "end": + msg = value.get("message", "") + log.info(f"Kotlin LSP progress [{token}]: ended - {msg}") + with self._progress_lock: + self._active_progress_tokens.discard(token) + if not self._active_progress_tokens: + self._indexing_complete.set() + + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_notification("language/status", do_nothing) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_request("window/workDoneProgress/create", work_done_progress_create) + self.server.on_notification("$/progress", progress_handler) + self.server.on_notification("$/logTrace", do_nothing) + self.server.on_notification("$/cancelRequest", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + + log.info("Starting Kotlin server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + capabilities = init_response["capabilities"] + assert "textDocumentSync" in capabilities, "Server must support textDocumentSync" + assert "hoverProvider" in capabilities, "Server must support hover" + assert "completionProvider" in capabilities, "Server must support code completion" + assert "signatureHelpProvider" in capabilities, "Server must support signature help" + assert "definitionProvider" in capabilities, "Server must support go to definition" + assert "referencesProvider" in capabilities, "Server must support find references" + assert "documentSymbolProvider" in capabilities, "Server must support document symbols" + assert "workspaceSymbolProvider" in capabilities, "Server must support workspace symbols" + assert "semanticTokensProvider" in capabilities, "Server must support semantic tokens" + + self.server.notify.initialized({}) + + # Wait for any async indexing to complete. + # - Older KLS (0.253.x): indexing is synchronous inside `initialize`, no $/progress is sent, + # _indexing_complete stays SET -> wait() returns immediately. + # - Newer KLS (261+): server sends window/workDoneProgress/create after initialized, + # which clears the event; wait() blocks until all progress tokens end. + _INDEXING_TIMEOUT = 120.0 + log.info("Waiting for Kotlin LSP indexing to complete (if async)...") + if self._indexing_complete.wait(timeout=_INDEXING_TIMEOUT): + log.info("Kotlin LSP ready") + else: + log.warning("Kotlin LSP did not signal indexing completion within %.0fs; proceeding anyway", _INDEXING_TIMEOUT) + + @override + def _get_wait_time_for_cross_file_referencing(self) -> float: + """Small safety buffer since we already waited for indexing to complete in _start_server.""" + return 1.0 diff --git a/src/solidlsp/language_servers/lean4_language_server.py b/src/solidlsp/language_servers/lean4_language_server.py new file mode 100644 index 0000000..4f4af40 --- /dev/null +++ b/src/solidlsp/language_servers/lean4_language_server.py @@ -0,0 +1,161 @@ +""" +Provides Lean 4 specific instantiation of the LanguageServer class. +Uses the built-in Lean 4 language server (lean --server). +""" + +import logging +import shutil +import subprocess + +from overrides import override + +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class Lean4LanguageServer(SolidLanguageServer): + """ + Provides Lean 4 specific instantiation of the LanguageServer class. + Uses the built-in Lean 4 language server invoked via ``lean --server``. + Requires ``lean`` to be installed and available on PATH (typically via elan). + """ + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def __init__(self, custom_settings: SolidLSPSettings.CustomLSSettings, ls_resources_dir: str, repository_root_path: str): + super().__init__(custom_settings, ls_resources_dir) + self._repository_root_path = repository_root_path + + def _get_or_install_core_dependency(self) -> str: + lean_path = shutil.which("lean") + if lean_path is None: + raise RuntimeError( + "lean is not installed or not in PATH.\n" + "Please install Lean 4 via elan: https://github.com/leanprover/elan\n" + " curl https://raw.githubusercontent.com/leanprover/elan/master/elan-init.sh -sSf | sh\n" + "After installation, make sure 'lean' is available on your PATH." + ) + return lean_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "--server"] + + @override + def create_launch_command_env(self) -> dict[str, str]: + """Provides LEAN_PATH and LEAN_SRC_PATH from ``lake env`` for cross-file references.""" + env: dict[str, str] = {} + lake_path = shutil.which("lake") + if lake_path is None: + log.warning("lake not found on PATH; cross-file references may not work") + return env + try: + result = subprocess.run( + [lake_path, "env"], + check=False, + cwd=self._repository_root_path, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode == 0: + for line in result.stdout.splitlines(): + if "=" in line: + key, _, value = line.partition("=") + if key in ("LEAN_PATH", "LEAN_SRC_PATH"): + env[key] = value + log.info(f"Lake env: {key}={value}") + else: + log.warning(f"lake env failed (exit {result.returncode}): {result.stderr[:200]}") + except Exception as e: + log.warning(f"Failed to run lake env: {e}") + return env + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a Lean4LanguageServer instance. This class is not meant to be + instantiated directly. Use LanguageServer.create() instead. + """ + super().__init__( + config, + repository_root_path, + None, + "lean4", + solidlsp_settings, + ) + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir, self.repository_root_path) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [".lake", "build"] + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Lean 4 Language Server. + """ + initialize_params = { + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "completion": { + "dynamicRegistration": True, + "completionItem": { + "snippetSupport": True, + "documentationFormat": ["markdown", "plaintext"], + }, + }, + "publishDiagnostics": {"relatedInformation": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """Start the Lean 4 language server process.""" + + def register_capability_handler(_params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(_params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("$/lean/fileProgress", do_nothing) + + log.info("Starting Lean 4 language server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + capabilities = init_response.get("capabilities", {}) + log.info(f"Lean 4 LSP capabilities: {list(capabilities.keys())}") + + self.server.notify.initialized({}) + + @override + def _get_wait_time_for_cross_file_referencing(self) -> float: + """Lean 4 projects need time to compile and build cross-file references.""" + return 10.0 diff --git a/src/solidlsp/language_servers/lua_ls.py b/src/solidlsp/language_servers/lua_ls.py new file mode 100644 index 0000000..7b31cc7 --- /dev/null +++ b/src/solidlsp/language_servers/lua_ls.py @@ -0,0 +1,328 @@ +""" +Provides Lua specific instantiation of the LanguageServer class using lua-language-server. + +You can pass the following entries in ``ls_specific_settings["lua"]``: + - lua_language_server_version: Override the pinned lua-language-server version + downloaded by Serena (default: the bundled Serena version). +""" + +import logging +import platform +import shutil +from collections.abc import Hashable +from pathlib import Path + +from overrides import override + +from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_types import SymbolKind +from solidlsp.ls_utils import FileUtils +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +LUA_LS_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_LUA_LS_VERSION = "3.15.0" +INITIAL_LUA_LS_SHA256_BY_ASSET = { + "lua-language-server-3.15.0-linux-x64.tar.gz": "4877b874c52fb7587707898da9026cc3a6c854d9bbab115ef49ac4e6a1b88007", + "lua-language-server-3.15.0-linux-arm64.tar.gz": "7dff8edfed4f34cf6325ff384791287d95f9a8dd9615a5279c7c6af81cf8c45d", + "lua-language-server-3.15.0-darwin-x64.tar.gz": "01d28a31e264434e51662814a68f584af068393caecfa158c4df5f7fdc3ca2f7", + "lua-language-server-3.15.0-darwin-arm64.tar.gz": "050f5f493f65112afc116e31281a9f73918546782d3696485dc052724838f58b", + "lua-language-server-3.15.0-win32-x64.zip": "76a10c05e8c947a448f00a61acead4240484cd1e2e8c66d54401c67d99b77535", +} +DEFAULT_LUA_LS_VERSION = "3.15.0" +DEFAULT_LUA_LS_SHA256_BY_ASSET = { + "lua-language-server-3.15.0-linux-x64.tar.gz": "4877b874c52fb7587707898da9026cc3a6c854d9bbab115ef49ac4e6a1b88007", + "lua-language-server-3.15.0-linux-arm64.tar.gz": "7dff8edfed4f34cf6325ff384791287d95f9a8dd9615a5279c7c6af81cf8c45d", + "lua-language-server-3.15.0-darwin-x64.tar.gz": "01d28a31e264434e51662814a68f584af068393caecfa158c4df5f7fdc3ca2f7", + "lua-language-server-3.15.0-darwin-arm64.tar.gz": "050f5f493f65112afc116e31281a9f73918546782d3696485dc052724838f58b", + "lua-language-server-3.15.0-win32-x64.zip": "76a10c05e8c947a448f00a61acead4240484cd1e2e8c66d54401c67d99b77535", +} + + +def _lua_ls_sha(version: str, asset_name: str) -> str | None: + if version == INITIAL_LUA_LS_VERSION: + return INITIAL_LUA_LS_SHA256_BY_ASSET.get(asset_name) + if version == DEFAULT_LUA_LS_VERSION: + return DEFAULT_LUA_LS_SHA256_BY_ASSET.get(asset_name) + return None + + +def _lua_ls_install_dir(ls_resources_dir: str, version: str) -> Path: + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + if version == INITIAL_LUA_LS_VERSION: + return Path(ls_resources_dir) / "lua" + return Path(ls_resources_dir) / f"lua-{version}" + + +class LuaLanguageServer(SolidLanguageServer): + """ + Provides Lua specific instantiation of the LanguageServer class using lua-language-server. + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # For Lua projects, we should ignore: + # - .luarocks: package manager cache + # - lua_modules: local dependencies + # - node_modules: if the project has JavaScript components + return super().is_ignored_dirname(dirname) or dirname in [".luarocks", "lua_modules", "node_modules", "build", "dist", ".cache"] + + @staticmethod + def _get_lua_ls_path(solidlsp_settings: SolidLSPSettings | None = None) -> str | None: + """Get the path to lua-language-server executable.""" + # First check if it's in PATH + lua_ls = shutil.which("lua-language-server") + if lua_ls: + return lua_ls + + # Check common installation locations + home = Path.home() + possible_paths = [ + home / ".local" / "bin" / "lua-language-server", + Path("/usr/local/bin/lua-language-server"), + Path("/opt/lua-language-server/bin/lua-language-server"), + ] + + if solidlsp_settings is not None: + lua_settings = solidlsp_settings.get_ls_specific_settings(Language.LUA) + lua_ls_version = lua_settings.get("lua_language_server_version", DEFAULT_LUA_LS_VERSION) + ls_resource_dir = _lua_ls_install_dir(LuaLanguageServer.ls_resources_dir(solidlsp_settings), lua_ls_version) + possible_paths.extend( + [ + ls_resource_dir / "bin" / "lua-language-server", + ls_resource_dir / "bin" / "lua-language-server.exe", + ] + ) + + # Add Windows-specific paths + if platform.system() == "Windows": + possible_paths.extend( + [ + home / "AppData" / "Local" / "lua-language-server" / "bin" / "lua-language-server.exe", + ] + ) + + for path in possible_paths: + if path.exists(): + return str(path) + + return None + + @staticmethod + def _download_lua_ls(solidlsp_settings: SolidLSPSettings) -> str: + """Download and install lua-language-server if not present.""" + lua_settings = solidlsp_settings.get_ls_specific_settings(Language.LUA) + lua_ls_version = lua_settings.get("lua_language_server_version", DEFAULT_LUA_LS_VERSION) + system = platform.system() + machine = platform.machine().lower() + + # Map platform and architecture to download URL + if system == "Linux": + if machine in ["x86_64", "amd64"]: + download_name = f"lua-language-server-{lua_ls_version}-linux-x64.tar.gz" + elif machine in ["aarch64", "arm64"]: + download_name = f"lua-language-server-{lua_ls_version}-linux-arm64.tar.gz" + else: + raise RuntimeError(f"Unsupported Linux architecture: {machine}") + elif system == "Darwin": + if machine in ["x86_64", "amd64"]: + download_name = f"lua-language-server-{lua_ls_version}-darwin-x64.tar.gz" + elif machine in ["arm64", "aarch64"]: + download_name = f"lua-language-server-{lua_ls_version}-darwin-arm64.tar.gz" + else: + raise RuntimeError(f"Unsupported macOS architecture: {machine}") + elif system == "Windows": + if machine in ["amd64", "x86_64"]: + download_name = f"lua-language-server-{lua_ls_version}-win32-x64.zip" + else: + raise RuntimeError(f"Unsupported Windows architecture: {machine}") + else: + raise RuntimeError(f"Unsupported operating system: {system}") + + download_url = f"https://github.com/LuaLS/lua-language-server/releases/download/{lua_ls_version}/{download_name}" + + install_dir = _lua_ls_install_dir(LuaLanguageServer.ls_resources_dir(solidlsp_settings), lua_ls_version) + install_dir.mkdir(parents=True, exist_ok=True) + + log.info("Downloading lua-language-server from %s", download_url) + archive_type = "gztar" if download_name.endswith(".tar.gz") else "zip" + FileUtils.download_and_extract_archive_verified( + download_url, + str(install_dir), + archive_type, + expected_sha256=_lua_ls_sha(lua_ls_version, download_name), + allowed_hosts=LUA_LS_ALLOWED_HOSTS, + ) + + # Make executable on Unix systems + if system != "Windows": + lua_ls_path = install_dir / "bin" / "lua-language-server" + if lua_ls_path.exists(): + lua_ls_path.chmod(0o755) + return str(lua_ls_path) + else: + lua_ls_path = install_dir / "bin" / "lua-language-server.exe" + if lua_ls_path.exists(): + return str(lua_ls_path) + + raise RuntimeError("Failed to find lua-language-server executable after extraction") + + @staticmethod + def _setup_runtime_dependency(solidlsp_settings: SolidLSPSettings) -> str: + """ + Check if required Lua runtime dependencies are available. + Downloads lua-language-server if not present. + """ + lua_ls_path = LuaLanguageServer._get_lua_ls_path(solidlsp_settings) + + if not lua_ls_path: + log.info("lua-language-server not found. Downloading...") + lua_ls_path = LuaLanguageServer._download_lua_ls(solidlsp_settings) + log.info("lua-language-server installed at: %s", lua_ls_path) + + return lua_ls_path + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + lua_ls_path = self._setup_runtime_dependency(solidlsp_settings) + + super().__init__( + config, repository_root_path, ProcessLaunchInfo(cmd=lua_ls_path, cwd=repository_root_path), "lua", solidlsp_settings + ) + self.request_id = 0 + + @override + def _document_symbols_cache_fingerprint(self) -> Hashable: + normalize_symbol_name_version = 1 + return normalize_symbol_name_version + + @override + def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: + original_name = symbol["name"] + + if symbol.get("kind") not in (SymbolKind.Function, SymbolKind.Method): + return original_name + + if "." in original_name: + return original_name.rsplit(".", 1)[-1] + + if ":" in original_name: + return original_name.rsplit(":", 1)[-1] + + return original_name + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Lua Language Server. + """ + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "completion": { + "dynamicRegistration": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + }, + }, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + }, + }, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "configuration": True, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + }, + }, + "initializationOptions": { + # Lua Language Server specific options + "runtime": { + "version": "Lua 5.4", + "path": ["?.lua", "?/init.lua"], + }, + "diagnostics": { + "enable": True, + "globals": ["vim", "describe", "it", "before_each", "after_each"], # Common globals + }, + "workspace": { + "library": [], # Can be extended with project-specific libraries + "checkThirdParty": False, + "userThirdParty": [], + }, + "telemetry": { + "enable": False, + }, + "completion": { + "enable": True, + "callSnippet": "Both", + "keywordSnippet": "Both", + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """Start Lua Language Server process""" + + def register_capability_handler(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting Lua Language Server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + assert "documentSymbolProvider" in init_response["capabilities"] + assert "referencesProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + + # Lua Language Server is typically ready immediately after initialization + # (no need to wait for events) diff --git a/src/solidlsp/language_servers/luau_lsp.py b/src/solidlsp/language_servers/luau_lsp.py new file mode 100644 index 0000000..a23daed --- /dev/null +++ b/src/solidlsp/language_servers/luau_lsp.py @@ -0,0 +1,374 @@ +""" +Provides Luau specific instantiation of the LanguageServer class using luau-lsp. + +Luau is the programming language used by Roblox, derived from Lua 5.1 with +additional features like type annotations, string interpolation, and more. +This uses JohnnyMorganz/luau-lsp as the language server backend. + +Requirements: + - luau-lsp binary must be installed and available in PATH, + or it will be automatically downloaded from GitHub releases. + +Advanced settings via ls_specific_settings["luau"]: + - luau_lsp_version: Override the pinned luau-lsp version downloaded by Serena + (default: the bundled Serena version) + - platform: "roblox" (default) or "standard" + - roblox_security_level: "None", "PluginSecurity" (default), + "LocalUserSecurity", or "RobloxScriptSecurity" + +See: https://github.com/JohnnyMorganz/luau-lsp +""" + +import logging +import platform +import shutil +import threading +from pathlib import Path + +from overrides import override + +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_utils import FileUtils +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +LUAU_LSP_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_LUAU_LSP_VERSION = "1.63.0" +INITIAL_LUAU_LSP_SHA256_BY_ASSET = { + "luau-lsp-linux-x86_64.zip": "e4b633ad9a2c15437f60f9e721263f79aa0da606867d8458f0e159a325bf2db8", + "luau-lsp-linux-arm64.zip": "355be010f337a6772df6255c92e1fb28a59d194abe5c570453f4186472244355", + "luau-lsp-macos.zip": "01c1d6dd5fee27295b2968915dabb08c192192c46d9fe9c97bf31a130c96b8cb", + "luau-lsp-win64.zip": "eea596d47dc1c94a61ba1b78e6472bb4445bc3309780751515e6ab0a0abba57d", +} +DEFAULT_LUAU_LSP_VERSION = "1.63.0" +DEFAULT_LUAU_LSP_SHA256_BY_ASSET = { + "luau-lsp-linux-x86_64.zip": "e4b633ad9a2c15437f60f9e721263f79aa0da606867d8458f0e159a325bf2db8", + "luau-lsp-linux-arm64.zip": "355be010f337a6772df6255c92e1fb28a59d194abe5c570453f4186472244355", + "luau-lsp-macos.zip": "01c1d6dd5fee27295b2968915dabb08c192192c46d9fe9c97bf31a130c96b8cb", + "luau-lsp-win64.zip": "eea596d47dc1c94a61ba1b78e6472bb4445bc3309780751515e6ab0a0abba57d", +} + + +def _luau_lsp_sha(version: str, asset_name: str) -> str | None: + if version == INITIAL_LUAU_LSP_VERSION: + return INITIAL_LUAU_LSP_SHA256_BY_ASSET.get(asset_name) + if version == DEFAULT_LUAU_LSP_VERSION: + return DEFAULT_LUAU_LSP_SHA256_BY_ASSET.get(asset_name) + return None + + +# Luau built-in docs CDN +LUAU_DOCS_URL = "https://luau-lsp.pages.dev/api-docs/luau-en-us.json" + +# Roblox type definitions and API docs CDN +ROBLOX_DOCS_URL = "https://luau-lsp.pages.dev/api-docs/en-us.json" +SUPPORTED_PLATFORMS = {"roblox", "standard"} +SUPPORTED_ROBLOX_SECURITY_LEVELS = { + "None", + "PluginSecurity", + "LocalUserSecurity", + "RobloxScriptSecurity", +} +LUAU_DOCS_ALLOWED_HOSTS = ("luau-lsp.pages.dev",) + + +class LuauLanguageServer(SolidLanguageServer): + """ + Provides Luau specific instantiation of the LanguageServer class using luau-lsp. + Luau is the programming language used by Roblox (a typed superset of Lua 5.1). + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [ + "node_modules", + "Packages", # Wally packages + "DevPackages", # Wally dev packages + "roblox_packages", # Some Rojo projects + "build", + "dist", + ".cache", + ] + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + luau_lsp_path = shutil.which("luau-lsp") + if luau_lsp_path is not None: + return luau_lsp_path + return self._download_luau_lsp() + + def _create_launch_command(self, core_path: str) -> list[str]: + definitions_path, docs_path = self._resolve_support_files() + + cmd = [core_path, "lsp"] + if definitions_path is not None: + cmd.append(f"--definitions:@roblox={definitions_path}") + if docs_path is not None: + cmd.append(f"--docs={docs_path}") + return cmd + + def _download_luau_lsp(self) -> str: + luau_lsp_version = self._custom_settings.get("luau_lsp_version", DEFAULT_LUAU_LSP_VERSION) + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + install_dir = ( + Path(self._ls_resources_dir) + if luau_lsp_version == INITIAL_LUAU_LSP_VERSION + else Path(self._ls_resources_dir) / f"luau-lsp-{luau_lsp_version}" + ) + install_dir.mkdir(parents=True, exist_ok=True) + + binary_path = self._find_existing_binary(install_dir) + if binary_path is not None: + return binary_path + + asset_name = self._get_luau_lsp_asset_name() + download_url = f"https://github.com/JohnnyMorganz/luau-lsp/releases/download/{luau_lsp_version}/{asset_name}" + + log.info("Downloading luau-lsp %s from %s", luau_lsp_version, download_url) + FileUtils.download_and_extract_archive_verified( + download_url, + str(install_dir), + "zip", + expected_sha256=_luau_lsp_sha(luau_lsp_version, asset_name), + allowed_hosts=LUAU_LSP_ALLOWED_HOSTS, + ) + + binary_path = self._find_existing_binary(install_dir) + if binary_path is None: + raise RuntimeError("Failed to find luau-lsp executable after extraction") + + return binary_path + + def _resolve_support_files(self) -> tuple[str | None, str | None]: + platform_type = LuauLanguageServer._get_platform_type(self._custom_settings) + if platform_type == "standard": + return None, self._download_standard_docs() + + security_level = LuauLanguageServer._get_roblox_security_level(self._custom_settings) + return self._download_roblox_support_files(security_level) + + def _download_standard_docs(self) -> str | None: + install_dir = Path(self._ls_resources_dir) + install_dir.mkdir(parents=True, exist_ok=True) + + return self._download_auxiliary_file( + install_dir / "luau-en-us.json", + LUAU_DOCS_URL, + "Luau API docs", + ) + + def _download_roblox_support_files(self, security_level: str) -> tuple[str | None, str | None]: + install_dir = Path(self._ls_resources_dir) + install_dir.mkdir(parents=True, exist_ok=True) + + definitions_filename = f"globalTypes.{security_level}.d.luau" + definitions_path = self._download_auxiliary_file( + install_dir / definitions_filename, + f"https://luau-lsp.pages.dev/type-definitions/{definitions_filename}", + "Roblox type definitions", + ) + docs_path = self._download_auxiliary_file( + install_dir / "en-us.json", + ROBLOX_DOCS_URL, + "Roblox API docs", + ) + + return definitions_path, docs_path + + @staticmethod + def _download_auxiliary_file(path: Path, url: str, description: str) -> str | None: + if path.exists(): + return str(path) + + try: + log.info("Downloading %s from %s", description, url) + FileUtils.download_file_verified(url, str(path), allowed_hosts=LUAU_DOCS_ALLOWED_HOSTS) + return str(path) + except Exception as exc: + log.warning("Failed to download %s: %s", description, exc) + return None + + @classmethod + def _find_existing_binary(cls, install_dir: Path) -> str | None: + binary_name = cls._get_binary_name() + direct_path = install_dir / binary_name + if direct_path.exists(): + cls._ensure_executable_bit(direct_path) + return str(direct_path) + + for candidate in install_dir.rglob(binary_name): + if candidate.is_file(): + cls._ensure_executable_bit(candidate) + return str(candidate) + + return None + + @staticmethod + def _ensure_executable_bit(binary_path: Path) -> None: + if platform.system() != "Windows": + binary_path.chmod(0o755) + + @staticmethod + def _get_binary_name() -> str: + return "luau-lsp.exe" if platform.system() == "Windows" else "luau-lsp" + + @staticmethod + def _get_luau_lsp_asset_name() -> str: + system = platform.system() + machine = platform.machine().lower() + + if system == "Linux": + if machine in ["x86_64", "amd64"]: + return "luau-lsp-linux-x86_64.zip" + if machine in ["aarch64", "arm64"]: + return "luau-lsp-linux-arm64.zip" + raise RuntimeError( + f"Unsupported Linux architecture: {machine}. " + "luau-lsp only provides linux-x86_64 and linux-arm64 binaries. " + "Please build from source: https://github.com/JohnnyMorganz/luau-lsp" + ) + if system == "Darwin": + return "luau-lsp-macos.zip" + if system == "Windows": + return "luau-lsp-win64.zip" + raise RuntimeError(f"Unsupported operating system: {system}") + + @staticmethod + def _get_platform_type(custom_settings: SolidLSPSettings.CustomLSSettings) -> str: + platform_type = custom_settings.get("platform", "roblox") + if platform_type not in SUPPORTED_PLATFORMS: + raise ValueError(f"Unsupported Luau platform: {platform_type}. Expected one of: {', '.join(sorted(SUPPORTED_PLATFORMS))}") + return platform_type + + @staticmethod + def _get_roblox_security_level(custom_settings: SolidLSPSettings.CustomLSSettings) -> str: + security_level = custom_settings.get("roblox_security_level", "PluginSecurity") + if security_level not in SUPPORTED_ROBLOX_SECURITY_LEVELS: + raise ValueError( + f"Unsupported Luau Roblox security level: {security_level}. " + f"Expected one of: {', '.join(sorted(SUPPORTED_ROBLOX_SECURITY_LEVELS))}" + ) + return security_level + + @classmethod + def _get_workspace_configuration(cls, custom_settings: SolidLSPSettings.CustomLSSettings) -> dict[str, dict[str, str]]: + return {"platform": {"type": cls._get_platform_type(custom_settings)}} + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + super().__init__(config, repository_root_path, None, "luau", solidlsp_settings) + self.server_ready = threading.Event() + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Luau Language Server. + """ + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "completion": { + "dynamicRegistration": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + }, + }, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + }, + }, + "rename": {"dynamicRegistration": True, "prepareSupport": True}, + "callHierarchy": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "configuration": True, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + }, + }, + # luau-lsp initialization options + # These can be overridden via .luaurc in the project root + "initializationOptions": {}, + } + return initialize_params + + def _start_server(self) -> None: + """Start Luau Language Server process""" + + def register_capability_handler(params: dict) -> None: + return + + def workspace_configuration_handler(params: dict) -> list: + items = params.get("items", []) + config = self._get_workspace_configuration(self._custom_settings) + return [config for _ in items] + + def window_log_message(msg: dict) -> None: + message_text = msg.get("message", "") + log.info("LSP: window/logMessage: %s", message_text) + if "workspace ready" in message_text.lower() or "initialized" in message_text.lower(): + log.info("Luau language server signaled readiness") + self.server_ready.set() + + def do_nothing(params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_request("workspace/configuration", workspace_configuration_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting Luau Language Server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + assert "documentSymbolProvider" in init_response["capabilities"] + assert "referencesProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + + # Wait for luau-lsp to complete initial setup + log.info("Waiting for Luau language server to become ready...") + if self.server_ready.wait(timeout=5.0): + log.info("Luau language server ready") + else: + log.warning("Timeout waiting for Luau language server readiness, proceeding anyway") + self.server_ready.set() diff --git a/src/solidlsp/language_servers/marksman.py b/src/solidlsp/language_servers/marksman.py new file mode 100644 index 0000000..98696f5 --- /dev/null +++ b/src/solidlsp/language_servers/marksman.py @@ -0,0 +1,260 @@ +""" +Provides Markdown specific instantiation of the LanguageServer class using marksman. +Contains various configurations and settings specific to Markdown. +""" + +import logging +import os +from collections.abc import Hashable + +from overrides import override + +from solidlsp.ls import ( + DocumentSymbols, + LanguageServerDependencyProvider, + LanguageServerDependencyProviderSinglePath, + LSPFileBuffer, + SolidLanguageServer, +) +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation +from solidlsp.settings import SolidLSPSettings + +from .common import RuntimeDependency, RuntimeDependencyCollection + +log = logging.getLogger(__name__) + +MARKSMAN_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_MARKSMAN_VERSION = "2024-12-18" +INITIAL_MARKSMAN_SHA256_BY_PLATFORM = { + "linux-x64": "b9cb666c643dfd9b699811fdfc445ed4c56be65c1d878c21d46847f0d7b0e475", + "linux-arm64": "b8d6972a56f3f9b7bbbf7c77ef8998e3b66fa82fb03c01398e224144486c9e73", + "osx-x64": "7e18803966231a33ee107d0d26f69b41f2f0dc1332c52dd9729c2e29fb77be83", + "osx-arm64": "7e18803966231a33ee107d0d26f69b41f2f0dc1332c52dd9729c2e29fb77be83", + "win-x64": "39de9df039c8b0d627ac5918a9d8792ad20fc49e2461d1f5c906975c016799ec", +} +DEFAULT_MARKSMAN_VERSION = "2024-12-18" +DEFAULT_MARKSMAN_SHA256_BY_PLATFORM = { + "linux-x64": "b9cb666c643dfd9b699811fdfc445ed4c56be65c1d878c21d46847f0d7b0e475", + "linux-arm64": "b8d6972a56f3f9b7bbbf7c77ef8998e3b66fa82fb03c01398e224144486c9e73", + "osx-x64": "7e18803966231a33ee107d0d26f69b41f2f0dc1332c52dd9729c2e29fb77be83", + "osx-arm64": "7e18803966231a33ee107d0d26f69b41f2f0dc1332c52dd9729c2e29fb77be83", + "win-x64": "39de9df039c8b0d627ac5918a9d8792ad20fc49e2461d1f5c906975c016799ec", +} + + +def _marksman_sha(version: str, platform_key: str) -> str | None: + if version == INITIAL_MARKSMAN_VERSION: + return INITIAL_MARKSMAN_SHA256_BY_PLATFORM[platform_key] + if version == DEFAULT_MARKSMAN_VERSION: + return DEFAULT_MARKSMAN_SHA256_BY_PLATFORM[platform_key] + return None + + +class Marksman(SolidLanguageServer): + """ + Provides Markdown specific instantiation of the LanguageServer class using marksman. + + You can pass the following entries in ``ls_specific_settings["markdown"]``: + - marksman_version: Override the pinned Marksman release tag downloaded by + Serena (default: the bundled Serena version). + """ + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + @classmethod + def _runtime_dependencies(cls, version: str) -> RuntimeDependencyCollection: + marksman_releases = f"https://github.com/artempyanykh/marksman/releases/download/{version}" + return RuntimeDependencyCollection( + [ + RuntimeDependency( + id="marksman", + url=f"{marksman_releases}/marksman-linux-x64", + platform_id="linux-x64", + archive_type="binary", + binary_name="marksman", + sha256=_marksman_sha(version, "linux-x64"), + allowed_hosts=MARKSMAN_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="marksman", + url=f"{marksman_releases}/marksman-linux-arm64", + platform_id="linux-arm64", + archive_type="binary", + binary_name="marksman", + sha256=_marksman_sha(version, "linux-arm64"), + allowed_hosts=MARKSMAN_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="marksman", + url=f"{marksman_releases}/marksman-macos", + platform_id="osx-x64", + archive_type="binary", + binary_name="marksman", + sha256=_marksman_sha(version, "osx-x64"), + allowed_hosts=MARKSMAN_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="marksman", + url=f"{marksman_releases}/marksman-macos", + platform_id="osx-arm64", + archive_type="binary", + binary_name="marksman", + sha256=_marksman_sha(version, "osx-arm64"), + allowed_hosts=MARKSMAN_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="marksman", + url=f"{marksman_releases}/marksman.exe", + platform_id="win-x64", + archive_type="binary", + binary_name="marksman.exe", + sha256=_marksman_sha(version, "win-x64"), + allowed_hosts=MARKSMAN_ALLOWED_HOSTS, + ), + ] + ) + + def _get_or_install_core_dependency(self) -> str: + """Setup runtime dependencies for marksman and return the command to start the server.""" + marksman_version = self._custom_settings.get("marksman_version", DEFAULT_MARKSMAN_VERSION) + deps = self._runtime_dependencies(marksman_version) + dependency = deps.get_single_dep_for_current_platform() + + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + marksman_ls_dir = ( + self._ls_resources_dir + if marksman_version == INITIAL_MARKSMAN_VERSION + else os.path.join(self._ls_resources_dir, f"marksman-{marksman_version}") + ) + marksman_executable_path = deps.binary_path(marksman_ls_dir) + if not os.path.exists(marksman_executable_path): + log.info( + f"Downloading marksman from {dependency.url} to {marksman_ls_dir}", + ) + deps.install(marksman_ls_dir) + if not os.path.exists(marksman_executable_path): + raise FileNotFoundError(f"Download failed? Could not find marksman executable at {marksman_executable_path}") + os.chmod(marksman_executable_path, 0o755) + return marksman_executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "server"] + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a Marksman instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + super().__init__( + config, + repository_root_path, + None, + "markdown", + solidlsp_settings, + ) + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["node_modules", ".obsidian", ".vitepress", ".vuepress"] + + def _document_symbols_cache_fingerprint(self) -> Hashable | None: + request_document_symbols_override_version = 1 + return request_document_symbols_override_version + + @override + def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: + """Override to remap Marksman's heading symbol kinds from String to Namespace. + + Marksman LSP returns all markdown headings (h1-h6) with SymbolKind.String (15). + This is problematic because String (15) >= Variable (13), so headings are + classified as "low-level" and filtered out of symbol overviews. + Remapping to Namespace (3) fixes this and is semantically appropriate + (headings are named sections containing other content). + """ + document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer) + + # NOTE: When changing this method, also update the cache fingerprint method above + + def remap_heading_kinds(symbol: UnifiedSymbolInformation) -> None: + if symbol["kind"] == SymbolKind.String: + symbol["kind"] = SymbolKind.Namespace + for child in symbol.get("children", []): + remap_heading_kinds(child) + + for sym in document_symbols.root_symbols: + remap_heading_kinds(sym) + + return document_symbols + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Marksman Language Server. + """ + initialize_params: dict = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "codeAction": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """ + Starts the Marksman Language Server and waits for it to be ready. + """ + + def register_capability_handler(_params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(_params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting marksman server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to marksman server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.debug(f"Received initialize response from marksman server: {init_response}") + + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "completionProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + + # marksman is typically ready immediately after initialization + log.info("Marksman server initialization complete") diff --git a/src/solidlsp/language_servers/matlab_language_server.py b/src/solidlsp/language_servers/matlab_language_server.py new file mode 100644 index 0000000..d669435 --- /dev/null +++ b/src/solidlsp/language_servers/matlab_language_server.py @@ -0,0 +1,533 @@ +""" +MATLAB language server integration using the official MathWorks MATLAB Language Server. + +Architecture: + This module uses the MathWorks MATLAB VS Code extension (mathworks.language-matlab) + which contains a Node.js-based language server. The extension is downloaded from the + VS Code Marketplace and extracted locally. The language server spawns a real MATLAB + process to provide code intelligence - it is NOT a standalone static analyzer. + + Flow: Serena -> Node.js LSP Server -> MATLAB Process -> Code Analysis + +Why MATLAB installation is required: + The language server launches an actual MATLAB session (via MatlabSession.js) to perform + code analysis, diagnostics, and other features. Without MATLAB, the LSP cannot function. + This is different from purely static analyzers that parse code without execution. + +Requirements: + - MATLAB R2021b or later must be installed and licensed + - Node.js must be installed (for running the language server) + - MATLAB path can be specified via MATLAB_PATH environment variable or auto-detected + +The MATLAB language server provides: + - Code diagnostics (publishDiagnostics) + - Code completions (completionProvider) + - Go to definition (definitionProvider) + - Find references (referencesProvider) + - Document symbols (documentSymbol) + - Document formatting (documentFormattingProvider) + - Function signature help (signatureHelpProvider) + - Symbol rename (renameProvider) +""" + +import glob +import logging +import os +import pathlib +import platform +import shutil +import threading +from typing import Any + +from solidlsp.ls import LanguageServerDependencyProvider, LSPFileBuffer, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_utils import FileUtils +from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, SymbolInformation +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +# Environment variable for MATLAB installation path +MATLAB_PATH_ENV_VAR = "MATLAB_PATH" + +MATLAB_EXTENSION_ALLOWED_HOSTS = ("marketplace.visualstudio.com",) + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_MATLAB_EXTENSION_VERSION = "1.3.9" +INITIAL_MATLAB_EXTENSION_SHA256 = "1da3add2c3a593fa0ebcdf1d15231faee8014de10f549c36915ab9d4f18390f2" +DEFAULT_MATLAB_EXTENSION_VERSION = "1.3.9" +DEFAULT_MATLAB_EXTENSION_SHA256 = "1da3add2c3a593fa0ebcdf1d15231faee8014de10f549c36915ab9d4f18390f2" + + +def _matlab_extension_sha(version: str) -> str | None: + if version == INITIAL_MATLAB_EXTENSION_VERSION: + return INITIAL_MATLAB_EXTENSION_SHA256 + if version == DEFAULT_MATLAB_EXTENSION_VERSION: + return DEFAULT_MATLAB_EXTENSION_SHA256 + return None + + +def _matlab_extension_dirname(version: str) -> str: + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + return "matlab-extension" if version == INITIAL_MATLAB_EXTENSION_VERSION else f"matlab-extension-{version}" + + +class MatlabLanguageServer(SolidLanguageServer): + """ + Provides MATLAB specific instantiation of the LanguageServer class using the official + MathWorks MATLAB Language Server. + + The MATLAB language server requires: + - MATLAB R2021b or later installed on the system + - Node.js for running the language server + + The language server is automatically downloaded from the VS Code marketplace + (MathWorks.language-matlab extension) and extracted. + + You can pass the following entries in ls_specific_settings["matlab"]: + - matlab_path: Path to MATLAB installation (overrides MATLAB_PATH env var) + - matlab_extension_version: Override the pinned MathWorks VS Code extension + version downloaded by Serena (default: the bundled Serena version) + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a MatlabLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + super().__init__( + config, + repository_root_path, + None, + "matlab", + solidlsp_settings, + ) + + assert isinstance(self._dependency_provider, self.DependencyProvider) + self._matlab_path = self._dependency_provider.get_matlab_path() + + self.server_ready = threading.Event() + self.initialize_searcher_command_available = threading.Event() + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + class DependencyProvider(LanguageServerDependencyProvider): + def __init__(self, custom_settings: SolidLSPSettings.CustomLSSettings, ls_resources_dir: str): + super().__init__(custom_settings, ls_resources_dir) + self._matlab_path: str | None = None + + @classmethod + def _download_matlab_extension(cls, url: str, target_dir: str, expected_sha256: str | None) -> bool: + """ + Download and extract the MATLAB extension from VS Code marketplace. + + The VS Code marketplace packages extensions as .vsix files (which are ZIP archives). + This method downloads the VSIX file and extracts it to get the language server. + """ + try: + log.info(f"Downloading MATLAB extension from {url}") + os.makedirs(target_dir, exist_ok=True) + FileUtils.download_and_extract_archive_verified( + url, + target_dir, + "zip", + expected_sha256=expected_sha256, + allowed_hosts=MATLAB_EXTENSION_ALLOWED_HOSTS, + ) + log.info("MATLAB extension extracted successfully") + return True + + except Exception as e: + log.error(f"Error downloading/extracting MATLAB extension: {e}") + return False + + def _find_matlab_extension(self) -> str | None: + """ + Find MATLAB extension in various locations. + + Search order: + 1. Environment variable (MATLAB_EXTENSION_PATH) + 2. Default download location (~/.serena/ls_resources/matlab-extension) + 3. VS Code installed extensions + + Returns: + Path to MATLAB extension directory or None if not found + + """ + # Check environment variable + env_path = os.environ.get("MATLAB_EXTENSION_PATH") + if env_path and os.path.exists(env_path): + log.debug(f"Found MATLAB extension via MATLAB_EXTENSION_PATH: {env_path}") + return env_path + elif env_path: + log.warning(f"MATLAB_EXTENSION_PATH set but directory not found: {env_path}") + + # Check the resolved-version download location (versioned for non-INITIAL, legacy "matlab-extension" for INITIAL) + matlab_extension_version = self._custom_settings.get("matlab_extension_version", DEFAULT_MATLAB_EXTENSION_VERSION) + default_path = os.path.join(self._ls_resources_dir, _matlab_extension_dirname(matlab_extension_version), "extension") + if os.path.exists(default_path): + log.debug(f"Found MATLAB extension in default location: {default_path}") + return default_path + + # Search VS Code extensions + vscode_extensions_dir = os.path.expanduser("~/.vscode/extensions") + if os.path.exists(vscode_extensions_dir): + for entry in os.listdir(vscode_extensions_dir): + if entry.startswith("mathworks.language-matlab"): + ext_path = os.path.join(vscode_extensions_dir, entry) + if os.path.isdir(ext_path): + log.debug(f"Found MATLAB extension in VS Code: {ext_path}") + return ext_path + + log.debug("MATLAB extension not found in any known location") + return None + + def _download_and_install_matlab_extension(self) -> str | None: + """ + Download and install MATLAB extension from VS Code marketplace. + + Returns: + Path to installed extension or None if download failed + + """ + matlab_extension_version = self._custom_settings.get("matlab_extension_version", DEFAULT_MATLAB_EXTENSION_VERSION) + matlab_extension_dir = os.path.join(self._ls_resources_dir, _matlab_extension_dirname(matlab_extension_version)) + matlab_extension_url = ( + "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/MathWorks/" + f"vsextensions/language-matlab/{matlab_extension_version}/vspackage" + ) + + log.info(f"Downloading MATLAB extension from: {matlab_extension_url}") + + if self._download_matlab_extension(matlab_extension_url, matlab_extension_dir, _matlab_extension_sha(matlab_extension_version)): + extension_path = os.path.join(matlab_extension_dir, "extension") + if os.path.exists(extension_path): + log.info("MATLAB extension downloaded and installed successfully") + return extension_path + else: + log.error(f"Download completed but extension not found at: {extension_path}") + else: + log.error("Failed to download MATLAB extension from marketplace") + + return None + + @classmethod + def _get_executable_path(cls, extension_path: str) -> str: + """ + Get the path to the MATLAB language server executable based on platform. + + The language server is a Node.js script located in the extension's server directory. + """ + # The MATLAB extension bundles the language server in the 'server' directory + server_dir = os.path.join(extension_path, "server", "out") + main_script = os.path.join(server_dir, "index.js") + + if os.path.exists(main_script): + return main_script + + # Alternative location + alt_script = os.path.join(extension_path, "out", "index.js") + if os.path.exists(alt_script): + return alt_script + + raise RuntimeError(f"MATLAB language server script not found in extension at {extension_path}") + + @staticmethod + def _find_matlab_installation() -> str: + """ + Find MATLAB installation path. + + Search order: + 1. MATLAB_PATH environment variable + 2. Common installation locations based on platform + + Returns: + Path to MATLAB installation directory. + + Raises: + RuntimeError: If MATLAB installation is not found. + + """ + # Check environment variable first + matlab_path = os.environ.get(MATLAB_PATH_ENV_VAR) + if matlab_path and os.path.isdir(matlab_path): + log.info(f"Using MATLAB from environment variable {MATLAB_PATH_ENV_VAR}: {matlab_path}") + return matlab_path + + system = platform.system() + + if system == "Darwin": # macOS + # Check common macOS locations + search_patterns = [ + "/Applications/MATLAB_*.app", + "/Volumes/*/Applications/MATLAB_*.app", + os.path.expanduser("~/Applications/MATLAB_*.app"), + ] + for pattern in search_patterns: + matches = sorted(glob.glob(pattern), reverse=True) # Newest version first + for match in matches: + if os.path.isdir(match): + log.info(f"Found MATLAB installation: {match}") + return match + + elif system == "Windows": + # Check common Windows locations + search_patterns = [ + "C:\\Program Files\\MATLAB\\R*", + "C:\\Program Files (x86)\\MATLAB\\R*", + ] + for pattern in search_patterns: + matches = sorted(glob.glob(pattern), reverse=True) + for match in matches: + if os.path.isdir(match): + log.info(f"Found MATLAB installation: {match}") + return match + + elif system == "Linux": + # Check common Linux locations + search_patterns = [ + "/usr/local/MATLAB/R*", + "/opt/MATLAB/R*", + os.path.expanduser("~/MATLAB/R*"), + ] + for pattern in search_patterns: + matches = sorted(glob.glob(pattern), reverse=True) + for match in matches: + if os.path.isdir(match): + log.info(f"Found MATLAB installation: {match}") + return match + + raise RuntimeError( + f"MATLAB installation not found. Set the {MATLAB_PATH_ENV_VAR} environment variable " + "to your MATLAB installation directory (e.g., /Applications/MATLAB_R2024b.app on macOS, " + "C:\\Program Files\\MATLAB\\R2024b on Windows, or /usr/local/MATLAB/R2024b on Linux)." + ) + + def get_matlab_path(self) -> str: + """Get MATLAB path from settings or auto-detect.""" + if self._matlab_path is not None: + return self._matlab_path + + matlab_path = self._custom_settings.get("matlab_path") + + if not matlab_path: + matlab_path = self._find_matlab_installation() # Raises RuntimeError if not found + + # Verify MATLAB path exists + if not os.path.isdir(matlab_path): + raise RuntimeError(f"MATLAB installation directory does not exist: {matlab_path}") + + log.info(f"Using MATLAB installation: {matlab_path}") + + self._matlab_path = matlab_path + return matlab_path + + def create_launch_command(self) -> list[str]: + # Verify node is installed + node_path = shutil.which("node") + if node_path is None: + raise RuntimeError("Node.js is not installed or isn't in PATH. Please install Node.js and try again.") + + # Find existing extension or download if needed + extension_path = self._find_matlab_extension() + if extension_path is None: + log.info("MATLAB extension not found on disk, attempting to download...") + extension_path = self._download_and_install_matlab_extension() + + if extension_path is None: + raise RuntimeError( + "Failed to locate or download MATLAB Language Server. Please either:\n" + "1. Set MATLAB_EXTENSION_PATH environment variable to the MATLAB extension directory\n" + "2. Install the MATLAB extension in VS Code (MathWorks.language-matlab)\n" + "3. Ensure internet connection for automatic download" + ) + + # Get the language server script path + server_script = self._get_executable_path(extension_path) + + if not os.path.exists(server_script): + raise RuntimeError(f"MATLAB Language Server script not found at: {server_script}") + + # Build the command to run the language server + # The MATLAB language server is run via Node.js with the --stdio flag + cmd = [node_path, server_script, "--stdio"] + return cmd + + def create_launch_command_env(self) -> dict[str, str]: + return { + "MATLAB_INSTALL_PATH": self.get_matlab_path(), + } + + def _create_base_initialize_params(self) -> dict: + """Return the initialize params for the MATLAB Language Server.""" + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": { + "dynamicRegistration": True, + "completionItem": {"snippetSupport": True}, + }, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": {"dynamicRegistration": True}, + "codeAction": {"dynamicRegistration": True}, + "formatting": {"dynamicRegistration": True}, + "rename": {"dynamicRegistration": True, "prepareSupport": True}, + "publishDiagnostics": {"relatedInformation": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """Start the MATLAB Language Server and wait for it to be ready.""" + root_uri = pathlib.Path(self.repository_root_path).as_uri() + + def register_capability_handler(params: dict) -> None: + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "workspace/executeCommand": + self.initialize_searcher_command_available.set() + return + + def execute_client_command_handler(params: dict) -> list: + return [] + + def workspace_folders_handler(params: dict) -> list: + """Handle workspace/workspaceFolders request from the server.""" + return [{"uri": root_uri, "name": os.path.basename(self.repository_root_path)}] + + def workspace_configuration_handler(params: dict) -> list: + """Handle workspace/configuration request from the server.""" + items = params.get("items", []) + result = [] + for item in items: + section = item.get("section", "") + if section == "MATLAB": + # Return MATLAB configuration + result.append({"installPath": self._matlab_path, "matlabConnectionTiming": "onStart"}) + else: + result.append({}) + return result + + def do_nothing(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + message_text = msg.get("message", "") + # Check for MATLAB language server ready signals + # Wait for "MVM attach success" or "Adding workspace folder" which indicates MATLAB is fully ready + # Note: "connected to" comes earlier but the server isn't fully ready at that point + if "mvm attach success" in message_text.lower() or "adding workspace folder" in message_text.lower(): + log.info("MATLAB language server ready signal detected (MVM attached)") + self.server_ready.set() + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_request("workspace/workspaceFolders", workspace_folders_handler) + self.server.on_request("workspace/configuration", workspace_configuration_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting MATLAB server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.debug(f"Received initialize response from MATLAB server: {init_response}") + + # Verify basic capabilities + capabilities = init_response.get("capabilities", {}) + assert capabilities.get("textDocumentSync") in [1, 2], "Expected Full or Incremental text sync" + + # Log available capabilities + if "completionProvider" in capabilities: + log.info("MATLAB server supports completions") + if "definitionProvider" in capabilities: + log.info("MATLAB server supports go-to-definition") + if "referencesProvider" in capabilities: + log.info("MATLAB server supports find-references") + if "documentSymbolProvider" in capabilities: + log.info("MATLAB server supports document symbols") + if "documentFormattingProvider" in capabilities: + log.info("MATLAB server supports document formatting") + if "renameProvider" in capabilities: + log.info("MATLAB server supports rename") + + self.server.notify.initialized({}) + + # Wait for server readiness with timeout + # MATLAB takes longer to start than most language servers (typically 10-30 seconds) + log.info("Waiting for MATLAB language server to be ready (this may take up to 60 seconds)...") + if not self.server_ready.wait(timeout=60.0): + # Fallback: assume server is ready after timeout + log.info("Timeout waiting for MATLAB server ready signal, proceeding anyway") + self.server_ready.set() + else: + log.info("MATLAB server initialization complete") + + def is_ignored_dirname(self, dirname: str) -> bool: + """Define MATLAB-specific directories to ignore.""" + return super().is_ignored_dirname(dirname) or dirname in [ + "slprj", # Simulink project files + "codegen", # Code generation output + "sldemo_cache", # Simulink demo cache + "helperFiles", # Common helper file directories + ] + + def _request_document_symbols( + self, relative_file_path: str, file_data: LSPFileBuffer | None + ) -> list[SymbolInformation] | list[DocumentSymbol] | None: + """ + Override to normalize MATLAB symbol names. + + The MATLAB LSP sometimes returns symbol names as lists instead of strings, + particularly for script sections (cell mode markers like %%). This method + normalizes the names to strings for compatibility with the unified symbol format. + """ + symbols = super()._request_document_symbols(relative_file_path, file_data) + + if symbols is None or len(symbols) == 0: + return symbols + + self._normalize_matlab_symbols(symbols) + return symbols + + def _normalize_matlab_symbols(self, symbols: list[SymbolInformation] | list[DocumentSymbol]) -> None: + """ + Normalize MATLAB symbol names in-place. + + MATLAB LSP returns section names as lists like ["Section Name"] instead of + strings. This converts them to plain strings. + """ + for symbol in symbols: + # MATLAB LSP returns names as lists for script sections, violating LSP spec + # Cast to Any to handle runtime type that differs from spec + name: Any = symbol.get("name") + if isinstance(name, list): + symbol["name"] = name[0] if name else "" + log.debug("Normalized MATLAB symbol name from list to string") + + # Recursively normalize children if present + children: Any = symbol.get("children") + if children and isinstance(children, list): + self._normalize_matlab_symbols(children) diff --git a/src/solidlsp/language_servers/msl_language_server.py b/src/solidlsp/language_servers/msl_language_server.py new file mode 100644 index 0000000..e8c89c1 --- /dev/null +++ b/src/solidlsp/language_servers/msl_language_server.py @@ -0,0 +1,99 @@ +""" +Provides mSL (mIRC Scripting Language) specific instantiation of the LanguageServer class. +Uses a custom Python-based LSP server (pygls) for parsing .mrc files. + +The LSP server script is shipped as ``msl_lsp_server.py`` alongside this module +and launched as a subprocess using the current Python interpreter. +""" + +import logging +import os +import sys +import threading + +from solidlsp.ls import ( + SolidLanguageServer, +) +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +_MSL_LSP_SCRIPT = os.path.join(os.path.dirname(__file__), "msl_lsp_server.py") + + +class MslLanguageServer(SolidLanguageServer): + """ + Provides mSL (mIRC Scripting Language) specific instantiation of the LanguageServer class. + Uses a Python-based LSP server for parsing .mrc files (aliases, events, menus, dialogs). + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates an MslLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + process_launch_info = ProcessLaunchInfo(cmd=[sys.executable, _MSL_LSP_SCRIPT], cwd=repository_root_path) + super().__init__( + config, + repository_root_path, + process_launch_info=process_launch_info, + language_id="msl", + solidlsp_settings=solidlsp_settings, + ) + self.server_ready = threading.Event() + + def _create_base_initialize_params(self) -> dict: + """Returns the initialize params for the mSL Language Server.""" + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "references": {"dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "symbol": {"dynamicRegistration": True}, + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """Starts the mSL Language Server.""" + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + self.server_ready.set() + + def do_nothing(params: dict) -> None: + pass + + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting mSL server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request to mSL LSP server") + init_response = self.server.send.initialize(initialize_params) + log.debug(f"Received initialize response: {init_response}") + + self.server.notify.initialized({}) + + # Wait briefly for server readiness + if not self.server_ready.wait(timeout=2.0): + log.info("Timeout waiting for mSL server ready signal, proceeding anyway") + self.server_ready.set() + + log.info("mSL server initialization complete") diff --git a/src/solidlsp/language_servers/msl_lsp_server.py b/src/solidlsp/language_servers/msl_lsp_server.py new file mode 100644 index 0000000..e87d3d1 --- /dev/null +++ b/src/solidlsp/language_servers/msl_lsp_server.py @@ -0,0 +1,439 @@ +"""mSL (mIRC Scripting Language) Language Server. + +A minimal LSP implementation for mIRC scripting language (.mrc files). +Provides document symbols, hover, references, go-to-definition, and workspace symbols +for aliases, events, menus, dialogs, and CTCP handlers. + +Launched as a subprocess by MslLanguageServer. Communicates via stdio. +""" + +import logging +import os +import pathlib +import re + +from lsprotocol import types as lsp +from pygls.lsp.server import LanguageServer + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +server = LanguageServer("msl-lsp", "0.1.0") + +# mSL top-level construct patterns +ALIAS_PATTERN = re.compile( + r"^[ \t]*alias\s+(?:-l\s+)?([a-zA-Z_][\w.]*)\s*(?:\{|$)", + re.MULTILINE | re.IGNORECASE, +) +EVENT_PATTERN = re.compile( + r"^[ \t]*on\s+(\*|\d+):(\w+):([^{]*?)(?:\{|$)", + re.MULTILINE | re.IGNORECASE, +) +RAW_EVENT_PATTERN = re.compile( + r"^[ \t]*raw\s+(\d+):([^{]*?)(?:\{|$)", + re.MULTILINE | re.IGNORECASE, +) +MENU_PATTERN = re.compile( + r"^[ \t]*menu\s+([^\s{]+)\s*\{", + re.MULTILINE | re.IGNORECASE, +) +DIALOG_PATTERN = re.compile( + r"^[ \t]*dialog\s+(-l\s+)?([a-zA-Z_][\w]*)\s*\{", + re.MULTILINE | re.IGNORECASE, +) +CTCP_PATTERN = re.compile( + r"^[ \t]*ctcp\s+(\*|\d+):(\w+):([^{]*?)(?:\{|$)", + re.MULTILINE | re.IGNORECASE, +) + + +def _get_line_col(text: str, pos: int) -> tuple[int, int]: + lines = text[:pos].split("\n") + return len(lines) - 1, len(lines[-1]) if lines else 0 + + +def _find_block_end(text: str, start: int) -> int: + count, i = 0, start + while i < len(text): + ch = text[i] + if ch == ";" and count > 0: + while i < len(text) and text[i] != "\n": + i += 1 + continue + if ch == "{": + count += 1 + elif ch == "}": + count -= 1 + if count == 0: + return i + i += 1 + return len(text) - 1 + + +def _make_symbol( + name: str, + kind: lsp.SymbolKind, + detail: str, + text: str, + match_start: int, + match_end: int, + match_text: str, +) -> lsp.DocumentSymbol: + sl, sc = _get_line_col(text, match_start) + bs = text.find("{", match_start) + if bs != -1: + el, ec = _get_line_col(text, _find_block_end(text, bs)) + else: + el, ec = _get_line_col(text, match_end) + return lsp.DocumentSymbol( + name=name, + kind=kind, + range=lsp.Range(lsp.Position(sl, 0), lsp.Position(el, ec + 1)), + selection_range=lsp.Range(lsp.Position(sl, sc), lsp.Position(sl, sc + len(match_text))), + detail=detail, + ) + + +def parse_symbols(text: str) -> list[lsp.DocumentSymbol]: + """Parse mSL source code and return document symbols.""" + symbols: list[lsp.DocumentSymbol] = [] + + for m in ALIAS_PATTERN.finditer(text): + symbols.append(_make_symbol(m.group(1), lsp.SymbolKind.Function, "alias", text, m.start(), m.end(), m.group(0))) + + for m in EVENT_PATTERN.finditer(text): + pat = m.group(3).strip().rstrip(":") + name = f"on {m.group(1)}:{m.group(2)}" + (f":{pat}" if pat else "") + symbols.append(_make_symbol(name, lsp.SymbolKind.Event, f"event:{m.group(2)}", text, m.start(), m.end(), m.group(0))) + + for m in RAW_EVENT_PATTERN.finditer(text): + pat = m.group(2).strip().rstrip(":") + name = f"raw {m.group(1)}" + (f":{pat}" if pat else "") + symbols.append(_make_symbol(name, lsp.SymbolKind.Event, "raw event", text, m.start(), m.end(), m.group(0))) + + for m in MENU_PATTERN.finditer(text): + symbols.append(_make_symbol(f"menu {m.group(1)}", lsp.SymbolKind.Module, "menu", text, m.start(), m.end(), m.group(0))) + + for m in DIALOG_PATTERN.finditer(text): + symbols.append(_make_symbol(f"dialog {m.group(2)}", lsp.SymbolKind.Class, "dialog", text, m.start(), m.end(), m.group(0))) + + for m in CTCP_PATTERN.finditer(text): + pat = m.group(3).strip().rstrip(":") + name = f"ctcp {m.group(1)}:{m.group(2)}" + (f":{pat}" if pat else "") + symbols.append(_make_symbol(name, lsp.SymbolKind.Event, "ctcp event", text, m.start(), m.end(), m.group(0))) + + symbols.sort(key=lambda s: s.range.start.line) + return symbols + + +def _get_workspace_roots() -> list[str]: + """Get workspace root paths from the server's workspace object. + + After initialization, pygls populates ``server.workspace`` with the + root URI/path and any workspace folders sent by the client. + """ + from pygls.uris import to_fs_path + + roots: list[str] = [] + try: + ws = server.workspace + except (RuntimeError, AttributeError): + return roots + + # workspace.folders is a dict of {uri_string: WorkspaceFolder} + if hasattr(ws, "folders") and ws.folders: + for folder_uri in ws.folders: + fs_path = to_fs_path(folder_uri) + if fs_path: + # resolve() normalizes drive letter casing on Windows + fs_path = str(pathlib.Path(fs_path).resolve()) + if os.path.isdir(fs_path): + roots.append(fs_path) + + # Fall back to root_path + if not roots and hasattr(ws, "root_path") and ws.root_path: + root = str(pathlib.Path(ws.root_path).resolve()) + if os.path.isdir(root): + roots.append(root) + + return roots + + +def _path_to_uri(path: str) -> str: + """Convert a filesystem path to a file URI.""" + return pathlib.Path(path).as_uri() + + +def _get_all_mrc_files() -> list[tuple[str, str, str]]: + """Scan workspace roots for all .mrc files. + + Returns list of (uri, file_path, source_text) tuples. + Prefers content from already-opened documents over disk reads. + """ + results: list[tuple[str, str, str]] = [] + seen_paths: set[str] = set() + + # First, include all files currently open in the workspace + try: + for uri, doc in server.workspace.text_documents.items(): + if uri.endswith(".mrc"): + # Resolve the filesystem path from the URI + from pygls.uris import to_fs_path + + file_path = to_fs_path(uri) or uri + norm = os.path.normcase(os.path.normpath(file_path)) + seen_paths.add(norm) + results.append((uri, file_path, doc.source)) + except (RuntimeError, AttributeError): + pass + + # Then scan workspace roots for any .mrc files not yet opened + for root in _get_workspace_roots(): + for dirpath, _dirnames, filenames in os.walk(root): + for fname in filenames: + if not fname.endswith(".mrc"): + continue + full_path = os.path.join(dirpath, fname) + norm = os.path.normcase(os.path.normpath(full_path)) + if norm in seen_paths: + continue + seen_paths.add(norm) + try: + with open(full_path, encoding="utf-8", errors="replace") as f: + source = f.read() + uri = _path_to_uri(full_path) + results.append((uri, full_path, source)) + except OSError: + pass + + return results + + +def _find_symbol_at_position(text: str, line: int, character: int) -> str | None: + """Find the symbol name at the given position in the text.""" + lines = text.split("\n") + if line >= len(lines): + return None + line_text = lines[line] + + # Check if position is within an alias definition + for m in ALIAS_PATTERN.finditer(text): + sl, _ = _get_line_col(text, m.start()) + if sl == line: + return m.group(1) + + # Check if position is within an event definition + for m in EVENT_PATTERN.finditer(text): + sl, _ = _get_line_col(text, m.start()) + if sl == line: + pat = m.group(3).strip().rstrip(":") + return f"on {m.group(1)}:{m.group(2)}" + (f":{pat}" if pat else "") + + # Check if position is within a raw event + for m in RAW_EVENT_PATTERN.finditer(text): + sl, _ = _get_line_col(text, m.start()) + if sl == line: + pat = m.group(2).strip().rstrip(":") + return f"raw {m.group(1)}" + (f":{pat}" if pat else "") + + # Check if position is within a menu + for m in MENU_PATTERN.finditer(text): + sl, _ = _get_line_col(text, m.start()) + if sl == line: + return f"menu {m.group(1)}" + + # Check if position is within a dialog + for m in DIALOG_PATTERN.finditer(text): + sl, _ = _get_line_col(text, m.start()) + if sl == line: + return f"dialog {m.group(2)}" + + # Check if position is within a CTCP handler + for m in CTCP_PATTERN.finditer(text): + sl, _ = _get_line_col(text, m.start()) + if sl == line: + return f"ctcp {m.group(1)}:{m.group(2)}" + + # Fall back: extract word at position (for alias call sites) + if character < len(line_text): + pos = character + # Skip leading $ if cursor is on it (e.g., $format.coins) + if pos < len(line_text) and line_text[pos] == "$": + pos += 1 + start = pos + while start > 0 and (line_text[start - 1].isalnum() or line_text[start - 1] in "_."): + start -= 1 + end = pos + while end < len(line_text) and (line_text[end].isalnum() or line_text[end] in "_."): + end += 1 + word = line_text[start:end] + if word: + return word + return None + + +def _get_symbol_detail(text: str, symbol_name: str) -> str | None: + """Get detail/documentation text for a symbol by finding its definition and extracting context.""" + for sym in parse_symbols(text): + if sym.name == symbol_name: + # Extract the definition line and a few lines of body for hover + text_lines = text.split("\n") + start_line = sym.range.start.line + end_line = min(sym.range.end.line, start_line + 5) + snippet_lines = text_lines[start_line : end_line + 1] + snippet = "\n".join(snippet_lines) + return f"```msl\n{snippet}\n```\n\n**Kind**: {sym.detail}" + return None + + +def _build_call_pattern(alias_name: str) -> re.Pattern[str]: + """Build a regex that matches calls to an alias (both as command and as $identifier).""" + escaped = re.escape(alias_name) + return re.compile( + rf"(? None: + pass + + +@server.feature(lsp.TEXT_DOCUMENT_DID_CHANGE) +def did_change(params: lsp.DidChangeTextDocumentParams) -> None: + pass + + +@server.feature(lsp.TEXT_DOCUMENT_DID_CLOSE) +def did_close(params: lsp.DidCloseTextDocumentParams) -> None: + pass + + +@server.feature(lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL) +def document_symbol(params: lsp.DocumentSymbolParams) -> list[lsp.DocumentSymbol]: + """Return document symbols for the given document.""" + try: + doc = server.workspace.get_text_document(params.text_document.uri) + return parse_symbols(doc.source) + except Exception as e: + logger.error(f"Error: {e}") + return [] + + +@server.feature(lsp.TEXT_DOCUMENT_HOVER) +def hover(params: lsp.HoverParams) -> lsp.Hover | None: + """Return hover information for the symbol at the given position.""" + try: + doc = server.workspace.get_text_document(params.text_document.uri) + symbol_name = _find_symbol_at_position(doc.source, params.position.line, params.position.character) + if not symbol_name: + return None + + # Search all files for the symbol definition to get its detail + for _uri, _path, source in _get_all_mrc_files(): + detail = _get_symbol_detail(source, symbol_name) + if detail: + return lsp.Hover( + contents=lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=detail), + ) + + # Fallback: return the symbol name itself + return lsp.Hover( + contents=lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=f"**{symbol_name}**"), + ) + except Exception as e: + logger.error(f"Error in hover: {e}") + return None + + +@server.feature(lsp.TEXT_DOCUMENT_REFERENCES) +def references(params: lsp.ReferenceParams) -> list[lsp.Location]: + """Find all references to the symbol at the given position across the workspace.""" + try: + doc = server.workspace.get_text_document(params.text_document.uri) + symbol_name = _find_symbol_at_position( + doc.source, + params.position.line, + params.position.character, + ) + if not symbol_name: + return [] + + results: list[lsp.Location] = [] + + # For alias symbols, search for call sites across all .mrc files + # For events/menus/dialogs, only return the definition location + is_alias = not any(symbol_name.startswith(prefix) for prefix in ("on ", "raw ", "menu ", "dialog ", "ctcp ")) + + for uri, _path, source in _get_all_mrc_files(): + if is_alias: + call_pattern = _build_call_pattern(symbol_name) + for m in call_pattern.finditer(source): + ref_line, ref_col = _get_line_col(source, m.start()) + results.append( + lsp.Location( + uri=uri, + range=lsp.Range( + lsp.Position(ref_line, ref_col), + lsp.Position(ref_line, ref_col + len(m.group(0))), + ), + ) + ) + else: + # For non-alias symbols, find the definition + for sym in parse_symbols(source): + if sym.name == symbol_name: + results.append(lsp.Location(uri=uri, range=sym.range)) + + return results + except Exception as e: + logger.error(f"Error finding references: {e}") + return [] + + +@server.feature(lsp.TEXT_DOCUMENT_DEFINITION) +def definition(params: lsp.DefinitionParams) -> list[lsp.Location]: + """Go to definition of the symbol at the given position.""" + try: + doc = server.workspace.get_text_document(params.text_document.uri) + symbol_name = _find_symbol_at_position( + doc.source, + params.position.line, + params.position.character, + ) + if not symbol_name: + return [] + + results: list[lsp.Location] = [] + for uri, _path, source in _get_all_mrc_files(): + for sym in parse_symbols(source): + if sym.name == symbol_name: + results.append(lsp.Location(uri=uri, range=sym.selection_range)) + return results + except Exception as e: + logger.error(f"Error finding definition: {e}") + return [] + + +@server.feature(lsp.WORKSPACE_SYMBOL) +def workspace_symbol(params: lsp.WorkspaceSymbolParams) -> list[lsp.SymbolInformation]: + """Search for symbols across the workspace.""" + query = params.query.lower() + results = [] + for uri, _path, source in _get_all_mrc_files(): + for sym in parse_symbols(source): + if query in sym.name.lower(): + results.append( + lsp.SymbolInformation( + name=sym.name, + kind=sym.kind, + location=lsp.Location(uri=uri, range=sym.range), + container_name=sym.detail, + ) + ) + return results + + +if __name__ == "__main__": + server.start_io() diff --git a/src/solidlsp/language_servers/nixd_ls.py b/src/solidlsp/language_servers/nixd_ls.py new file mode 100644 index 0000000..eacd636 --- /dev/null +++ b/src/solidlsp/language_servers/nixd_ls.py @@ -0,0 +1,362 @@ +# type: ignore +""" +Provides Nix specific instantiation of the LanguageServer class using nixd (Nix Language Server). + +Note: Windows is not supported as Nix itself doesn't support Windows natively. +""" + +import logging +import platform +import shutil +import subprocess +from pathlib import Path + +from overrides import override + +from solidlsp import ls_types +from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class NixLanguageServer(SolidLanguageServer): + """ + Provides Nix specific instantiation of the LanguageServer class using nixd. + """ + + def _extend_nix_symbol_range_to_include_semicolon( + self, symbol: ls_types.UnifiedSymbolInformation, file_content: str + ) -> ls_types.UnifiedSymbolInformation: + """ + Extend symbol range to include trailing semicolon for Nix attribute symbols. + + nixd provides ranges that exclude semicolons (expression-level), but serena needs + statement-level ranges that include semicolons for proper replacement. + """ + range_info = symbol["range"] + end_line = range_info["end"]["line"] + end_char = range_info["end"]["character"] + + # Split file content into lines + lines = file_content.split("\n") + if end_line >= len(lines): + return symbol + + line = lines[end_line] + + # Check if there's a semicolon immediately after the current range end + if end_char < len(line) and line[end_char] == ";": + # Extend range to include the semicolon + new_range = {"start": range_info["start"], "end": {"line": end_line, "character": end_char + 1}} + + # Create modified symbol with extended range + extended_symbol = symbol.copy() + extended_symbol["range"] = new_range + + # CRITICAL: Also update the location.range if it exists + if extended_symbol.get("location"): + location = extended_symbol["location"].copy() + if "range" in location: + location["range"] = new_range.copy() + extended_symbol["location"] = location + + return extended_symbol + + return symbol + + @override + def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: + # Override to extend Nix symbol ranges to include trailing semicolons. + # nixd provides expression-level ranges (excluding semicolons) but serena needs + # statement-level ranges (including semicolons) for proper symbol replacement. + + # Get symbols from parent implementation + document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer) + + # Get file content for range extension + file_content = self.language_server.retrieve_full_file_content(relative_file_path) + + # Extend ranges for all symbols recursively + def extend_symbol_and_children(symbol: ls_types.UnifiedSymbolInformation) -> ls_types.UnifiedSymbolInformation: + # Extend this symbol's range + extended = self._extend_nix_symbol_range_to_include_semicolon(symbol, file_content) + + # Extend children recursively + if extended.get("children"): + extended["children"] = [extend_symbol_and_children(child) for child in extended["children"]] + + return extended + + # Apply range extension to all symbols + extended_root_symbols = [extend_symbol_and_children(sym) for sym in document_symbols.root_symbols] + + return DocumentSymbols(extended_root_symbols) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # For Nix projects, we should ignore: + # - result: nix build output symlinks + # - result-*: multiple build outputs + # - .direnv: direnv cache + return super().is_ignored_dirname(dirname) or dirname in ["result", ".direnv"] or dirname.startswith("result-") + + @staticmethod + def _get_nixd_version(): + """Get the installed nixd version or None if not found.""" + try: + result = subprocess.run(["nixd", "--version"], capture_output=True, text=True, check=False) + if result.returncode == 0: + # nixd outputs version like: nixd 2.0.0 + return result.stdout.strip() + except FileNotFoundError: + return None + return None + + @staticmethod + def _check_nixd_installed(): + """Check if nixd is installed in the system.""" + return shutil.which("nixd") is not None + + @staticmethod + def _get_nixd_path(): + """Get the path to nixd executable.""" + # First check if it's in PATH + nixd_path = shutil.which("nixd") + if nixd_path: + return nixd_path + + # Check common installation locations + home = Path.home() + possible_paths = [ + home / ".local" / "bin" / "nixd", + home / ".serena" / "language_servers" / "nixd" / "nixd", + home / ".nix-profile" / "bin" / "nixd", + Path("/usr/local/bin/nixd"), + Path("/run/current-system/sw/bin/nixd"), # NixOS system profile + Path("/opt/homebrew/bin/nixd"), # Homebrew on Apple Silicon + Path("/usr/local/opt/nixd/bin/nixd"), # Homebrew on Intel Mac + ] + + # Add Windows-specific paths + if platform.system() == "Windows": + possible_paths.extend( + [ + home / "AppData" / "Local" / "nixd" / "nixd.exe", + home / ".serena" / "language_servers" / "nixd" / "nixd.exe", + ] + ) + + for path in possible_paths: + if path.exists(): + return str(path) + + return None + + @staticmethod + def _install_nixd_with_nix(): + """Install nixd using nix if available.""" + # Check if nix is available + if not shutil.which("nix"): + return None + + print("Installing nixd using nix... This may take a few minutes.") + try: + # Try to install nixd using nix profile + result = subprocess.run( + ["nix", "profile", "install", "github:nix-community/nixd"], + capture_output=True, + text=True, + check=False, + timeout=600, # 10 minute timeout for building + ) + + if result.returncode == 0: + # Check if nixd is now in PATH + nixd_path = shutil.which("nixd") + if nixd_path: + print(f"Successfully installed nixd at: {nixd_path}") + return nixd_path + else: + # Try nix-env as fallback + result = subprocess.run( + ["nix-env", "-iA", "nixpkgs.nixd"], + capture_output=True, + text=True, + check=False, + timeout=600, + ) + if result.returncode == 0: + nixd_path = shutil.which("nixd") + if nixd_path: + print(f"Successfully installed nixd at: {nixd_path}") + return nixd_path + print(f"Failed to install nixd: {result.stderr}") + + except subprocess.TimeoutExpired: + print("Nix install timed out after 10 minutes") + except Exception as e: + print(f"Error installing nixd with nix: {e}") + + return None + + @staticmethod + def _setup_runtime_dependency(): + """ + Check if required Nix runtime dependencies are available. + Attempts to install nixd if not present. + """ + # First check if Nix is available (nixd needs it at runtime) + if not shutil.which("nix"): + print("WARNING: Nix is not installed. nixd requires Nix to function properly.") + raise RuntimeError("Nix is required for nixd. Please install Nix from https://nixos.org/download.html") + + nixd_path = NixLanguageServer._get_nixd_path() + + if not nixd_path: + print("nixd not found. Attempting to install...") + + # Try to install with nix if available + nixd_path = NixLanguageServer._install_nixd_with_nix() + + if not nixd_path: + raise RuntimeError( + "nixd (Nix Language Server) is not installed.\n" + "Please install nixd using one of the following methods:\n" + " - Using Nix flakes: nix profile install github:nix-community/nixd\n" + " - From nixpkgs: nix-env -iA nixpkgs.nixd\n" + " - On macOS with Homebrew: brew install nixd\n\n" + "After installation, make sure 'nixd' is in your PATH." + ) + + # Verify nixd works + try: + result = subprocess.run([nixd_path, "--version"], capture_output=True, text=True, check=False, timeout=5) + if result.returncode != 0: + raise RuntimeError(f"nixd failed to run: {result.stderr}") + except Exception as e: + raise RuntimeError(f"Failed to verify nixd installation: {e}") + + return nixd_path + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + nixd_path = self._setup_runtime_dependency() + + super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=nixd_path, cwd=repository_root_path), "nix", solidlsp_settings) + self.request_id = 0 + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for nixd. + """ + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "completion": { + "dynamicRegistration": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + }, + }, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + }, + }, + "codeAction": { + "dynamicRegistration": True, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + ] + } + }, + }, + "rename": {"dynamicRegistration": True, "prepareSupport": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "configuration": True, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + }, + }, + "initializationOptions": { + # nixd specific options + "nixpkgs": {"expr": "import { }"}, + "formatting": {"command": ["nixpkgs-fmt"]}, # or ["alejandra"] or ["nixfmt"] + "options": { + "enable": True, + "target": { + "installable": "", # Will be auto-detected from flake.nix if present + }, + }, + }, + } + return initialize_params + + def _start_server(self): + """Start nixd server process""" + + def register_capability_handler(params): + return + + def window_log_message(msg): + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(params): + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting nixd server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + assert "documentSymbolProvider" in init_response["capabilities"] + assert "referencesProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + + # nixd server is typically ready immediately after initialization diff --git a/src/solidlsp/language_servers/ocaml_lsp_server.py b/src/solidlsp/language_servers/ocaml_lsp_server.py new file mode 100644 index 0000000..3a26e50 --- /dev/null +++ b/src/solidlsp/language_servers/ocaml_lsp_server.py @@ -0,0 +1,424 @@ +""" +Provides OCaml and Reason specific instantiation of the SolidLanguageServer class. +Contains various configurations and settings specific to OCaml and Reason. +""" + +import logging +import os +import platform +import re +import shutil +import stat +import subprocess +import threading +from typing import Any + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings +from solidlsp.util.subprocess_util import subprocess_kwargs + +log = logging.getLogger(__name__) + + +class OcamlLanguageServer(SolidLanguageServer): + """ + Provides OCaml and Reason specific instantiation of the SolidLanguageServer class. + Contains various configurations and settings specific to OCaml and Reason. + """ + + _ocaml_version: tuple[int, int, int] + _lsp_version: tuple[int, int, int] + _index_built: bool + + # Minimum LSP version for reliable cross-file references + MIN_LSP_VERSION_FOR_CROSS_FILE_REFS: tuple[int, int, int] = (1, 23, 0) + + @staticmethod + def _ensure_opam_installed() -> None: + """Ensure OPAM is installed and available.""" + opam_path = shutil.which("opam") + if opam_path is None: + raise RuntimeError( + "OPAM is not installed or not in PATH.\n" + "Please install OPAM from: https://opam.ocaml.org/doc/Install.html\n\n" + "Installation instructions:\n" + " - macOS: brew install opam\n" + " - Ubuntu/Debian: sudo apt install opam\n" + " - Fedora: sudo dnf install opam\n" + " - Windows: https://fdopen.github.io/opam-repository-mingw/installation/\n\n" + "After installation, initialize OPAM with: opam init" + ) + + @staticmethod + def _detect_ocaml_version(repository_root_path: str) -> tuple[int, int, int]: + """ + Detect and return the OCaml version as a tuple (major, minor, patch). + Also checks for version compatibility with ocaml-lsp-server. + Raises RuntimeError if version cannot be determined. + """ + try: + result = subprocess.run( + ["opam", "exec", "--", "ocaml", "-version"], + check=True, + capture_output=True, + text=True, + cwd=repository_root_path, + **subprocess_kwargs(), + ) + version_match = re.search(r"(\d+)\.(\d+)\.(\d+)", result.stdout) + if version_match: + major = int(version_match.group(1)) + minor = int(version_match.group(2)) + patch = int(version_match.group(3)) + version_tuple = (major, minor, patch) + version_str = f"{major}.{minor}.{patch}" + log.info(f"OCaml version: {version_str}") + + if version_tuple == (5, 1, 0): + raise RuntimeError( + f"OCaml {version_str} is incompatible with ocaml-lsp-server.\n" + "Please use OCaml < 5.1 or >= 5.1.1.\n" + "Consider creating a new opam switch:\n" + " opam switch create ocaml-base-compiler.4.14.2" + ) + return version_tuple + raise RuntimeError( + f"Could not parse OCaml version from output: {result.stdout.strip()}\n" + "Please ensure OCaml is properly installed: opam exec -- ocaml -version" + ) + except subprocess.CalledProcessError as e: + raise RuntimeError( + f"Failed to detect OCaml version: {e.stderr}\n" + "Please ensure OCaml is installed and opam is configured:\n" + " opam switch show\n" + " opam exec -- ocaml -version" + ) from e + except FileNotFoundError as e: + raise RuntimeError( + "OCaml not found. Please install OCaml via opam:\n" + " opam switch create ocaml-base-compiler.4.14.2\n" + " eval $(opam env)" + ) from e + + @staticmethod + def _detect_lsp_version(repository_root_path: str) -> tuple[int, int, int]: + """ + Detect and return the ocaml-lsp-server version as a tuple (major, minor, patch). + Raises RuntimeError if version cannot be determined. + """ + try: + result = subprocess.run( + ["opam", "list", "-i", "ocaml-lsp-server", "--columns=version", "--short"], + check=True, + capture_output=True, + text=True, + cwd=repository_root_path, + **subprocess_kwargs(), + ) + version_str = result.stdout.strip() + version_match = re.search(r"(\d+)\.(\d+)\.(\d+)", version_str) + if version_match: + major = int(version_match.group(1)) + minor = int(version_match.group(2)) + patch = int(version_match.group(3)) + version_tuple = (major, minor, patch) + log.info(f"ocaml-lsp-server version: {major}.{minor}.{patch}") + return version_tuple + raise RuntimeError( + f"Could not parse ocaml-lsp-server version from output: {version_str}\n" + "Please ensure ocaml-lsp-server is properly installed:\n" + " opam list -i ocaml-lsp-server" + ) + except subprocess.CalledProcessError as e: + raise RuntimeError( + f"Failed to detect ocaml-lsp-server version: {e.stderr}\nPlease install ocaml-lsp-server:\n opam install ocaml-lsp-server" + ) from e + except FileNotFoundError as e: + raise RuntimeError("opam not found. Please install opam:\n https://opam.ocaml.org/doc/Install.html") from e + + @staticmethod + def _ensure_ocaml_lsp_installed(repository_root_path: str) -> str: + """ + Ensure ocaml-lsp-server is installed and return the executable path. + Raises RuntimeError with helpful message if not installed. + """ + # Check if ocaml-lsp-server is installed + try: + result = subprocess.run( + ["opam", "list", "-i", "ocaml-lsp-server"], + check=False, + capture_output=True, + text=True, + cwd=repository_root_path, + **subprocess_kwargs(), + ) + if "ocaml-lsp-server" not in result.stdout or "# No matches found" in result.stdout: + raise RuntimeError( + "ocaml-lsp-server is not installed.\n\n" + "Please install it with:\n" + " opam install ocaml-lsp-server\n\n" + "Note: ocaml-lsp-server requires OCaml < 5.1 or >= 5.1.1 (OCaml 5.1.0 is not supported).\n" + "If you have OCaml 5.1.0, create a new opam switch with a compatible version:\n" + " opam switch create ocaml-base-compiler.4.14.2\n" + " opam switch \n" + " eval $(opam env)\n" + " opam install ocaml-lsp-server\n\n" + "For more information: https://github.com/ocaml/ocaml-lsp" + ) + log.info("ocaml-lsp-server is installed") + except subprocess.CalledProcessError as e: + raise RuntimeError(f"Failed to check ocaml-lsp-server installation: {e.stderr}") + + # Find the executable path + try: + if platform.system() == "Windows": + result = subprocess.run( + ["opam", "exec", "--", "where", "ocamllsp"], + check=True, + capture_output=True, + text=True, + cwd=repository_root_path, + **subprocess_kwargs(), + ) + executable_path = result.stdout.strip().split("\n")[0] + else: + result = subprocess.run( + ["opam", "exec", "--", "which", "ocamllsp"], + check=True, + capture_output=True, + text=True, + cwd=repository_root_path, + **subprocess_kwargs(), + ) + executable_path = result.stdout.strip() + + if not os.path.exists(executable_path): + raise RuntimeError(f"ocaml-lsp-server executable not found at {executable_path}") + + if platform.system() != "Windows": + os.chmod(executable_path, os.stat(executable_path).st_mode | stat.S_IEXEC) + + return executable_path + + except subprocess.CalledProcessError as e: + raise RuntimeError( + f"Failed to find ocaml-lsp-server executable.\n" + f"Command failed: {e.cmd}\n" + f"Return code: {e.returncode}\n" + f"Stderr: {e.stderr}\n\n" + "This usually means ocaml-lsp-server is not installed or not in PATH.\n" + "Try:\n" + " 1. Check opam switch: opam switch show\n" + " 2. Install ocaml-lsp-server: opam install ocaml-lsp-server\n" + " 3. Ensure opam env is activated: eval $(opam env)" + ) + + @property + def supports_cross_file_references(self) -> bool: + """ + Check if this OCaml environment supports cross-file references. + + Cross-file references require OCaml >= 5.2 with project-wide occurrences + AND ocaml-lsp-server >= 1.23.0 for reliable cross-file reference support. + Full requirements: + - OCaml 5.2+ + - ocaml-lsp-server >= 1.23.0 (earlier versions have unreliable cross-file refs) + - merlin >= 5.1-502 (provides ocaml-index tool) + - dune >= 3.16.0 + - Index built via `dune build @ocaml-index` + - For best results: `dune build -w` running (enables dune RPC) + + Note: Even when this returns True, cross-file refs may not work in all + cases. The LSP server needs dune's RPC server (via -w flag) to be fully + aware of the index. Without watch mode, cross-file refs are best-effort. + + See: https://discuss.ocaml.org/t/ann-project-wide-occurrences-in-merlin-and-lsp/14847 + """ + ocaml_ok = self._ocaml_version >= (5, 2, 0) + lsp_ok = self._lsp_version >= self.MIN_LSP_VERSION_FOR_CROSS_FILE_REFS + return ocaml_ok and lsp_ok and self._index_built + + @staticmethod + def _build_ocaml_index_static(repository_root_path: str) -> bool: + """ + Build the OCaml index for project-wide occurrences. + This enables cross-file reference finding on OCaml 5.2+. + Must be called BEFORE starting the LSP server. + Returns True if successful, False otherwise. + """ + log.info("Building OCaml index for cross-file references (dune build @ocaml-index)...") + try: + result = subprocess.run( + ["opam", "exec", "--", "dune", "build", "@ocaml-index"], + cwd=repository_root_path, + capture_output=True, + text=True, + timeout=120, + check=False, + **subprocess_kwargs(), + ) + if result.returncode == 0: + log.info("OCaml index built successfully") + return True + else: + log.warning(f"Failed to build OCaml index: {result.stderr}") + return False + except subprocess.TimeoutExpired: + log.warning("OCaml index build timed out after 120 seconds") + return False + except FileNotFoundError: + log.warning("opam not found, cannot build OCaml index") + return False + except Exception as e: + log.warning(f"Error building OCaml index: {e}") + return False + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates an OcamlLanguageServer instance. + This class is not meant to be instantiated directly. Use SolidLanguageServer.create() instead. + """ + # Ensure dependencies are available + self._ensure_opam_installed() + + # Detect OCaml version for feature gating + self._ocaml_version = self._detect_ocaml_version(repository_root_path) + self._index_built = False + + # Verify ocaml-lsp-server is installed (we don't need the path, just validation) + self._ensure_ocaml_lsp_installed(repository_root_path) + + # Detect LSP version for cross-file reference support + self._lsp_version = self._detect_lsp_version(repository_root_path) + + # Build OCaml index BEFORE starting server (required for cross-file refs on OCaml 5.2+) + if self._ocaml_version >= (5, 2, 0): + self._index_built = self._build_ocaml_index_static(repository_root_path) + + # Use opam exec to run ocamllsp - this ensures correct opam environment + # which is required for project-wide occurrences (cross-file references) to work + ocaml_lsp_cmd = ["opam", "exec", "--", "ocamllsp", "--fallback-read-dot-merlin"] + log.info(f"Using ocaml-lsp-server via: {' '.join(ocaml_lsp_cmd)}") + + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=ocaml_lsp_cmd, cwd=repository_root_path), + "ocaml", + solidlsp_settings, + ) + self.server_ready = threading.Event() + self.completions_available = threading.Event() + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + """Define language-specific directories to ignore for OCaml projects.""" + return super().is_ignored_dirname(dirname) or dirname in ["_build", "_opam", ".opam"] + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the OCaml Language Server. + Supports both OCaml and Reason. + """ + initialize_params = { + "locale": "en", + "capabilities": { + "workspace": { + "workspaceFolders": True, + "configuration": True, + }, + "textDocument": { + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [1, 2]}, + "codeDescriptionSupport": True, + "dataSupport": True, + }, + "diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False}, + "synchronization": { + "dynamicRegistration": True, + "willSave": True, + "willSaveWaitUntil": True, + "didSave": True, + }, + "completion": { + "dynamicRegistration": True, + "completionItem": { + "snippetSupport": True, + "documentationFormat": ["markdown", "plaintext"], + }, + }, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + }, + "formatting": {"dynamicRegistration": True}, + "rename": {"dynamicRegistration": True, "prepareSupport": True}, + }, + }, + "trace": "verbose", + } + return initialize_params + + def _start_server(self) -> None: + """ + Starts the OCaml Language Server (supports both OCaml and Reason) + """ + + def register_capability_handler(params: Any) -> None: + if "registrations" in params: + for registration in params.get("registrations", []): + method = registration.get("method", "") + log.info(f"OCaml LSP registered capability: {method}") + return + + def lang_status_handler(params: dict[str, Any]) -> None: + if params.get("type") == "ServiceReady" and params.get("message") == "ServiceReady": + self.server_ready.set() + + def do_nothing(params: Any) -> None: + return + + def window_log_message(msg: dict[str, Any]) -> None: + log.info(f"LSP: window/logMessage: {msg}") + if "initialization done" in msg.get("message", "").lower(): + self.server_ready.set() + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("language/status", lang_status_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting OCaml LSP server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + # Verify expected capabilities + capabilities = init_response.get("capabilities", {}) + log.info(f"OCaml LSP capabilities: {list(capabilities.keys())}") + + text_doc_sync = capabilities.get("textDocumentSync") + if isinstance(text_doc_sync, dict): + assert text_doc_sync.get("change") == 2, "Expected incremental sync" + assert "completionProvider" in capabilities, "Expected completion support" + + self.server.notify.initialized({}) + self.completions_available.set() + self.server_ready.set() + + log.info("OCaml Language Server initialized successfully") diff --git a/src/solidlsp/language_servers/omnisharp.py b/src/solidlsp/language_servers/omnisharp.py new file mode 100644 index 0000000..e64746e --- /dev/null +++ b/src/solidlsp/language_servers/omnisharp.py @@ -0,0 +1,413 @@ +""" +Provides C# specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C#. +""" + +import json +import logging +import os +import threading +from collections.abc import Iterable + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.ls_utils import DotnetVersion, FileUtils, PlatformId, PlatformUtils +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +OMNISHARP_ALLOWED_HOSTS = ("roslynomnisharp.blob.core.windows.net", "download.visualstudio.microsoft.com") +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_OMNISHARP_VERSION = "1.39.10" +INITIAL_RAZOR_OMNISHARP_VERSION = "7.0.0-preview.23363.1" +DEFAULT_OMNISHARP_VERSION = "1.39.10" +DEFAULT_RAZOR_OMNISHARP_VERSION = "7.0.0-preview.23363.1" + + +def breadth_first_file_scan(root: str) -> Iterable[str]: + """ + This function was obtained from https://stackoverflow.com/questions/49654234/is-there-a-breadth-first-search-option-available-in-os-walk-or-equivalent-py + It traverses the directory tree in breadth first order. + """ + dirs = [root] + # while we has dirs to scan + while dirs: + next_dirs = [] + for parent in dirs: + # scan each dir + for f in os.listdir(parent): + # if there is a dir, then save for next ittr + # if it is a file then yield it (we'll return later) + ff = os.path.join(parent, f) + if os.path.isdir(ff): + next_dirs.append(ff) + else: + yield ff + + # once we've done all the current dirs then + # we set up the next itter as the child dirs + # from the current itter. + dirs = next_dirs + + +def find_least_depth_sln_file(root_dir: str) -> str | None: + for filename in breadth_first_file_scan(root_dir): + if filename.endswith(".sln"): + return filename + return None + + +class OmniSharp(SolidLanguageServer): + """ + Provides C# specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C#. + + You can pass the following entries in ``ls_specific_settings["csharp_omnisharp"]``: + - omnisharp_version: Override the pinned OmniSharp version downloaded by Serena. + - razor_omnisharp_version: Override the pinned Razor plugin version downloaded by Serena. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates an OmniSharp instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + omnisharp_executable_path, dll_path = self._setup_runtime_dependencies(config, solidlsp_settings) + + slnfilename = find_least_depth_sln_file(repository_root_path) + if slnfilename is None: + log.error("No *.sln file found in repository") + raise SolidLSPException("No SLN file found in repository") + + cmd = " ".join( + [ + omnisharp_executable_path, + "-lsp", + "--encoding", + "ascii", + "-z", + "-s", + f'"{slnfilename}"', + "--hostPID", + str(os.getpid()), + "DotNet:enablePackageRestore=false", + "--loglevel", + "trace", + "--plugin", + dll_path, + "FileOptions:SystemExcludeSearchPatterns:0=**/.git", + "FileOptions:SystemExcludeSearchPatterns:1=**/.svn", + "FileOptions:SystemExcludeSearchPatterns:2=**/.hg", + "FileOptions:SystemExcludeSearchPatterns:3=**/CVS", + "FileOptions:SystemExcludeSearchPatterns:4=**/.DS_Store", + "FileOptions:SystemExcludeSearchPatterns:5=**/Thumbs.db", + "RoslynExtensionsOptions:EnableAnalyzersSupport=true", + "FormattingOptions:EnableEditorConfigSupport=true", + "RoslynExtensionsOptions:EnableImportCompletion=true", + "Sdk:IncludePrereleases=true", + "RoslynExtensionsOptions:AnalyzeOpenDocumentsOnly=true", + "formattingOptions:useTabs=false", + "formattingOptions:tabSize=4", + "formattingOptions:indentationSize=4", + ] + ) + super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), "csharp", solidlsp_settings) + + self.server_ready = threading.Event() + self.definition_available = threading.Event() + self.references_available = threading.Event() + self.completions_available = threading.Event() + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["bin", "obj"] + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Omnisharp Language Server. + """ + with open(os.path.join(os.path.dirname(__file__), "omnisharp", "initialize_params.json"), encoding="utf-8") as f: + d = json.load(f) + + del d["_description"] + + # processId, rootPath, rootUri and workspaceFolders are set centrally by the InitializeParamsBuilder + del d["rootPath"] + del d["rootUri"] + del d["workspaceFolders"] + + return d + + @classmethod + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> tuple[str, str]: + """ + Setup runtime dependencies for OmniSharp. + """ + platform_id = PlatformUtils.get_platform_id() + dotnet_version = PlatformUtils.get_dotnet_version() + + with open(os.path.join(os.path.dirname(__file__), "omnisharp", "runtime_dependencies.json"), encoding="utf-8") as f: + d = json.load(f) + del d["_description"] + omnisharp_settings = solidlsp_settings.get_ls_specific_settings(Language.CSHARP_OMNISHARP) + omnisharp_version = omnisharp_settings.get("omnisharp_version", DEFAULT_OMNISHARP_VERSION) + razor_omnisharp_version = omnisharp_settings.get("razor_omnisharp_version", DEFAULT_RAZOR_OMNISHARP_VERSION) + for dependency in d["runtimeDependencies"]: + if dependency["id"] == "OmniSharp": + dependency["url"] = dependency["url"].replace(DEFAULT_OMNISHARP_VERSION, omnisharp_version) + if "installPath" in dependency: + dependency["installPath"] = dependency["installPath"].replace(DEFAULT_OMNISHARP_VERSION, omnisharp_version) + if "installTestPath" in dependency: + dependency["installTestPath"] = dependency["installTestPath"].replace(DEFAULT_OMNISHARP_VERSION, omnisharp_version) + if omnisharp_version not in (INITIAL_OMNISHARP_VERSION, DEFAULT_OMNISHARP_VERSION): + dependency["integrity"] = None + elif dependency["id"] == "RazorOmnisharp": + dependency["url"] = dependency["url"].replace(DEFAULT_RAZOR_OMNISHARP_VERSION, razor_omnisharp_version) + if razor_omnisharp_version not in (INITIAL_RAZOR_OMNISHARP_VERSION, DEFAULT_RAZOR_OMNISHARP_VERSION): + dependency["integrity"] = None + + assert platform_id in [ + PlatformId.LINUX_x64, + PlatformId.WIN_x64, + ], f"Only linux-x64 and win-x64 platform is supported at the moment but got {platform_id=}" + assert dotnet_version in [ + DotnetVersion.V6, + DotnetVersion.V7, + DotnetVersion.V8, + DotnetVersion.V9, + ], f"Only dotnet version 6-9 are supported at the moment but got {dotnet_version=}" + + # TODO: Do away with this assumption + # Currently, runtime binaries are not available for .Net 7 and .Net 8. Hence, we assume .Net 6 runtime binaries to be compatible with .Net 7, .Net 8 + if dotnet_version in [DotnetVersion.V7, DotnetVersion.V8, DotnetVersion.V9]: + dotnet_version = DotnetVersion.V6 + + runtime_dependencies = d["runtimeDependencies"] + runtime_dependencies = [dependency for dependency in runtime_dependencies if dependency["platformId"] == platform_id.value] + runtime_dependencies = [ + dependency + for dependency in runtime_dependencies + if "dotnet_version" not in dependency or dependency["dotnet_version"] == dotnet_version.value + ] + assert len(runtime_dependencies) == 2 + runtime_dependencies = { + runtime_dependencies[0]["id"]: runtime_dependencies[0], + runtime_dependencies[1]["id"]: runtime_dependencies[1], + } + + assert "OmniSharp" in runtime_dependencies + assert "RazorOmnisharp" in runtime_dependencies + + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + omnisharp_dirname = "OmniSharp" if omnisharp_version == INITIAL_OMNISHARP_VERSION else f"OmniSharp-{omnisharp_version}" + omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), omnisharp_dirname) + if not os.path.exists(omnisharp_ls_dir): + os.makedirs(omnisharp_ls_dir) + FileUtils.download_and_extract_archive_verified( + runtime_dependencies["OmniSharp"]["url"], + omnisharp_ls_dir, + "zip", + expected_sha256=runtime_dependencies["OmniSharp"].get("integrity"), + allowed_hosts=OMNISHARP_ALLOWED_HOSTS, + ) + omnisharp_executable_path = os.path.join(omnisharp_ls_dir, runtime_dependencies["OmniSharp"]["binaryName"]) + assert os.path.exists(omnisharp_executable_path) + os.chmod(omnisharp_executable_path, 0o755) + + razor_dirname = ( + "RazorOmnisharp" if razor_omnisharp_version == INITIAL_RAZOR_OMNISHARP_VERSION else f"RazorOmnisharp-{razor_omnisharp_version}" + ) + razor_omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), razor_dirname) + if not os.path.exists(razor_omnisharp_ls_dir): + os.makedirs(razor_omnisharp_ls_dir) + FileUtils.download_and_extract_archive_verified( + runtime_dependencies["RazorOmnisharp"]["url"], + razor_omnisharp_ls_dir, + "zip", + expected_sha256=runtime_dependencies["RazorOmnisharp"].get("integrity"), + allowed_hosts=OMNISHARP_ALLOWED_HOSTS, + ) + razor_omnisharp_dll_path = os.path.join(razor_omnisharp_ls_dir, runtime_dependencies["RazorOmnisharp"]["dll_path"]) + assert os.path.exists(razor_omnisharp_dll_path) + + return omnisharp_executable_path, razor_omnisharp_dll_path + + def _start_server(self) -> None: + """ + Starts the Omnisharp Language Server + """ + + def register_capability_handler(params: dict) -> None: + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "textDocument/definition": + self.definition_available.set() + if registration["method"] == "textDocument/references": + self.references_available.set() + if registration["method"] == "textDocument/completion": + self.completions_available.set() + + def lang_status_handler(params: dict) -> None: + # TODO: Should we wait for + # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} + # Before proceeding? + # if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": + # self.service_ready_event.set() + pass + + def execute_client_command_handler(params: dict) -> list: + return [] + + def do_nothing(params: dict) -> None: + return + + def check_experimental_status(params: dict) -> None: + if params["quiescent"] is True: + self.server_ready.set() + + def workspace_configuration_handler(params: dict) -> list[dict]: + # TODO: We do not know the appropriate way to handle this request. Should ideally contact the OmniSharp dev team + return [ + { + "RoslynExtensionsOptions": { + "EnableDecompilationSupport": False, + "EnableAnalyzersSupport": True, + "EnableImportCompletion": True, + "EnableAsyncCompletion": False, + "DocumentAnalysisTimeoutMs": 30000, + "DiagnosticWorkersThreadCount": 18, + "AnalyzeOpenDocumentsOnly": True, + "InlayHintsOptions": { + "EnableForParameters": False, + "ForLiteralParameters": False, + "ForIndexerParameters": False, + "ForObjectCreationParameters": False, + "ForOtherParameters": False, + "SuppressForParametersThatDifferOnlyBySuffix": False, + "SuppressForParametersThatMatchMethodIntent": False, + "SuppressForParametersThatMatchArgumentName": False, + "EnableForTypes": False, + "ForImplicitVariableTypes": False, + "ForLambdaParameterTypes": False, + "ForImplicitObjectCreation": False, + }, + "LocationPaths": None, + }, + "FormattingOptions": { + "OrganizeImports": False, + "EnableEditorConfigSupport": True, + "NewLine": "\n", + "UseTabs": False, + "TabSize": 4, + "IndentationSize": 4, + "SpacingAfterMethodDeclarationName": False, + "SeparateImportDirectiveGroups": False, + "SpaceWithinMethodDeclarationParenthesis": False, + "SpaceBetweenEmptyMethodDeclarationParentheses": False, + "SpaceAfterMethodCallName": False, + "SpaceWithinMethodCallParentheses": False, + "SpaceBetweenEmptyMethodCallParentheses": False, + "SpaceAfterControlFlowStatementKeyword": True, + "SpaceWithinExpressionParentheses": False, + "SpaceWithinCastParentheses": False, + "SpaceWithinOtherParentheses": False, + "SpaceAfterCast": False, + "SpaceBeforeOpenSquareBracket": False, + "SpaceBetweenEmptySquareBrackets": False, + "SpaceWithinSquareBrackets": False, + "SpaceAfterColonInBaseTypeDeclaration": True, + "SpaceAfterComma": True, + "SpaceAfterDot": False, + "SpaceAfterSemicolonsInForStatement": True, + "SpaceBeforeColonInBaseTypeDeclaration": True, + "SpaceBeforeComma": False, + "SpaceBeforeDot": False, + "SpaceBeforeSemicolonsInForStatement": False, + "SpacingAroundBinaryOperator": "single", + "IndentBraces": False, + "IndentBlock": True, + "IndentSwitchSection": True, + "IndentSwitchCaseSection": True, + "IndentSwitchCaseSectionWhenBlock": True, + "LabelPositioning": "oneLess", + "WrappingPreserveSingleLine": True, + "WrappingKeepStatementsOnSingleLine": True, + "NewLinesForBracesInTypes": True, + "NewLinesForBracesInMethods": True, + "NewLinesForBracesInProperties": True, + "NewLinesForBracesInAccessors": True, + "NewLinesForBracesInAnonymousMethods": True, + "NewLinesForBracesInControlBlocks": True, + "NewLinesForBracesInAnonymousTypes": True, + "NewLinesForBracesInObjectCollectionArrayInitializers": True, + "NewLinesForBracesInLambdaExpressionBody": True, + "NewLineForElse": True, + "NewLineForCatch": True, + "NewLineForFinally": True, + "NewLineForMembersInObjectInit": True, + "NewLineForMembersInAnonymousTypes": True, + "NewLineForClausesInQuery": True, + }, + "FileOptions": { + "SystemExcludeSearchPatterns": [ + "**/node_modules/**/*", + "**/bin/**/*", + "**/obj/**/*", + "**/.git/**/*", + "**/.git", + "**/.svn", + "**/.hg", + "**/CVS", + "**/.DS_Store", + "**/Thumbs.db", + ], + "ExcludeSearchPatterns": [], + }, + "RenameOptions": { + "RenameOverloads": False, + "RenameInStrings": False, + "RenameInComments": False, + }, + "ImplementTypeOptions": { + "InsertionBehavior": 0, + "PropertyGenerationBehavior": 0, + }, + "DotNetCliOptions": {"LocationPaths": None}, + "Plugins": {"LocationPaths": None}, + } + ] + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("language/status", lang_status_handler) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + self.server.on_notification("experimental/serverStatus", check_experimental_status) + self.server.on_request("workspace/configuration", workspace_configuration_handler) + + log.info("Starting OmniSharp server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + self.server.notify.initialized({}) + with open(os.path.join(os.path.dirname(__file__), "omnisharp", "workspace_did_change_configuration.json"), encoding="utf-8") as f: + self.server.notify.workspace_did_change_configuration({"settings": json.load(f)}) + assert "capabilities" in init_response + if "definitionProvider" in init_response["capabilities"] and init_response["capabilities"]["definitionProvider"]: + self.definition_available.set() + if "referencesProvider" in init_response["capabilities"] and init_response["capabilities"]["referencesProvider"]: + self.references_available.set() + + missing_capabilities = [ + name + for event, name in ((self.definition_available, "definition"), (self.references_available, "references")) + if not event.is_set() + ] + if missing_capabilities: + log.warning("OmniSharp did not advertise capabilities during initialization: %s", ", ".join(missing_capabilities)) diff --git a/src/solidlsp/language_servers/omnisharp/initialize_params.json b/src/solidlsp/language_servers/omnisharp/initialize_params.json new file mode 100644 index 0000000..f9ee86c --- /dev/null +++ b/src/solidlsp/language_servers/omnisharp/initialize_params.json @@ -0,0 +1,631 @@ +{ + "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", + "processId": "os.getpid()", + "clientInfo": { + "name": "Visual Studio Code - Insiders", + "version": "1.82.0-insider" + }, + "locale": "en", + "rootPath": "$rootPath", + "rootUri": "$rootUri", + "capabilities": { + "workspace": { + "applyEdit": true, + "workspaceEdit": { + "documentChanges": true, + "resourceOperations": [ + "create", + "rename", + "delete" + ], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": true, + "changeAnnotationSupport": { + "groupsOnLabel": true + } + }, + "configuration": false, + "didChangeWatchedFiles": { + "dynamicRegistration": true, + "relativePatternSupport": true + }, + "symbol": { + "dynamicRegistration": true, + "symbolKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ] + }, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "resolveSupport": { + "properties": [ + "location.range" + ] + } + }, + "codeLens": { + "refreshSupport": true + }, + "executeCommand": { + "dynamicRegistration": true + }, + "didChangeConfiguration": { + "dynamicRegistration": true + }, + "workspaceFolders": true, + "semanticTokens": { + "refreshSupport": true + }, + "fileOperations": { + "dynamicRegistration": true, + "didCreate": true, + "didRename": true, + "didDelete": true, + "willCreate": true, + "willRename": true, + "willDelete": true + }, + "inlineValue": { + "refreshSupport": true + }, + "inlayHint": { + "refreshSupport": true + }, + "diagnostics": { + "refreshSupport": true + } + }, + "textDocument": { + "publishDiagnostics": { + "relatedInformation": true, + "versionSupport": false, + "tagSupport": { + "valueSet": [ + 1, + 2 + ] + }, + "codeDescriptionSupport": true, + "dataSupport": true + }, + "synchronization": { + "dynamicRegistration": true, + "willSave": true, + "willSaveWaitUntil": true, + "didSave": true + }, + "completion": { + "dynamicRegistration": true, + "contextSupport": true, + "completionItem": { + "snippetSupport": true, + "commitCharactersSupport": true, + "documentationFormat": [ + "markdown", + "plaintext" + ], + "deprecatedSupport": true, + "preselectSupport": true, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "insertReplaceSupport": true, + "resolveSupport": { + "properties": [ + "documentation", + "detail", + "additionalTextEdits" + ] + }, + "insertTextModeSupport": { + "valueSet": [ + 1, + 2 + ] + }, + "labelDetailsSupport": true + }, + "insertTextMode": 2, + "completionItemKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ] + }, + "completionList": { + "itemDefaults": [ + "commitCharacters", + "editRange", + "insertTextFormat", + "insertTextMode" + ] + } + }, + "hover": { + "dynamicRegistration": true, + "contentFormat": [ + "markdown", + "plaintext" + ] + }, + "signatureHelp": { + "dynamicRegistration": true, + "signatureInformation": { + "documentationFormat": [ + "markdown", + "plaintext" + ], + "parameterInformation": { + "labelOffsetSupport": true + }, + "activeParameterSupport": true + }, + "contextSupport": true + }, + "definition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "references": { + "dynamicRegistration": true + }, + "documentHighlight": { + "dynamicRegistration": true + }, + "documentSymbol": { + "dynamicRegistration": true, + "symbolKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ] + }, + "hierarchicalDocumentSymbolSupport": true, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "labelSupport": true + }, + "codeAction": { + "dynamicRegistration": true, + "isPreferredSupport": true, + "disabledSupport": true, + "dataSupport": true, + "resolveSupport": { + "properties": [ + "edit" + ] + }, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports" + ] + } + }, + "honorsChangeAnnotations": false + }, + "codeLens": { + "dynamicRegistration": true + }, + "formatting": { + "dynamicRegistration": true + }, + "rangeFormatting": { + "dynamicRegistration": true + }, + "onTypeFormatting": { + "dynamicRegistration": true + }, + "rename": { + "dynamicRegistration": true, + "prepareSupport": true, + "prepareSupportDefaultBehavior": 1, + "honorsChangeAnnotations": true + }, + "documentLink": { + "dynamicRegistration": true, + "tooltipSupport": true + }, + "typeDefinition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "implementation": { + "dynamicRegistration": true, + "linkSupport": true + }, + "colorProvider": { + "dynamicRegistration": true + }, + "foldingRange": { + "dynamicRegistration": true, + "rangeLimit": 5000, + "lineFoldingOnly": true, + "foldingRangeKind": { + "valueSet": [ + "comment", + "imports", + "region" + ] + }, + "foldingRange": { + "collapsedText": false + } + }, + "declaration": { + "dynamicRegistration": true, + "linkSupport": true + }, + "selectionRange": { + "dynamicRegistration": true + }, + "callHierarchy": { + "dynamicRegistration": true + }, + "semanticTokens": { + "dynamicRegistration": true, + "tokenTypes": [ + "namespace", + "type", + "class", + "enum", + "interface", + "struct", + "typeParameter", + "parameter", + "variable", + "property", + "enumMember", + "event", + "function", + "method", + "macro", + "keyword", + "modifier", + "comment", + "string", + "number", + "regexp", + "operator", + "decorator" + ], + "tokenModifiers": [ + "declaration", + "definition", + "readonly", + "static", + "deprecated", + "abstract", + "async", + "modification", + "documentation", + "defaultLibrary" + ], + "formats": [ + "relative" + ], + "requests": { + "range": true, + "full": { + "delta": true + } + }, + "multilineTokenSupport": false, + "overlappingTokenSupport": false, + "serverCancelSupport": true, + "augmentsSyntaxTokens": false + }, + "linkedEditingRange": { + "dynamicRegistration": true + }, + "typeHierarchy": { + "dynamicRegistration": true + }, + "inlineValue": { + "dynamicRegistration": true + }, + "inlayHint": { + "dynamicRegistration": true, + "resolveSupport": { + "properties": [ + "tooltip", + "textEdits", + "label.tooltip", + "label.location", + "label.command" + ] + } + }, + "diagnostic": { + "dynamicRegistration": true, + "relatedDocumentSupport": false + } + }, + "window": { + "showMessage": { + "messageActionItem": { + "additionalPropertiesSupport": true + } + }, + "showDocument": { + "support": true + }, + "workDoneProgress": true + }, + "general": { + "staleRequestSupport": { + "cancel": true, + "retryOnContentModified": [ + "textDocument/semanticTokens/full", + "textDocument/semanticTokens/range", + "textDocument/semanticTokens/full/delta" + ] + }, + "regularExpressions": { + "engine": "ECMAScript", + "version": "ES2020" + }, + "markdown": { + "parser": "marked", + "version": "1.1.0", + "allowedTags": [ + "ul", + "li", + "p", + "code", + "blockquote", + "ol", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "em", + "pre", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + "div", + "del", + "a", + "strong", + "br", + "img", + "span" + ] + }, + "positionEncodings": [ + "utf-16" + ] + }, + "notebookDocument": { + "synchronization": { + "dynamicRegistration": true, + "executionSummarySupport": true + } + }, + "experimental": { + "snippetTextEdit": true, + "codeActionGroup": true, + "hoverActions": true, + "serverStatusNotification": true, + "colorDiagnosticOutput": true, + "openServerLogs": true, + "commands": { + "commands": [ + "editor.action.triggerParameterHints" + ] + } + } + }, + "initializationOptions": { + "RoslynExtensionsOptions": { + "EnableDecompilationSupport": false, + "EnableAnalyzersSupport": true, + "EnableImportCompletion": true, + "EnableAsyncCompletion": false, + "DocumentAnalysisTimeoutMs": 30000, + "DiagnosticWorkersThreadCount": 18, + "AnalyzeOpenDocumentsOnly": true, + "InlayHintsOptions": { + "EnableForParameters": false, + "ForLiteralParameters": false, + "ForIndexerParameters": false, + "ForObjectCreationParameters": false, + "ForOtherParameters": false, + "SuppressForParametersThatDifferOnlyBySuffix": false, + "SuppressForParametersThatMatchMethodIntent": false, + "SuppressForParametersThatMatchArgumentName": false, + "EnableForTypes": false, + "ForImplicitVariableTypes": false, + "ForLambdaParameterTypes": false, + "ForImplicitObjectCreation": false + }, + "LocationPaths": null + }, + "FormattingOptions": { + "OrganizeImports": false, + "EnableEditorConfigSupport": true, + "NewLine": "\n", + "UseTabs": false, + "TabSize": 4, + "IndentationSize": 4, + "SpacingAfterMethodDeclarationName": false, + "SeparateImportDirectiveGroups": false, + "SpaceWithinMethodDeclarationParenthesis": false, + "SpaceBetweenEmptyMethodDeclarationParentheses": false, + "SpaceAfterMethodCallName": false, + "SpaceWithinMethodCallParentheses": false, + "SpaceBetweenEmptyMethodCallParentheses": false, + "SpaceAfterControlFlowStatementKeyword": true, + "SpaceWithinExpressionParentheses": false, + "SpaceWithinCastParentheses": false, + "SpaceWithinOtherParentheses": false, + "SpaceAfterCast": false, + "SpaceBeforeOpenSquareBracket": false, + "SpaceBetweenEmptySquareBrackets": false, + "SpaceWithinSquareBrackets": false, + "SpaceAfterColonInBaseTypeDeclaration": true, + "SpaceAfterComma": true, + "SpaceAfterDot": false, + "SpaceAfterSemicolonsInForStatement": true, + "SpaceBeforeColonInBaseTypeDeclaration": true, + "SpaceBeforeComma": false, + "SpaceBeforeDot": false, + "SpaceBeforeSemicolonsInForStatement": false, + "SpacingAroundBinaryOperator": "single", + "IndentBraces": false, + "IndentBlock": true, + "IndentSwitchSection": true, + "IndentSwitchCaseSection": true, + "IndentSwitchCaseSectionWhenBlock": true, + "LabelPositioning": "oneLess", + "WrappingPreserveSingleLine": true, + "WrappingKeepStatementsOnSingleLine": true, + "NewLinesForBracesInTypes": true, + "NewLinesForBracesInMethods": true, + "NewLinesForBracesInProperties": true, + "NewLinesForBracesInAccessors": true, + "NewLinesForBracesInAnonymousMethods": true, + "NewLinesForBracesInControlBlocks": true, + "NewLinesForBracesInAnonymousTypes": true, + "NewLinesForBracesInObjectCollectionArrayInitializers": true, + "NewLinesForBracesInLambdaExpressionBody": true, + "NewLineForElse": true, + "NewLineForCatch": true, + "NewLineForFinally": true, + "NewLineForMembersInObjectInit": true, + "NewLineForMembersInAnonymousTypes": true, + "NewLineForClausesInQuery": true + }, + "FileOptions": { + "SystemExcludeSearchPatterns": [ + "**/node_modules/**/*", + "**/bin/**/*", + "**/obj/**/*", + "**/.git/**/*", + "**/.git", + "**/.svn", + "**/.hg", + "**/CVS", + "**/.DS_Store", + "**/Thumbs.db" + ], + "ExcludeSearchPatterns": [] + }, + "RenameOptions": { + "RenameOverloads": false, + "RenameInStrings": false, + "RenameInComments": false + }, + "ImplementTypeOptions": { + "InsertionBehavior": 0, + "PropertyGenerationBehavior": 0 + }, + "DotNetCliOptions": { + "LocationPaths": null + }, + "Plugins": { + "LocationPaths": null + } + }, + "trace": "verbose", + "workspaceFolders": [ + { + "uri": "$uri", + "name": "$name" + } + ] +} \ No newline at end of file diff --git a/src/solidlsp/language_servers/omnisharp/runtime_dependencies.json b/src/solidlsp/language_servers/omnisharp/runtime_dependencies.json new file mode 100644 index 0000000..7f884b9 --- /dev/null +++ b/src/solidlsp/language_servers/omnisharp/runtime_dependencies.json @@ -0,0 +1,450 @@ +{ + "_description": "Used to download the runtime dependencies for running OmniSharp. Obtained from https://github.com/dotnet/vscode-csharp/blob/main/package.json", + "runtimeDependencies": [ + { + "id": "OmniSharp", + "description": "OmniSharp for Windows (.NET 4 / x86)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-win-x86-1.39.10.zip", + "installPath": ".omnisharp/1.39.10", + "platforms": [ + "win32" + ], + "architectures": [ + "x86" + ], + "installTestPath": "./.omnisharp/1.39.10/OmniSharp.exe", + "platformId": "win-x86", + "isFramework": true, + "integrity": "C81CE2099AD494EF63F9D88FAA70D55A68CF175810F944526FF94AAC7A5109F9", + "dotnet_version": "4", + "binaryName": "OmniSharp.exe" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Windows (.NET 6 / x86)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-win-x86-net6.0-1.39.10.zip", + "installPath": ".omnisharp/1.39.10-net6.0", + "platforms": [ + "win32" + ], + "architectures": [ + "x86" + ], + "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", + "platformId": "win-x86", + "isFramework": false, + "integrity": "B7E62415CFC3DAC2154AC636C5BF0FB4B2C9BBF11B5A1FBF72381DDDED59791E", + "dotnet_version": "6", + "binaryName": "OmniSharp.exe" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Windows (.NET 4 / x64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-win-x64-1.39.10.zip", + "installPath": ".omnisharp/1.39.10", + "platforms": [ + "win32" + ], + "architectures": [ + "x86_64" + ], + "installTestPath": "./.omnisharp/1.39.10/OmniSharp.exe", + "platformId": "win-x64", + "isFramework": true, + "integrity": "BE0ED10AACEA17E14B78BD0D887DE5935D4ECA3712192A701F3F2100CA3C8B6E", + "dotnet_version": "4", + "binaryName": "OmniSharp.exe" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Windows (.NET 6 / x64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-win-x64-net6.0-1.39.10.zip", + "installPath": ".omnisharp/1.39.10-net6.0", + "platforms": [ + "win32" + ], + "architectures": [ + "x86_64" + ], + "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", + "platformId": "win-x64", + "isFramework": false, + "integrity": "A73327395E7EF92C1D8E307055463DA412662C03F077ECC743462FD2760BB537", + "dotnet_version": "6", + "binaryName": "OmniSharp.exe" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Windows (.NET 4 / arm64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-win-arm64-1.39.10.zip", + "installPath": ".omnisharp/1.39.10", + "platforms": [ + "win32" + ], + "architectures": [ + "arm64" + ], + "installTestPath": "./.omnisharp/1.39.10/OmniSharp.exe", + "platformId": "win-arm64", + "isFramework": true, + "integrity": "32FA0067B0639F87760CD1A769B16E6A53588C137C4D31661836CA4FB28D3DD6", + "dotnet_version": "4", + "binaryName": "OmniSharp.exe" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Windows (.NET 6 / arm64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-win-arm64-net6.0-1.39.10.zip", + "installPath": ".omnisharp/1.39.10-net6.0", + "platforms": [ + "win32" + ], + "architectures": [ + "arm64" + ], + "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", + "platformId": "win-arm64", + "isFramework": false, + "integrity": "433F9B360CAA7B4DDD85C604D5C5542C1A718BCF2E71B2BCFC7526E6D41F4E8F", + "dotnet_version": "6", + "binaryName": "OmniSharp.exe" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for OSX (Mono / x64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-osx-1.39.10.zip", + "installPath": ".omnisharp/1.39.10", + "platforms": [ + "darwin" + ], + "architectures": [ + "x86_64", + "arm64" + ], + "binaries": [ + "./mono.osx", + "./run" + ], + "installTestPath": "./.omnisharp/1.39.10/run", + "platformId": "osx", + "isFramework": true, + "integrity": "2CC42F0EC7C30CFA8858501D12ECB6FB685A1FCFB8ECB35698A4B12406551968", + "dotnet_version": "mono" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for OSX (.NET 6 / x64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-osx-x64-net6.0-1.39.10.zip", + "installPath": ".omnisharp/1.39.10-net6.0", + "platforms": [ + "darwin" + ], + "architectures": [ + "x86_64" + ], + "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", + "platformId": "osx-x64", + "isFramework": false, + "integrity": "C9D6E9F2C839A66A7283AE6A9EC545EE049B48EB230D33E91A6322CB67FF9D97", + "dotnet_version": "6" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for OSX (.NET 6 / arm64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-osx-arm64-net6.0-1.39.10.zip", + "installPath": ".omnisharp/1.39.10-net6.0", + "platforms": [ + "darwin" + ], + "architectures": [ + "arm64" + ], + "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", + "platformId": "osx-arm64", + "isFramework": false, + "integrity": "851350F52F83E3BAD5A92D113E4B9882FCD1DEB16AA84FF94B6F2CEE3C70051E", + "dotnet_version": "6" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Linux (Mono / x86)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-linux-x86-1.39.10.zip", + "installPath": ".omnisharp/1.39.10", + "platforms": [ + "linux" + ], + "architectures": [ + "x86", + "i686" + ], + "binaries": [ + "./mono.linux-x86", + "./run" + ], + "installTestPath": "./.omnisharp/1.39.10/run", + "platformId": "linux-x86", + "isFramework": true, + "integrity": "474B1CDBAE64CFEC655FB6B0659BCE481023C48274441C72991E67B6E13E56A1", + "dotnet_version": "mono" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Linux (Mono / x64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-linux-x64-1.39.10.zip", + "installPath": ".omnisharp/1.39.10", + "platforms": [ + "linux" + ], + "architectures": [ + "x86_64" + ], + "binaries": [ + "./mono.linux-x86_64", + "./run" + ], + "installTestPath": "./.omnisharp/1.39.10/run", + "platformId": "linux-x64", + "isFramework": true, + "integrity": "FB4CAA47343265100349375D79DBCCE1868950CED675CB07FCBE8462EDBCDD37", + "dotnet_version": "mono" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Linux (.NET 6 / x64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-linux-x64-net6.0-1.39.10.zip", + "installPath": ".omnisharp/1.39.10-net6.0", + "platforms": [ + "linux" + ], + "architectures": [ + "x86_64" + ], + "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", + "platformId": "linux-x64", + "isFramework": false, + "integrity": "0926D3BEA060BF4373356B2FC0A68C10D0DE1B1150100B551BA5932814CE51E2", + "dotnet_version": "6", + "binaryName": "OmniSharp" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Linux (Mono / arm64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-linux-arm64-1.39.10.zip", + "installPath": ".omnisharp/1.39.10", + "platforms": [ + "linux" + ], + "architectures": [ + "arm64" + ], + "binaries": [ + "./mono.linux-arm64", + "./run" + ], + "installTestPath": "./.omnisharp/1.39.10/run", + "platformId": "linux-arm64", + "isFramework": true, + "integrity": "478F3594DFD0167E9A56E36F0364A86C73F8132A3E7EA916CA1419EFE141D2CC", + "dotnet_version": "mono" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Linux (.NET 6 / arm64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-linux-arm64-net6.0-1.39.10.zip", + "installPath": ".omnisharp/1.39.10-net6.0", + "platforms": [ + "linux" + ], + "architectures": [ + "arm64" + ], + "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", + "platformId": "linux-arm64", + "isFramework": false, + "integrity": "6FB6A572043A74220A92F6C19C7BB0C3743321C7563A815FD2702EF4FA7D688E", + "dotnet_version": "6" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Linux musl (.NET 6 / x64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-linux-musl-x64-net6.0-1.39.10.zip", + "installPath": ".omnisharp/1.39.10-net6.0", + "platforms": [ + "linux-musl" + ], + "architectures": [ + "x86_64" + ], + "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", + "platformId": "linux-musl-x64", + "isFramework": false, + "integrity": "6BFDA3AD11DBB0C6514B86ECC3E1597CC41C6E309B7575F7C599E07D9E2AE610", + "dotnet_version": "6" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Linux musl (.NET 6 / arm64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-linux-musl-arm64-net6.0-1.39.10.zip", + "installPath": ".omnisharp/1.39.10-net6.0", + "platforms": [ + "linux-musl" + ], + "architectures": [ + "arm64" + ], + "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", + "platformId": "linux-musl-arm64", + "isFramework": false, + "integrity": "DA63619EA024EB9BBF6DB5A85C6150CAB5C0BD554544A3596ED1B17F926D6875", + "dotnet_version": "6" + }, + { + "id": "RazorOmnisharp", + "description": "Razor Language Server for OmniSharp (Windows / x64)", + "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/8d42e62ea4051381c219b3e31bc4eced/razorlanguageserver-win-x64-7.0.0-preview.23363.1.zip", + "installPath": ".razoromnisharp", + "platforms": [ + "win32" + ], + "architectures": [ + "x86_64" + ], + "platformId": "win-x64", + "dll_path": "OmniSharpPlugin/Microsoft.AspNetCore.Razor.OmniSharpPlugin.dll", + "integrity": "8D2DC5484018A2390A2366D05CADDD2BE21F619122DACD1BA1A87815B98D7361" + }, + { + "id": "RazorOmnisharp", + "description": "Razor Language Server for OmniSharp (Windows / x86)", + "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/e440c4f3a4a96334fe177513935fa010/razorlanguageserver-win-x86-7.0.0-preview.23363.1.zip", + "installPath": ".razoromnisharp", + "platforms": [ + "win32" + ], + "architectures": [ + "x86" + ], + "platformId": "win-x86", + "dll_path": "OmniSharpPlugin/Microsoft.AspNetCore.Razor.OmniSharpPlugin.dll", + "integrity": "94C365B9D4B8D17612AF3574A3ABFB8CA75727866A8AF892EEEBE8F7559729A7" + }, + { + "id": "RazorOmnisharp", + "description": "Razor Language Server for OmniSharp (Windows / ARM64)", + "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/4ef26e45cf32fe8d51c0e7dd21f1fef6/razorlanguageserver-win-arm64-7.0.0-preview.23363.1.zip", + "installPath": ".razoromnisharp", + "platforms": [ + "win32" + ], + "architectures": [ + "arm64" + ], + "platformId": "win-arm64", + "dll_path": "OmniSharpPlugin/Microsoft.AspNetCore.Razor.OmniSharpPlugin.dll", + "integrity": "2186E17AB20621C13A5B04F7053BDC744A9938AF6F9FA6259208F09F55999946" + }, + { + "id": "RazorOmnisharp", + "description": "Razor Language Server for OmniSharp (Linux / x64)", + "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/6d4e23a3c7cf0465743950a39515a716/razorlanguageserver-linux-x64-7.0.0-preview.23363.1.zip", + "installPath": ".razoromnisharp", + "platforms": [ + "linux" + ], + "architectures": [ + "x86_64" + ], + "binaries": [ + "./rzls" + ], + "platformId": "linux-x64", + "dll_path": "OmniSharpPlugin/Microsoft.AspNetCore.Razor.OmniSharpPlugin.dll", + "integrity": "4B08C47D70AA96BE40AEDE22CDFCD909EB9073F1E1992E2B5B99D9D7A47F3276" + }, + { + "id": "RazorOmnisharp", + "description": "Razor Language Server for OmniSharp (Linux ARM64)", + "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/85deebd44647ebf65724cc291d722283/razorlanguageserver-linux-arm64-7.0.0-preview.23363.1.zip", + "installPath": ".razoromnisharp", + "platforms": [ + "linux" + ], + "architectures": [ + "arm64" + ], + "binaries": [ + "./rzls" + ], + "platformId": "linux-arm64", + "integrity": "A73FA7D0AC5E4DAC9FF15B9B675D90E30DFB45E40A66ECC70E22433EE028791C" + }, + { + "id": "RazorOmnisharp", + "description": "Razor Language Server for OmniSharp (Linux musl / x64)", + "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/4f0caa94ae182785655efb15eafcef23/razorlanguageserver-linux-musl-x64-7.0.0-preview.23363.1.zip", + "installPath": ".razoromnisharp", + "platforms": [ + "linux-musl" + ], + "architectures": [ + "x86_64" + ], + "binaries": [ + "./rzls" + ], + "platformId": "linux-musl-x64", + "integrity": "7B0F8B1FEAC7726A5AC8D2C19F19B3530615F9226F75BB40D74EB0D8757CD11C" + }, + { + "id": "RazorOmnisharp", + "description": "Razor Language Server for OmniSharp (Linux musl ARM64)", + "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/0a24828206a6f3b4bc743d058ef88ce7/razorlanguageserver-linux-musl-arm64-7.0.0-preview.23363.1.zip", + "installPath": ".razoromnisharp", + "platforms": [ + "linux-musl" + ], + "architectures": [ + "arm64" + ], + "binaries": [ + "./rzls" + ], + "platformId": "linux-musl-arm64", + "integrity": "8BC53D60D53B7FC9E40144D862B0748BCB8768C7C18C8E6900D675DED14ABD08" + }, + { + "id": "RazorOmnisharp", + "description": "Razor Language Server for OmniSharp (macOS / x64)", + "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/2afcafaf41082989efcc10405abb9314/razorlanguageserver-osx-x64-7.0.0-preview.23363.1.zip", + "installPath": ".razoromnisharp", + "platforms": [ + "darwin" + ], + "architectures": [ + "x86_64" + ], + "binaries": [ + "./rzls" + ], + "platformId": "osx-x64", + "integrity": "7678022CBAC975A6D45E01A2985B88307E88F110578F3C04DB9D1C545BC69BDB" + }, + { + "id": "RazorOmnisharp", + "description": "Razor Language Server for OmniSharp (macOS ARM64)", + "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/8bf2ed2f00d481a5987e3eb5165afddd/razorlanguageserver-osx-arm64-7.0.0-preview.23363.1.zip", + "installPath": ".razoromnisharp", + "platforms": [ + "darwin" + ], + "architectures": [ + "arm64" + ], + "binaries": [ + "./rzls" + ], + "platformId": "osx-arm64", + "integrity": "0A68EE21F5B8DDFDFC8D5E86E519C54D29B3A4D582F4D4284545C40A741AAFE6" + } + ] +} diff --git a/src/solidlsp/language_servers/omnisharp/workspace_did_change_configuration.json b/src/solidlsp/language_servers/omnisharp/workspace_did_change_configuration.json new file mode 100644 index 0000000..148841c --- /dev/null +++ b/src/solidlsp/language_servers/omnisharp/workspace_did_change_configuration.json @@ -0,0 +1,111 @@ +{ + "RoslynExtensionsOptions": { + "EnableDecompilationSupport": false, + "EnableAnalyzersSupport": true, + "EnableImportCompletion": true, + "EnableAsyncCompletion": false, + "DocumentAnalysisTimeoutMs": 30000, + "DiagnosticWorkersThreadCount": 18, + "AnalyzeOpenDocumentsOnly": true, + "InlayHintsOptions": { + "EnableForParameters": false, + "ForLiteralParameters": false, + "ForIndexerParameters": false, + "ForObjectCreationParameters": false, + "ForOtherParameters": false, + "SuppressForParametersThatDifferOnlyBySuffix": false, + "SuppressForParametersThatMatchMethodIntent": false, + "SuppressForParametersThatMatchArgumentName": false, + "EnableForTypes": false, + "ForImplicitVariableTypes": false, + "ForLambdaParameterTypes": false, + "ForImplicitObjectCreation": false + }, + "LocationPaths": null + }, + "FormattingOptions": { + "OrganizeImports": false, + "EnableEditorConfigSupport": true, + "NewLine": "\n", + "UseTabs": false, + "TabSize": 4, + "IndentationSize": 4, + "SpacingAfterMethodDeclarationName": false, + "SeparateImportDirectiveGroups": false, + "SpaceWithinMethodDeclarationParenthesis": false, + "SpaceBetweenEmptyMethodDeclarationParentheses": false, + "SpaceAfterMethodCallName": false, + "SpaceWithinMethodCallParentheses": false, + "SpaceBetweenEmptyMethodCallParentheses": false, + "SpaceAfterControlFlowStatementKeyword": true, + "SpaceWithinExpressionParentheses": false, + "SpaceWithinCastParentheses": false, + "SpaceWithinOtherParentheses": false, + "SpaceAfterCast": false, + "SpaceBeforeOpenSquareBracket": false, + "SpaceBetweenEmptySquareBrackets": false, + "SpaceWithinSquareBrackets": false, + "SpaceAfterColonInBaseTypeDeclaration": true, + "SpaceAfterComma": true, + "SpaceAfterDot": false, + "SpaceAfterSemicolonsInForStatement": true, + "SpaceBeforeColonInBaseTypeDeclaration": true, + "SpaceBeforeComma": false, + "SpaceBeforeDot": false, + "SpaceBeforeSemicolonsInForStatement": false, + "SpacingAroundBinaryOperator": "single", + "IndentBraces": false, + "IndentBlock": true, + "IndentSwitchSection": true, + "IndentSwitchCaseSection": true, + "IndentSwitchCaseSectionWhenBlock": true, + "LabelPositioning": "oneLess", + "WrappingPreserveSingleLine": true, + "WrappingKeepStatementsOnSingleLine": true, + "NewLinesForBracesInTypes": true, + "NewLinesForBracesInMethods": true, + "NewLinesForBracesInProperties": true, + "NewLinesForBracesInAccessors": true, + "NewLinesForBracesInAnonymousMethods": true, + "NewLinesForBracesInControlBlocks": true, + "NewLinesForBracesInAnonymousTypes": true, + "NewLinesForBracesInObjectCollectionArrayInitializers": true, + "NewLinesForBracesInLambdaExpressionBody": true, + "NewLineForElse": true, + "NewLineForCatch": true, + "NewLineForFinally": true, + "NewLineForMembersInObjectInit": true, + "NewLineForMembersInAnonymousTypes": true, + "NewLineForClausesInQuery": true + }, + "FileOptions": { + "SystemExcludeSearchPatterns": [ + "**/node_modules/**/*", + "**/bin/**/*", + "**/obj/**/*", + "**/.git/**/*", + "**/.git", + "**/.svn", + "**/.hg", + "**/CVS", + "**/.DS_Store", + "**/Thumbs.db" + ], + "ExcludeSearchPatterns": [] + }, + "RenameOptions": { + "RenameOverloads": false, + "RenameInStrings": false, + "RenameInComments": false + }, + "ImplementTypeOptions": { + "InsertionBehavior": 0, + "PropertyGenerationBehavior": 0 + }, + "DotNetCliOptions": { + "LocationPaths": null + }, + "Plugins": { + "LocationPaths": null + } +} \ No newline at end of file diff --git a/src/solidlsp/language_servers/pascal_server.py b/src/solidlsp/language_servers/pascal_server.py new file mode 100644 index 0000000..e27831f --- /dev/null +++ b/src/solidlsp/language_servers/pascal_server.py @@ -0,0 +1,962 @@ +""" +Provides Pascal/Free Pascal specific instantiation of the LanguageServer class using pasls. +Contains various configurations and settings specific to Pascal and Free Pascal. + +pasls installation strategy: +1. Use existing pasls from PATH +2. Download a pinned prebuilt binary from GitHub releases + +Supported platforms for binary download: +- linux-x64, linux-arm64 +- osx-x64, osx-arm64 +- win-x64 + +Integrity features: +- SHA256 checksum verification before installation +- Atomic update with rollback on failure +- Windows file locking detection + +You can pass the following entries in ls_specific_settings["pascal"]: + +Version management: +- pasls_version: Override the pinned pasls version downloaded by Serena + (default: the bundled Serena version). + +Environment variables (recommended for CodeTools configuration): +- pp: Path to FPC compiler driver, must be "fpc.exe" (e.g., "D:/laz32/fpc/bin/i386-win32/fpc.exe"). + Do NOT use backend compilers like ppc386.exe or ppcx64.exe - CodeTools queries fpc.exe for + configuration (fpc -iV, fpc -iTO, etc.). This is the most important setting for hover/navigation. +- fpcdir: Path to FPC source directory (e.g., "D:/laz32/fpcsrc"). Helps CodeTools locate + standard library sources for better navigation. +- lazarusdir: Path to Lazarus directory (e.g., "D:/laz32/lazarus"). Required for Lazarus + projects using LCL and other Lazarus components. + +Target platform overrides (use only if pp setting is not sufficient): +- fpc_target: Override target OS (e.g., "Win32", "Win64", "Linux"). Sets FPCTARGET env var. +- fpc_target_cpu: Override target CPU (e.g., "i386", "x86_64", "aarch64"). Sets FPCTARGETCPU. + +Example configuration in ~/.serena/serena_config.yml: + ls_specific_settings: + pascal: + pp: "D:/laz32/fpc/bin/i386-win32/fpc.exe" + fpcdir: "D:/laz32/fpcsrc" + lazarusdir: "D:/laz32/lazarus" +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import platform +import shutil +import tarfile +import tempfile +import threading +import time +import urllib.error +import urllib.request +import uuid +import zipfile + +from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, quote_windows_path +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_PASLS_VERSION = "v0.2.0" +INITIAL_PASLS_SHA256_BY_PLATFORM = { + "linux-x64": "517259395b0a385a5e848cf48b967645a984be3dd456118bc08771283a822a5b", + "linux-arm64": "cb4986941cfdcf9cb74ece6bbb53a443390a908e880108a37f4ccf82b2d6c502", + "osx-x64": "0abfcd98f63f77dba74094339a40d4407b69317c1c77b13a26b9b7dbdfd885f1", + "osx-arm64": "d4c2411e406af96ceae12b11e77fdb0c684ca15a68bfd8b4f9c6fe1fbdf515a7", + "win-x64": "1493c31552e6f90a59800b2d44669e01fc4551d3647f3cea5dd105a9f6bc73e5", +} +DEFAULT_PASLS_VERSION = "v0.2.0" +DEFAULT_PASLS_SHA256_BY_PLATFORM = { + "linux-x64": "517259395b0a385a5e848cf48b967645a984be3dd456118bc08771283a822a5b", + "linux-arm64": "cb4986941cfdcf9cb74ece6bbb53a443390a908e880108a37f4ccf82b2d6c502", + "osx-x64": "0abfcd98f63f77dba74094339a40d4407b69317c1c77b13a26b9b7dbdfd885f1", + "osx-arm64": "d4c2411e406af96ceae12b11e77fdb0c684ca15a68bfd8b4f9c6fe1fbdf515a7", + "win-x64": "1493c31552e6f90a59800b2d44669e01fc4551d3647f3cea5dd105a9f6bc73e5", +} +PASLS_VERSION = DEFAULT_PASLS_VERSION + + +def _pasls_sha(version: str, platform_key: str) -> str | None: + if version == INITIAL_PASLS_VERSION: + return INITIAL_PASLS_SHA256_BY_PLATFORM.get(platform_key) + if version == DEFAULT_PASLS_VERSION: + return DEFAULT_PASLS_SHA256_BY_PLATFORM.get(platform_key) + return None + + +class PascalLanguageServer(SolidLanguageServer): + """ + Provides Pascal specific instantiation of the LanguageServer class using pasls. + Contains various configurations and settings specific to Free Pascal and Lazarus. + Supports overriding the bundled pasls version via ``pasls_version``. + """ + + # URL configuration + PASLS_VERSION = PASLS_VERSION + PASLS_RELEASES_URL = f"https://github.com/zen010101/pascal-language-server/releases/download/{PASLS_VERSION}" + PASLS_API_URL = f"https://api.github.com/repos/zen010101/pascal-language-server/releases/tags/{PASLS_VERSION}" + + # Update check interval (seconds) + UPDATE_CHECK_INTERVAL = 86400 # 24 hours + + # Metadata directory name + META_DIR = ".meta" + + # Network timeout (seconds) + NETWORK_TIMEOUT = 10 + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a PascalLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + pasls_executable_path = self._setup_runtime_dependencies(solidlsp_settings) + + # Build environment variables for pasls + # These control CodeTools' configuration and target platform settings + proc_env: dict[str, str] = {} + + # Read from ls_specific_settings["pascal"] + from solidlsp.ls_config import Language + + pascal_settings = solidlsp_settings.get_ls_specific_settings(Language.PASCAL) + + # pp: Path to FPC compiler driver (must be fpc.exe, NOT ppc386.exe/ppcx64.exe) + # CodeTools queries fpc.exe for configuration via "fpc -iV", "fpc -iTO", etc. + pp = pascal_settings.get("pp", "") + if pp: + proc_env["PP"] = pp + log.info(f"Setting PP={pp} from ls_specific_settings") + + # fpcdir: Path to FPC source directory (e.g., "D:/laz32/fpcsrc") + fpcdir = pascal_settings.get("fpcdir", "") + if fpcdir: + proc_env["FPCDIR"] = fpcdir + log.info(f"Setting FPCDIR={fpcdir} from ls_specific_settings") + + # lazarusdir: Path to Lazarus directory (e.g., "D:/laz32/lazarus") + lazarusdir = pascal_settings.get("lazarusdir", "") + if lazarusdir: + proc_env["LAZARUSDIR"] = lazarusdir + log.info(f"Setting LAZARUSDIR={lazarusdir} from ls_specific_settings") + + # fpc_target: Override target OS (e.g., "Win32", "Win64", "Linux") + fpc_target = pascal_settings.get("fpc_target", "") + if fpc_target: + proc_env["FPCTARGET"] = fpc_target + log.info(f"Setting FPCTARGET={fpc_target} from ls_specific_settings") + + # fpc_target_cpu: Override target CPU (e.g., "i386", "x86_64", "aarch64") + fpc_target_cpu = pascal_settings.get("fpc_target_cpu", "") + if fpc_target_cpu: + proc_env["FPCTARGETCPU"] = fpc_target_cpu + log.info(f"Setting FPCTARGETCPU={fpc_target_cpu} from ls_specific_settings") + + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=pasls_executable_path, cwd=repository_root_path, env=proc_env), + "pascal", + solidlsp_settings, + ) + self.server_ready = threading.Event() + + # ============== Metadata Directory Management ============== + + @classmethod + def _meta_dir(cls, pasls_dir: str) -> str: + """Get metadata directory path, create if not exists.""" + meta_path = os.path.join(pasls_dir, cls.META_DIR) + os.makedirs(meta_path, exist_ok=True) + return meta_path + + @classmethod + def _meta_file(cls, pasls_dir: str, filename: str) -> str: + """Get metadata file path.""" + return os.path.join(cls._meta_dir(pasls_dir), filename) + + # ============== Version Management ============== + + @staticmethod + def _normalize_version(version: str | None) -> str: + """Normalize version string by removing 'v' prefix and whitespace.""" + if not version: + return "" + return version.strip().lstrip("vV") + + @classmethod + def _is_newer_version(cls, latest: str | None, local: str | None) -> bool: + """Compare versions, return True if latest is newer than local.""" + if not latest: + return False + if not local: + return True + + latest_norm = cls._normalize_version(latest) + local_norm = cls._normalize_version(local) + + if not latest_norm: + return False + if not local_norm: + return True + + try: + + def parse_version(v: str) -> list[int]: + parts = [] + for part in v.split("."): + num = "" + for c in part: + if c.isdigit(): + num += c + else: + break + parts.append(int(num) if num else 0) + return parts + + latest_parts = parse_version(latest_norm) + local_parts = parse_version(local_norm) + + # Pad to same length + max_len = max(len(latest_parts), len(local_parts)) + latest_parts.extend([0] * (max_len - len(latest_parts))) + local_parts.extend([0] * (max_len - len(local_parts))) + + return latest_parts > local_parts + except Exception: + log.warning(f"Failed to parse versions for comparison: {latest_norm} vs {local_norm}") + return False + + @classmethod + def _get_latest_version(cls) -> str | None: + """Get latest version from GitHub API, return None on failure.""" + try: + headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": "Serena-LSP"} + # Support GITHUB_TOKEN for CI environments with rate limits + github_token = os.environ.get("GITHUB_TOKEN") + if github_token: + headers["Authorization"] = f"token {github_token}" + + req = urllib.request.Request(cls.PASLS_API_URL, headers=headers) + with urllib.request.urlopen(req, timeout=cls.NETWORK_TIMEOUT) as response: + data = json.loads(response.read().decode()) + return data.get("tag_name") + except Exception as e: + log.debug(f"Failed to get latest pasls version: {type(e).__name__}: {e}") + return None + + @classmethod + def _get_local_version(cls, pasls_dir: str) -> str | None: + """Read local version file.""" + version_file = cls._meta_file(pasls_dir, "version") + if os.path.exists(version_file): + try: + with open(version_file, encoding="utf-8") as f: + return f.read().strip() + except OSError: + return None + return None + + @classmethod + def _save_local_version(cls, pasls_dir: str, version: str) -> None: + """Save version to local file.""" + version_file = cls._meta_file(pasls_dir, "version") + try: + with open(version_file, "w", encoding="utf-8") as f: + f.write(version) + except OSError as e: + log.warning(f"Failed to save version file: {e}") + + # ============== Update Check Timing ============== + + @classmethod + def _should_check_update(cls, pasls_dir: str) -> bool: + """Check if we should query for updates (more than 24 hours since last check).""" + last_check_file = cls._meta_file(pasls_dir, "last_check") + if not os.path.exists(last_check_file): + return True + try: + with open(last_check_file, encoding="utf-8") as f: + last_check = float(f.read().strip()) + return (time.time() - last_check) > cls.UPDATE_CHECK_INTERVAL + except (OSError, ValueError): + return True + + @classmethod + def _update_last_check(cls, pasls_dir: str) -> None: + """Update last check timestamp.""" + last_check_file = cls._meta_file(pasls_dir, "last_check") + try: + with open(last_check_file, "w", encoding="utf-8") as f: + f.write(str(time.time())) + except OSError as e: + log.warning(f"Failed to update last check time: {e}") + + # ============== SHA256 Checksum ============== + + @classmethod + def _get_checksums(cls) -> dict[str, str] | None: + """Download checksums file from GitHub, return {filename: sha256} dict.""" + checksums_url = f"{cls.PASLS_RELEASES_URL}/checksums.sha256" + try: + req = urllib.request.Request(checksums_url, headers={"User-Agent": "Serena-LSP"}) + with urllib.request.urlopen(req, timeout=cls.NETWORK_TIMEOUT) as response: + content = response.read().decode("utf-8") + checksums = {} + for line in content.strip().split("\n"): + line = line.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + if len(parts) >= 2: + sha256 = parts[0] + filename = parts[1].lstrip("*") # Remove possible * prefix + checksums[filename] = sha256 + return checksums + except Exception as e: + log.warning(f"Failed to get checksums: {type(e).__name__}: {e}") + return None + + @staticmethod + def _calculate_sha256(file_path: str) -> str: + """Calculate SHA256 checksum of a file.""" + sha256_hash = hashlib.sha256() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + sha256_hash.update(chunk) + return sha256_hash.hexdigest() + + @classmethod + def _verify_checksum(cls, file_path: str, expected_sha256: str) -> bool: + """Verify file checksum.""" + try: + actual_sha256 = cls._calculate_sha256(file_path) + if actual_sha256.lower() == expected_sha256.lower(): + log.debug(f"Checksum verified: {file_path}") + return True + else: + log.error(f"Checksum mismatch for {file_path}: expected {expected_sha256}, got {actual_sha256}") + return False + except Exception as e: + log.error(f"Failed to verify checksum: {e}") + return False + + # ============== Windows File Locking ============== + + @staticmethod + def _is_file_locked(file_path: str) -> bool: + """Check if file is locked (Windows).""" + if platform.system() != "Windows": + return False + + if not os.path.exists(file_path): + return False + + try: + with open(file_path, "a"): + pass + return False + except (OSError, PermissionError): + return True + + @classmethod + def _safe_remove(cls, file_path: str) -> bool: + """Safely remove file, handle Windows file locking.""" + if not os.path.exists(file_path): + return True + + if platform.system() == "Windows" and cls._is_file_locked(file_path): + temp_name = f"{file_path}.old.{uuid.uuid4().hex[:8]}" + try: + os.rename(file_path, temp_name) + log.info(f"File locked, renamed to: {temp_name}") + cls._mark_for_cleanup(os.path.dirname(file_path), temp_name) + return True + except PermissionError: + log.warning(f"Cannot remove/rename locked file: {file_path}") + return False + else: + try: + os.remove(file_path) + return True + except OSError as e: + log.warning(f"Failed to remove file {file_path}: {e}") + return False + + @classmethod + def _mark_for_cleanup(cls, pasls_dir: str, file_path: str) -> None: + """Mark file for later cleanup.""" + cleanup_file = cls._meta_file(pasls_dir, "cleanup_list") + try: + with open(cleanup_file, "a", encoding="utf-8") as f: + f.write(file_path + "\n") + except OSError: + pass + + @classmethod + def _cleanup_old_files(cls, pasls_dir: str) -> None: + """Clean up old files marked for deletion.""" + cleanup_file = cls._meta_file(pasls_dir, "cleanup_list") + if not os.path.exists(cleanup_file): + return + + try: + with open(cleanup_file, encoding="utf-8") as f: + files = [line.strip() for line in f if line.strip()] + + remaining = [] + for file_path in files: + if os.path.exists(file_path): + try: + os.remove(file_path) + log.debug(f"Cleaned up old file: {file_path}") + except OSError: + remaining.append(file_path) + + if remaining: + with open(cleanup_file, "w", encoding="utf-8") as f: + f.write("\n".join(remaining) + "\n") + else: + os.remove(cleanup_file) + except OSError: + pass + + # ============== Download and Atomic Update ============== + + @classmethod + def _download_archive(cls, url: str, target_path: str) -> bool: + """Download archive to specified path.""" + try: + os.makedirs(os.path.dirname(target_path), exist_ok=True) + req = urllib.request.Request(url, headers={"User-Agent": "Serena-LSP"}) + with urllib.request.urlopen(req, timeout=60) as response: + with open(target_path, "wb") as f: + while True: + chunk = response.read(8192) + if not chunk: + break + f.write(chunk) + return True + except Exception as e: + log.error(f"Failed to download {url}: {type(e).__name__}: {e}") + return False + + @classmethod + def _is_safe_tar_member(cls, member: tarfile.TarInfo, target_dir: str) -> bool: + """Check if tar member is safe (prevent path traversal attack).""" + # Check for .. in path components + if ".." in member.name.split("/") or ".." in member.name.split("\\"): + return False + + # Check extracted path is within target directory + abs_target = os.path.abspath(target_dir) + abs_member = os.path.abspath(os.path.join(target_dir, member.name)) + + return abs_member.startswith(abs_target + os.sep) or abs_member == abs_target + + @classmethod + def _extract_archive(cls, archive_path: str, target_dir: str, archive_type: str) -> bool: + """Safely extract archive to specified directory.""" + try: + os.makedirs(target_dir, exist_ok=True) + + if archive_type == "gztar": + with tarfile.open(archive_path, "r:gz") as tar: + for member in tar.getmembers(): + if not cls._is_safe_tar_member(member, target_dir): + log.error(f"Unsafe tar member detected (path traversal): {member.name}") + return False + tar.extractall(target_dir) + + elif archive_type == "zip": + with zipfile.ZipFile(archive_path, "r") as zip_ref: + for name in zip_ref.namelist(): + if ".." in name.split("/") or ".." in name.split("\\"): + log.error(f"Unsafe zip member detected (path traversal): {name}") + return False + abs_target = os.path.abspath(target_dir) + abs_member = os.path.abspath(os.path.join(target_dir, name)) + if not (abs_member.startswith(abs_target + os.sep) or abs_member == abs_target): + log.error(f"Unsafe zip member detected (path traversal): {name}") + return False + zip_ref.extractall(target_dir) + + else: + log.error(f"Unsupported archive type: {archive_type}") + return False + + # Handle nested directory: if extraction created a single subdirectory, + # move its contents up to target_dir (common with GitHub release archives) + cls._flatten_single_subdir(target_dir) + + return True + except Exception as e: + log.error(f"Failed to extract archive: {type(e).__name__}: {e}") + return False + + @classmethod + def _flatten_single_subdir(cls, target_dir: str) -> None: + """If target_dir contains only a single subdirectory, move its contents up.""" + entries = os.listdir(target_dir) + if len(entries) == 1: + subdir = os.path.join(target_dir, entries[0]) + if os.path.isdir(subdir): + # Move all contents from subdir to target_dir + for item in os.listdir(subdir): + src = os.path.join(subdir, item) + dst = os.path.join(target_dir, item) + shutil.move(src, dst) + # Remove the now-empty subdirectory + os.rmdir(subdir) + + @classmethod + def _get_archive_filename(cls, dep: RuntimeDependency) -> str: + """Get archive filename from URL.""" + assert dep.url is not None, "RuntimeDependency.url must be set" + return dep.url.split("/")[-1] + + @classmethod + def _atomic_install(cls, pasls_dir: str, deps: RuntimeDependencyCollection, checksums: dict[str, str] | None) -> bool: + """Atomic update: download -> verify checksum -> extract -> replace.""" + temp_dir = pasls_dir + ".tmp" + backup_dir = pasls_dir + ".backup" + temp_archive_dir = tempfile.mkdtemp(prefix="solidlsp_") + + try: + dep = deps.get_single_dep_for_current_platform() + assert dep.url is not None, "RuntimeDependency.url must be set" + assert dep.archive_type is not None, "RuntimeDependency.archive_type must be set" + + archive_filename = cls._get_archive_filename(dep) + archive_path = os.path.join(temp_archive_dir, archive_filename) + + # 1. Clean up any existing temp directory + if os.path.exists(temp_dir): + shutil.rmtree(temp_dir) + # 2. Download archive + log.info(f"Downloading pasls archive: {archive_filename}") + if not cls._download_archive(dep.url, archive_path): + log.error("Failed to download pasls archive") + return False + + # 3. Verify SHA256 checksum (critical security step, before extraction) + expected_sha256 = checksums.get(archive_filename) if checksums else dep.sha256 + if expected_sha256: + log.info(f"Verifying SHA256 checksum for {archive_filename}...") + if not cls._verify_checksum(archive_path, expected_sha256): + log.error(f"SHA256 checksum verification FAILED for {archive_filename}") + log.error("Aborting installation due to checksum mismatch - possible security issue!") + try: + os.remove(archive_path) + except OSError: + pass + return False + log.info("SHA256 checksum verified successfully") + else: + log.warning("No checksums available - skipping verification (not recommended for production)") + + # 4. Extract to temp directory + os.makedirs(temp_dir, exist_ok=True) + log.info("Extracting archive to temporary directory...") + if not cls._extract_archive(archive_path, temp_dir, dep.archive_type): + log.error("Failed to extract archive") + return False + + # 5. Set execute permission + binary_path = deps.binary_path(temp_dir) + if os.path.exists(binary_path): + try: + os.chmod(binary_path, 0o755) + except OSError: + pass # May fail on Windows + + # 6. Backup old version + if os.path.exists(pasls_dir): + if os.path.exists(backup_dir): + shutil.rmtree(backup_dir) + shutil.move(pasls_dir, backup_dir) + + # 7. Replace with new version + shutil.move(temp_dir, pasls_dir) + + # 8. Restore meta directory from backup (preserves version info, last_check, etc.) + if os.path.exists(backup_dir): + backup_meta = os.path.join(backup_dir, cls.META_DIR) + if os.path.exists(backup_meta): + target_meta = os.path.join(pasls_dir, cls.META_DIR) + if not os.path.exists(target_meta): + shutil.copytree(backup_meta, target_meta) + + # 9. Clean up downloaded archive and temp directory + shutil.rmtree(temp_archive_dir, ignore_errors=True) + + log.info("pasls installation completed successfully") + return True + + except Exception as e: + log.error(f"Installation failed: {e}") + + # Rollback + if os.path.exists(backup_dir) and not os.path.exists(pasls_dir): + try: + shutil.move(backup_dir, pasls_dir) + log.info("Rolled back to previous version") + except Exception as rollback_error: + log.error(f"Rollback failed: {rollback_error}") + + # Clean up temp directories + if os.path.exists(temp_dir): + try: + shutil.rmtree(temp_dir) + except Exception: + pass + shutil.rmtree(temp_archive_dir, ignore_errors=True) + + return False + + @classmethod + def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str: + """ + Setup runtime dependencies for Pascal Language Server (pasls). + Downloads the pinned Serena-supported pasls release with checksum verification. + + Returns: + str: The command to start the pasls server + + """ + pascal_settings = solidlsp_settings.get_ls_specific_settings(Language.PASCAL) + pasls_version = pascal_settings.get("pasls_version", PASLS_VERSION) + cls.PASLS_VERSION = pasls_version + cls.PASLS_RELEASES_URL = f"https://github.com/zen010101/pascal-language-server/releases/download/{pasls_version}" + cls.PASLS_API_URL = f"https://api.github.com/repos/zen010101/pascal-language-server/releases/tags/{pasls_version}" + + # Check if pasls is already in PATH + pasls_in_path = shutil.which("pasls") + if pasls_in_path: + log.info(f"Found pasls in PATH: {pasls_in_path}") + return quote_windows_path(pasls_in_path) + + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + pasls_dir = ( + cls.ls_resources_dir(solidlsp_settings) + if pasls_version == INITIAL_PASLS_VERSION + else os.path.join(cls.ls_resources_dir(solidlsp_settings), f"pasls-{pasls_version}") + ) + os.makedirs(pasls_dir, exist_ok=True) + + # Clean up old files from previous sessions + cls._cleanup_old_files(pasls_dir) + + # Use RuntimeDependencyCollection for platform detection + # Asset names follow zen010101/pascal-language-server release convention: + # pasls-{cpu_arch}-{os}.{ext} where cpu_arch is x86_64/aarch64/i386 + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="PascalLanguageServer", + description="Pascal Language Server for Linux (x64)", + url=f"{cls.PASLS_RELEASES_URL}/pasls-x86_64-linux.tar.gz", + platform_id="linux-x64", + archive_type="gztar", + binary_name="pasls", + sha256=_pasls_sha(pasls_version, "linux-x64"), + ), + RuntimeDependency( + id="PascalLanguageServer", + description="Pascal Language Server for Linux (arm64)", + url=f"{cls.PASLS_RELEASES_URL}/pasls-aarch64-linux.tar.gz", + platform_id="linux-arm64", + archive_type="gztar", + binary_name="pasls", + sha256=_pasls_sha(pasls_version, "linux-arm64"), + ), + RuntimeDependency( + id="PascalLanguageServer", + description="Pascal Language Server for macOS (x64)", + url=f"{cls.PASLS_RELEASES_URL}/pasls-x86_64-darwin.zip", + platform_id="osx-x64", + archive_type="zip", + binary_name="pasls", + sha256=_pasls_sha(pasls_version, "osx-x64"), + ), + RuntimeDependency( + id="PascalLanguageServer", + description="Pascal Language Server for macOS (arm64)", + url=f"{cls.PASLS_RELEASES_URL}/pasls-aarch64-darwin.zip", + platform_id="osx-arm64", + archive_type="zip", + binary_name="pasls", + sha256=_pasls_sha(pasls_version, "osx-arm64"), + ), + RuntimeDependency( + id="PascalLanguageServer", + description="Pascal Language Server for Windows (x64)", + url=f"{cls.PASLS_RELEASES_URL}/pasls-x86_64-win64.zip", + platform_id="win-x64", + archive_type="zip", + binary_name="pasls.exe", + sha256=_pasls_sha(pasls_version, "win-x64"), + ), + ] + ) + + pasls_executable_path = deps.binary_path(pasls_dir) + + # Determine if download is needed + need_download = False + latest_version = None + checksums = None + + if not os.path.exists(pasls_executable_path): + # First install + log.info("pasls not found, will download...") + need_download = True + latest_version = cls._get_latest_version() + checksums = cls._get_checksums() + elif cls._should_check_update(pasls_dir): + # Check for updates + log.debug("Checking for pasls updates...") + latest_version = cls._get_latest_version() + local_version = cls._get_local_version(pasls_dir) + + if cls._is_newer_version(latest_version, local_version): + log.info(f"New pasls version available: {latest_version} (current: {local_version})") + + # Check Windows file locking + if cls._is_file_locked(pasls_executable_path): + log.warning("Cannot update pasls: file is in use. Will retry next time.") + else: + need_download = True + checksums = cls._get_checksums() + else: + log.debug(f"pasls is up to date: {local_version}") + + if need_download: + if cls._atomic_install(pasls_dir, deps, checksums): + # Update metadata after successful installation + if latest_version: + cls._save_local_version(pasls_dir, latest_version) + else: + # API failed but download succeeded, record placeholder version + cls._save_local_version(pasls_dir, "unknown") + cls._update_last_check(pasls_dir) + else: + # Installation failed, use existing version if available + if not os.path.exists(pasls_executable_path): + raise RuntimeError("Failed to install pasls and no local version available") + log.warning("Update failed, using existing version") + + # Update check time even if no update (avoid frequent checks) + if not need_download and cls._should_check_update(pasls_dir): + cls._update_last_check(pasls_dir) + + assert os.path.exists(pasls_executable_path), f"pasls executable not found at {pasls_executable_path}" + + # Ensure execute permission + try: + os.chmod(pasls_executable_path, 0o755) + except OSError: + pass # May fail on Windows, ignore + + log.info(f"Using pasls at: {pasls_executable_path}") + return quote_windows_path(pasls_executable_path) + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Pascal Language Server. + + pasls (genericptr/pascal-language-server) reads compiler paths from: + 1. Environment variables (PP, FPCDIR, LAZARUSDIR) via TCodeToolsOptions.InitWithEnvironmentVariables + 2. Lazarus config files via GuessCodeToolConfig + + We only pass target OS/CPU in initializationOptions if explicitly set. + """ + # Build initializationOptions from environment variables + # pasls reads these to configure CodeTools: + # - PP: Path to FPC compiler executable + # - FPCDIR: Path to FPC source directory + # - LAZARUSDIR: Path to Lazarus directory (only needed for LCL projects) + # - FPCTARGET: Target OS + # - FPCTARGETCPU: Target CPU + initialization_options: dict = {} + + env_vars = ["PP", "FPCDIR", "LAZARUSDIR", "FPCTARGET", "FPCTARGETCPU"] + for var in env_vars: + value = os.environ.get(var, "") + if value: + initialization_options[var] = value + + initialization_options.update( + { + "checkSyntax": True, + "publishDiagnostics": True, + "showSyntaxErrors": True, + } + ) + + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": { + "didSave": True, + "dynamicRegistration": True, + "willSave": True, + "willSaveWaitUntil": True, + }, + "completion": { + "dynamicRegistration": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + }, + }, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + }, + }, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentHighlight": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "codeAction": { + "dynamicRegistration": True, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + ] + } + }, + }, + "formatting": {"dynamicRegistration": True}, + "rangeFormatting": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + "executeCommand": {"dynamicRegistration": True}, + "configuration": True, + "workspaceEdit": { + "documentChanges": True, + }, + }, + }, + "initializationOptions": initialization_options, + } + + return initialize_params + + def _start_server(self) -> None: + """ + Starts the Pascal Language Server, waits for the server to be ready and yields the LanguageServer instance. + """ + + def register_capability_handler(params: dict) -> None: + log.debug(f"Capability registered: {params}") + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + # Mark server as ready when we see initialization messages + message_text = msg.get("message", "") + if "initialized" in message_text.lower() or "ready" in message_text.lower(): + log.info("Pascal language server ready signal detected") + self.server_ready.set() + + def publish_diagnostics(params: dict) -> None: + log.debug(f"Diagnostics: {params}") + return + + def do_nothing(params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("window/showMessage", window_log_message) + self.server.on_notification("textDocument/publishDiagnostics", publish_diagnostics) + self.server.on_notification("$/progress", do_nothing) + + log.info("Starting Pascal server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.debug(f"Received initialize response from Pascal server: {init_response}") + + # Verify capabilities + capabilities = init_response.get("capabilities", {}) + assert "textDocumentSync" in capabilities + + # Check for various capabilities + if "completionProvider" in capabilities: + log.info("Pascal server supports code completion") + if "definitionProvider" in capabilities: + log.info("Pascal server supports go to definition") + if "referencesProvider" in capabilities: + log.info("Pascal server supports find references") + if "documentSymbolProvider" in capabilities: + log.info("Pascal server supports document symbols") + + self.server.notify.initialized({}) + + # Wait for server readiness with timeout + log.info("Waiting for Pascal language server to be ready...") + if not self.server_ready.wait(timeout=5.0): + # pasls may not send explicit ready signals, so we proceed after timeout + log.info("Timeout waiting for Pascal server ready signal, assuming server is ready") + self.server_ready.set() + else: + log.info("Pascal server initialization complete") + + def is_ignored_dirname(self, dirname: str) -> bool: + """ + Check if a directory should be ignored for Pascal projects. + Common Pascal/Lazarus directories to ignore. + """ + ignored_dirs = { + "lib", + "backup", + "__history", + "__recovery", + "bin", + ".git", + ".svn", + ".hg", + "node_modules", + } + return dirname.lower() in ignored_dirs diff --git a/src/solidlsp/language_servers/perl_language_server.py b/src/solidlsp/language_servers/perl_language_server.py new file mode 100644 index 0000000..7e3744b --- /dev/null +++ b/src/solidlsp/language_servers/perl_language_server.py @@ -0,0 +1,245 @@ +""" +Provides Perl specific instantiation of the LanguageServer class using Perl::LanguageServer. + +Note: Windows is not supported as Nix itself doesn't support Windows natively. +""" + +import logging +import subprocess +import time +from typing import Any + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_utils import PlatformId, PlatformUtils +from solidlsp.lsp_protocol_handler.lsp_types import DidChangeConfigurationParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +# Defaults handed to Perl::LanguageServer via its `perl.fileFilter` / `perl.ignoreDirs` settings. +# Exposed for override via `ls_specific_settings["perl"]`. Extensions added via `file_filter` are +# also synced into Language.PERL.get_source_fn_matcher() (a cached singleton) so Serena's symbol +# index and the language server agree on which files are Perl sources (#1449). +_DEFAULT_FILE_FILTER: list[str] = [".pm", ".pl", ".t"] +_DEFAULT_IGNORE_DIRS: list[str] = [".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"] + + +class PerlLanguageServer(SolidLanguageServer): + """ + Provides Perl specific instantiation of the LanguageServer class using Perl::LanguageServer. + + You can pass the following entries in ``ls_specific_settings["perl"]``: + - file_filter: List of file extensions (with leading dot) that Perl::LanguageServer should + index, e.g. ``[".pm", ".pl", ".t", ".cgi"]``. Defaults to ``[".pm", ".pl", ".t"]``. + The same extensions are added to Serena's Perl source-file matcher, so ``find_symbol`` + and symbol indexing treat them consistently (see #1449). + - ignore_dirs: Directory names Perl::LanguageServer should skip when indexing. Defaults to + ``[".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"]``. + """ + + @staticmethod + def _get_perl_version() -> str | None: + """Get the installed Perl version or None if not found.""" + try: + result = subprocess.run(["perl", "-v"], capture_output=True, text=True, check=False) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + return None + return None + + @staticmethod + def _get_perl_language_server_version() -> str | None: + """Get the installed Perl::LanguageServer version or None if not found.""" + try: + result = subprocess.run( + ["perl", "-MPerl::LanguageServer", "-e", "print $Perl::LanguageServer::VERSION"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + return None + return None + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # For Perl projects, we should ignore: + # - blib: build library directory + # - local: local Perl module installation + # - .carton: Carton dependency manager cache + # - vendor: vendored dependencies + # - _build: Module::Build output + return super().is_ignored_dirname(dirname) or dirname in ["blib", "local", ".carton", "vendor", "_build", "cover_db"] + + @classmethod + def _setup_runtime_dependencies(cls) -> str: + """ + Check if required Perl runtime dependencies are available. + Raises RuntimeError with helpful message if dependencies are missing. + """ + platform_id = PlatformUtils.get_platform_id() + + valid_platforms = [ + PlatformId.LINUX_x64, + PlatformId.LINUX_arm64, + PlatformId.OSX, + PlatformId.OSX_x64, + PlatformId.OSX_arm64, + ] + if platform_id not in valid_platforms: + raise RuntimeError(f"Platform {platform_id} is not supported for Perl at the moment") + + perl_version = cls._get_perl_version() + if not perl_version: + raise RuntimeError( + "Perl is not installed. Please install Perl from https://www.perl.org/get.html and make sure it is added to your PATH." + ) + + perl_ls_version = cls._get_perl_language_server_version() + if not perl_ls_version: + raise RuntimeError( + "Found a Perl version but Perl::LanguageServer is not installed.\n" + "Please install Perl::LanguageServer: cpanm Perl::LanguageServer\n" + "See: https://metacpan.org/pod/Perl::LanguageServer" + ) + + return "perl -MPerl::LanguageServer -e 'Perl::LanguageServer::run'" + + @staticmethod + def _resolve_filter_settings(solidlsp_settings: SolidLSPSettings) -> tuple[list[str], list[str]]: + """Resolve the ``fileFilter`` / ``ignoreDirs`` to hand to Perl::LanguageServer. + + Reads optional overrides from ``ls_specific_settings["perl"]`` (keys ``file_filter`` and + ``ignore_dirs``); falls back to the defaults otherwise. Extracted as a pure function so the + configuration plumbing can be unit-tested without starting the language server. + """ + perl_settings = solidlsp_settings.get_ls_specific_settings(Language.PERL) + file_filter = perl_settings.get("file_filter", list(_DEFAULT_FILE_FILTER)) + ignore_dirs = perl_settings.get("ignore_dirs", list(_DEFAULT_IGNORE_DIRS)) + return file_filter, ignore_dirs + + @staticmethod + def _sync_source_fn_matcher(file_filter: list[str]) -> None: + """Keep Serena's Perl source-file matcher in sync with the LS ``fileFilter``. + + ``Language.PERL.get_source_fn_matcher()`` is a ``@cache``d per-language singleton, so adding + the configured extensions here propagates to every consumer of the matcher — symbol index + traversal (``project.is_ignored_path``), the LS ignore check (``ls.is_ignored_path``), and + language composition detection. Without this, ``find_symbol`` would not surface symbols in + files whose extensions were added to ``file_filter`` (#1449). + """ + Language.PERL.get_source_fn_matcher().add_extensions(*file_filter) + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + # Setup runtime dependencies before initializing + perl_ls_cmd = self._setup_runtime_dependencies() + + super().__init__( + config, repository_root_path, ProcessLaunchInfo(cmd=perl_ls_cmd, cwd=repository_root_path), "perl", solidlsp_settings + ) + self.request_id = 0 + self._file_filter, self._ignore_dirs = self._resolve_filter_settings(solidlsp_settings) + # Sync Serena's source-file matcher with the configured extensions so find_symbol and the + # language server agree on which files are Perl sources (see #1449). + self._sync_source_fn_matcher(self._file_filter) + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for Perl::LanguageServer. + Based on the expected structure from Perl::LanguageServer::Methods::_rpcreq_initialize. + """ + initialize_params = { + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": {"dynamicRegistration": True}, + "hover": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + "initializationOptions": {}, + } + + return initialize_params + + def _start_server(self) -> None: + """Start Perl::LanguageServer process""" + + def register_capability_handler(params: Any) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(params: Any) -> None: + return + + def workspace_configuration_handler(params: Any) -> Any: + """Handle workspace/configuration request from Perl::LanguageServer.""" + log.info(f"Received workspace/configuration request: {params}") + + perl_config = { + "perlInc": [self.repository_root_path, "."], + "fileFilter": self._file_filter, + "ignoreDirs": self._ignore_dirs, + } + + return [perl_config] + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_request("workspace/configuration", workspace_configuration_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting Perl::LanguageServer process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.info( + "After sent initialize params", + ) + + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + assert "referencesProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + + # Send workspace configuration to Perl::LanguageServer + # Perl::LanguageServer requires didChangeConfiguration to set perlInc, fileFilter, and ignoreDirs + # See: Perl::LanguageServer::Methods::workspace::_rpcnot_didChangeConfiguration + perl_config: DidChangeConfigurationParams = { + "settings": { + "perl": { + "perlInc": [self.repository_root_path, "."], + "fileFilter": self._file_filter, + "ignoreDirs": self._ignore_dirs, + } + } + } + log.info(f"Sending workspace/didChangeConfiguration notification with config: {perl_config}") + self.server.notify.workspace_did_change_configuration(perl_config) + + # Perl::LanguageServer needs time to index files and resolve cross-file references + # Without this delay, requests for definitions/references may return empty results + settling_time = 0.5 + log.info(f"Allowing {settling_time} seconds for Perl::LanguageServer to index files...") + time.sleep(settling_time) + log.info("Perl::LanguageServer settling period complete") diff --git a/src/solidlsp/language_servers/phpactor.py b/src/solidlsp/language_servers/phpactor.py new file mode 100644 index 0000000..306374d --- /dev/null +++ b/src/solidlsp/language_servers/phpactor.py @@ -0,0 +1,179 @@ +""" +Provides PHP specific instantiation of the LanguageServer class using Phpactor. +""" + +import logging +import os +import re +import shutil +import stat +import subprocess + +from overrides import override + +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_utils import FileUtils +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +PHPACTOR_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_PHPACTOR_VERSION = "2025.12.21.1" +INITIAL_PHPACTOR_PHAR_SHA256 = "53bbe9625cd9b5e9b394bc2f595fbad13dbbe6dfc96950c56dea3b5d9a246cc3" +DEFAULT_PHPACTOR_VERSION = "2025.12.21.1" +DEFAULT_PHPACTOR_PHAR_SHA256 = "53bbe9625cd9b5e9b394bc2f595fbad13dbbe6dfc96950c56dea3b5d9a246cc3" + + +def _phpactor_sha(version: str) -> str | None: + if version == INITIAL_PHPACTOR_VERSION: + return INITIAL_PHPACTOR_PHAR_SHA256 + if version == DEFAULT_PHPACTOR_VERSION: + return DEFAULT_PHPACTOR_PHAR_SHA256 + return None + + +class PhpactorServer(SolidLanguageServer): + """ + Provides PHP specific instantiation of the LanguageServer class using Phpactor. + + Phpactor is an open-source (MIT) PHP language server that requires PHP 8.1+ on the system. + It is an alternative to Intelephense, which is the default PHP language server. + + You can pass the following entries in ls_specific_settings["php_phpactor"]: + - ignore_vendor: whether to ignore directories named "vendor" (default: true) + - phpactor_version: Override the pinned Phpactor PHAR version downloaded by + Serena (default: the bundled Serena version) + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in self._ignored_dirnames + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """ + Setup runtime dependencies for Phpactor and return the path to the PHAR file. + """ + phpactor_version = self._custom_settings.get("phpactor_version", DEFAULT_PHPACTOR_VERSION) + phpactor_phar_url = f"https://github.com/phpactor/phpactor/releases/download/{phpactor_version}/phpactor.phar" + # Verify PHP is installed + php_path = shutil.which("php") + assert php_path is not None, ( + "PHP is not installed or not found in PATH. Phpactor requires PHP 8.1+. Please install PHP and try again." + ) + + # Check PHP version (Phpactor requires PHP 8.1+) + result = subprocess.run(["php", "--version"], capture_output=True, text=True, check=False) + php_version_output = result.stdout.strip() + log.info(f"PHP version: {php_version_output}") + version_match = re.search(r"PHP (\d+)\.(\d+)", php_version_output) + if version_match: + major, minor = int(version_match.group(1)), int(version_match.group(2)) + if major < 8 or (major == 8 and minor < 1): + raise RuntimeError(f"PHP {major}.{minor} detected, but Phpactor requires PHP 8.1+. Please upgrade PHP.") + else: + log.warning("Could not parse PHP version from output. Continuing anyway.") + + # legacy unversioned phar at root reserved for INITIAL; every other version goes into a versioned subdir + if phpactor_version == INITIAL_PHPACTOR_VERSION: + phar_dir = self._ls_resources_dir + else: + phar_dir = os.path.join(self._ls_resources_dir, f"phpactor-{phpactor_version}") + phpactor_phar_path = os.path.join(phar_dir, "phpactor.phar") + if not os.path.exists(phpactor_phar_path): + os.makedirs(phar_dir, exist_ok=True) + log.info(f"Downloading phpactor PHAR from {phpactor_phar_url}") + FileUtils.download_and_extract_archive_verified( + phpactor_phar_url, + phpactor_phar_path, + "binary", + expected_sha256=_phpactor_sha(phpactor_version), + allowed_hosts=PHPACTOR_ALLOWED_HOSTS, + ) + + assert os.path.exists(phpactor_phar_path), f"phpactor PHAR not found at {phpactor_phar_path}, download may have failed." + + # Ensure the PHAR is executable + current_mode = os.stat(phpactor_phar_path).st_mode + os.chmod(phpactor_phar_path, current_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + + return phpactor_phar_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return ["php", core_path, "language-server"] + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + super().__init__(config, repository_root_path, None, "php", solidlsp_settings) + # Override internal language enum for correct file matching + self.language = Language.PHP_PHPACTOR + + self._ignored_dirnames = {"node_modules", "cache"} + if self._custom_settings.get("ignore_vendor", True): + self._ignored_dirnames.add("vendor") + log.info(f"Ignoring the following directories for PHP (Phpactor): {', '.join(sorted(self._ignored_dirnames))}") + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialization params for the Phpactor Language Server. + """ + initialize_params = { + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "documentSymbol": { + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + }, + }, + "initializationOptions": { + "language_server_phpstan.enabled": False, + "language_server_psalm.enabled": False, + "language_server_php_cs_fixer.enabled": False, + }, + } + return initialize_params + + def _start_server(self) -> None: + """Start Phpactor server process.""" + + def register_capability_handler(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting Phpactor server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.info("After sent initialize params") + + # Verify server capabilities + assert "capabilities" in init_response + assert init_response["capabilities"].get("definitionProvider"), "Phpactor did not advertise definition support" + + self.server.notify.initialized({}) diff --git a/src/solidlsp/language_servers/phpantom.py b/src/solidlsp/language_servers/phpantom.py new file mode 100644 index 0000000..f0f11dd --- /dev/null +++ b/src/solidlsp/language_servers/phpantom.py @@ -0,0 +1,288 @@ +""" +Provides PHP specific instantiation of the LanguageServer class using PHPantom. +""" + +import logging +import os +import shutil +import stat +from time import sleep + +from overrides import override + +from solidlsp import ls_types +from solidlsp.ls import ( + LanguageServerDependencyProvider, + LanguageServerDependencyProviderSinglePath, + LSPFileBuffer, + SolidLanguageServer, +) +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_utils import PlatformId, PlatformUtils +from solidlsp.lsp_protocol_handler import lsp_types as protocol_lsp_types +from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink +from solidlsp.settings import SolidLSPSettings + +from .common import RuntimeDependency, RuntimeDependencyCollection + +log = logging.getLogger(__name__) + +PHPANTOM_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_PHPANTOM_VERSION = "0.8.0" +DEFAULT_PHPANTOM_VERSION = "0.8.0" + +_PHPANTOM_RUNTIME_DEPENDENCIES_BY_VERSION: dict[str, tuple[RuntimeDependency, ...]] = { + "0.8.0": ( + RuntimeDependency( + id="phpantom_lsp", + description="PHPantom language server for macOS (arm64)", + url="https://github.com/PHPantom-dev/phpantom_lsp/releases/download/0.8.0/phpantom_lsp-aarch64-apple-darwin.tar.gz", + platform_id=PlatformId.OSX_arm64.value, + archive_type="gztar", + binary_name="phpantom_lsp", + sha256="2cdfd103b5df98d20712eaeea9bd00d2b459e2a588296f1ab8e558fe25fde456", + allowed_hosts=PHPANTOM_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="phpantom_lsp", + description="PHPantom language server for macOS (x64)", + url="https://github.com/PHPantom-dev/phpantom_lsp/releases/download/0.8.0/phpantom_lsp-x86_64-apple-darwin.tar.gz", + platform_id=PlatformId.OSX_x64.value, + archive_type="gztar", + binary_name="phpantom_lsp", + sha256="e09eef93342cd38c9f9cc6c58064d81b005d06ec6d054e7cdeeec7698dc6c5da", + allowed_hosts=PHPANTOM_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="phpantom_lsp", + description="PHPantom language server for Linux (arm64)", + url="https://github.com/PHPantom-dev/phpantom_lsp/releases/download/0.8.0/phpantom_lsp-aarch64-unknown-linux-gnu.tar.gz", + platform_id=PlatformId.LINUX_arm64.value, + archive_type="gztar", + binary_name="phpantom_lsp", + sha256="e87fc96430f1bcc4966f953033a73a4e2ea53b2dbb7dc3e5f71cc8ced9022a57", + allowed_hosts=PHPANTOM_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="phpantom_lsp", + description="PHPantom language server for Linux (x64)", + url="https://github.com/PHPantom-dev/phpantom_lsp/releases/download/0.8.0/phpantom_lsp-x86_64-unknown-linux-gnu.tar.gz", + platform_id=PlatformId.LINUX_x64.value, + archive_type="gztar", + binary_name="phpantom_lsp", + sha256="39615b495e624bbafe8787c3be61acabc123ec5ac23e9b30e00ab7660f50e020", + allowed_hosts=PHPANTOM_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="phpantom_lsp", + description="PHPantom language server for Windows (arm64)", + url="https://github.com/PHPantom-dev/phpantom_lsp/releases/download/0.8.0/phpantom_lsp-aarch64-pc-windows-msvc.zip", + platform_id=PlatformId.WIN_arm64.value, + archive_type="zip", + binary_name="phpantom_lsp.exe", + sha256="352bfd90351c0f35947ea1af458dabe7a4dc4753d0cabaf9711a23a61346a63d", + allowed_hosts=PHPANTOM_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="phpantom_lsp", + description="PHPantom language server for Windows (x64)", + url="https://github.com/PHPantom-dev/phpantom_lsp/releases/download/0.8.0/phpantom_lsp-x86_64-pc-windows-msvc.zip", + platform_id=PlatformId.WIN_x64.value, + archive_type="zip", + binary_name="phpantom_lsp.exe", + sha256="17e23af816fc7ec695fe716f6209df6b0eafcec2fdafb5d9d72e2b352d5ddf83", + allowed_hosts=PHPANTOM_ALLOWED_HOSTS, + ), + ) +} + + +def _create_phpantom_dependencies(version: str) -> RuntimeDependencyCollection: + dependencies = _PHPANTOM_RUNTIME_DEPENDENCIES_BY_VERSION.get(version) + if dependencies is None: + raise RuntimeError( + f"Unsupported phpantom_version '{version}'. " + + f"Known bundled versions: {', '.join(sorted(_PHPANTOM_RUNTIME_DEPENDENCIES_BY_VERSION))}" + ) + return RuntimeDependencyCollection(dependencies) + + +class PHPantomServer(SolidLanguageServer): + """ + Provides PHP specific instantiation of the LanguageServer class using PHPantom. + + PHPantom is an open-source PHP language server written in Rust. + It is an experimental alternative to Intelephense, which remains the default PHP language server. + + You can pass the following entries in ls_specific_settings["php_phpantom"]: + - ls_path: path to a pre-installed phpantom_lsp binary + - phpantom_version: override the pinned PHPantom version downloaded by Serena + - ignore_vendor: whether to ignore directories named "vendor" (default: true) + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in self._ignored_dirnames + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """ + Setup runtime dependencies for PHPantom and return the path to the executable. + """ + # checking PATH first + system_phpantom = shutil.which("phpantom_lsp") + if system_phpantom is not None: + log.info(f"Using system-installed phpantom_lsp at {system_phpantom}") + return system_phpantom + + # resolving the bundled binary + phpantom_version = self._custom_settings.get("phpantom_version", DEFAULT_PHPANTOM_VERSION) + dependencies = _create_phpantom_dependencies(phpantom_version) + binary_dirname = "phpantom-lsp" if phpantom_version == INITIAL_PHPANTOM_VERSION else f"phpantom-lsp-{phpantom_version}" + binary_dir = os.path.join(self._ls_resources_dir, binary_dirname) + binary_path = dependencies.binary_path(binary_dir) + if not os.path.exists(binary_path): + os.makedirs(binary_dir, exist_ok=True) + dep = dependencies.get_single_dep_for_current_platform("phpantom_lsp") + log.info(f"Downloading phpantom_lsp from {dep.url}") + dependencies.install(binary_dir) + + if not os.path.exists(binary_path): + raise FileNotFoundError(f"phpantom_lsp executable not found at {binary_path}") + + if PlatformUtils.get_platform_id() not in (PlatformId.WIN_x64, PlatformId.WIN_arm64): + current_mode = os.stat(binary_path).st_mode + os.chmod(binary_path, current_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + + return binary_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "--stdio"] + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + super().__init__(config, repository_root_path, None, "php", solidlsp_settings) + self.request_id = 0 + self.language = Language.PHP_PHPANTOM + + self._ignored_dirnames = {"node_modules", "cache"} + if self._custom_settings.get("ignore_vendor", True): + self._ignored_dirnames.add("vendor") + log.info(f"Ignoring the following directories for PHP (PHPantom): {', '.join(sorted(self._ignored_dirnames))}") + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialization params for the PHPantom Language Server. + """ + # declaring client capabilities + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + }, + "workspace": { + "applyEdit": True, + "workspaceEdit": { + "documentChanges": True, + "resourceOperations": ["create", "rename", "delete"], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": True, + "changeAnnotationSupport": {"groupsOnLabel": True}, + }, + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """Start PHPantom server process.""" + + def register_capability_handler(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting PHPantom server process") + self.server.start() + initialize_params = self._create_initialize_params() + + # negotiating server capabilities + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.info("After sent initialize params") + + capabilities = init_response["capabilities"] + assert "textDocumentSync" in capabilities + assert "completionProvider" in capabilities + assert "definitionProvider" in capabilities + assert "documentSymbolProvider" in capabilities, "Server must support document symbols" + assert capabilities.get("referencesProvider"), "PHPantom did not advertise references support" + + self.server.notify.initialized({}) + + @override + def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[protocol_lsp_types.Location] | None: + # waiting for cross-file index updates + sleep(1) + return super()._send_references_request(relative_file_path, line, column) + + @override + def _send_definition_request(self, definition_params: DefinitionParams) -> Definition | list[LocationLink] | None: + # waiting for cross-file index updates + sleep(1) + return super()._send_definition_request(definition_params) + + @override + def request_hover( + self, + relative_file_path: str, + line: int, + column: int, + file_buffer: LSPFileBuffer | None = None, + ) -> ls_types.Hover | None: + # requesting direct hover info + hover = super().request_hover(relative_file_path, line, column, file_buffer=file_buffer) + if hover is not None: + return hover + + # falling back to a usage-site hover + references = self.request_references(relative_file_path, line, column) + for reference in references: + ref_relative_path = reference.get("relativePath") + if ref_relative_path is None: + continue + start = reference["range"]["start"] + if ref_relative_path == relative_file_path and start["line"] == line and start["character"] == column: + continue + usage_hover = super().request_hover(ref_relative_path, start["line"], start["character"]) + if usage_hover is not None: + return usage_hover + + return None diff --git a/src/solidlsp/language_servers/powershell_language_server.py b/src/solidlsp/language_servers/powershell_language_server.py new file mode 100644 index 0000000..99bac57 --- /dev/null +++ b/src/solidlsp/language_servers/powershell_language_server.py @@ -0,0 +1,418 @@ +""" +Provides PowerShell specific instantiation of the LanguageServer class using PowerShell Editor Services. +Contains various configurations and settings specific to PowerShell scripting. + +You can pass the following entries in ``ls_specific_settings["powershell"]``: + - pses_version: Override the pinned PowerShell Editor Services version + downloaded by Serena (default: the bundled Serena version). + - psscriptanalyzer_version: Override the pinned PSScriptAnalyzer version + saved into the bundled PowerShell Editor Services module path. +""" + +import logging +import os +import platform +import shutil +import subprocess +import tempfile +import threading +from collections.abc import Hashable +from pathlib import Path + +from overrides import override + +from solidlsp import ls_types +from solidlsp.ls import LSPConstants, RawDocumentSymbol, SolidLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_types import SymbolKind +from solidlsp.ls_utils import FileUtils +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +# PowerShell Editor Services version to download +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_PSES_VERSION = "4.4.0" +INITIAL_PSES_SHA256 = "690b91092989a0f66e6f43986166aaef69d64b559a9fda51feed882e1103fbcc" +DEFAULT_PSES_VERSION = "4.4.0" +DEFAULT_PSES_SHA256 = "690b91092989a0f66e6f43986166aaef69d64b559a9fda51feed882e1103fbcc" + + +def _pses_sha(version: str) -> str | None: + if version == INITIAL_PSES_VERSION: + return INITIAL_PSES_SHA256 + if version == DEFAULT_PSES_VERSION: + return DEFAULT_PSES_SHA256 + return None + + +def _pses_install_dir(ls_resources_dir: str, version: str) -> Path: + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + if version == INITIAL_PSES_VERSION: + return Path(ls_resources_dir) / "powershell" + return Path(ls_resources_dir) / f"powershell-{version}" + + +PSES_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") +PSSCRIPTANALYZER_VERSION = "1.25.0" + + +class PowerShellLanguageServer(SolidLanguageServer): + """ + Provides PowerShell specific instantiation of the LanguageServer class using PowerShell Editor Services. + Contains various configurations and settings specific to PowerShell scripting. + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # For PowerShell projects, ignore common build/output directories + return super().is_ignored_dirname(dirname) or dirname in [ + "bin", + "obj", + ".vscode", + "TestResults", + "Output", + ] + + @staticmethod + def _get_pwsh_path() -> str | None: + """Get the path to PowerShell Core (pwsh) executable.""" + # Check if pwsh is in PATH + pwsh = shutil.which("pwsh") + if pwsh: + return pwsh + + # Check common installation locations + home = Path.home() + system = platform.system() + + possible_paths: list[Path] = [] + if system == "Windows": + possible_paths = [ + Path(os.environ.get("PROGRAMFILES", "C:\\Program Files")) / "PowerShell" / "7" / "pwsh.exe", + Path(os.environ.get("PROGRAMFILES", "C:\\Program Files")) / "PowerShell" / "7-preview" / "pwsh.exe", + home / "AppData" / "Local" / "Microsoft" / "PowerShell" / "pwsh.exe", + ] + elif system == "Darwin": + possible_paths = [ + Path("/usr/local/bin/pwsh"), + Path("/opt/homebrew/bin/pwsh"), + home / ".dotnet" / "tools" / "pwsh", + ] + else: # Linux + possible_paths = [ + Path("/usr/bin/pwsh"), + Path("/usr/local/bin/pwsh"), + Path("/opt/microsoft/powershell/7/pwsh"), + home / ".dotnet" / "tools" / "pwsh", + ] + + for path in possible_paths: + if path.exists(): + return str(path) + + return None + + @classmethod + def _get_pses_path(cls, solidlsp_settings: SolidLSPSettings) -> str | None: + """Get the path to PowerShell Editor Services installation.""" + ps_settings = solidlsp_settings.get_ls_specific_settings(Language.POWERSHELL) + pses_version = ps_settings.get("pses_version", DEFAULT_PSES_VERSION) + install_dir = _pses_install_dir(cls.ls_resources_dir(solidlsp_settings), pses_version) + start_script = install_dir / "PowerShellEditorServices" / "Start-EditorServices.ps1" + + if start_script.exists(): + return str(start_script) + + return None + + @classmethod + def _download_pses(cls, solidlsp_settings: SolidLSPSettings) -> str: + """Download and install PowerShell Editor Services.""" + ps_settings = solidlsp_settings.get_ls_specific_settings(Language.POWERSHELL) + pses_version = ps_settings.get("pses_version", DEFAULT_PSES_VERSION) + download_url = ( + f"https://github.com/PowerShell/PowerShellEditorServices/releases/download/v{pses_version}/PowerShellEditorServices.zip" + ) + + # Create installation directory; legacy unversioned dir reserved for INITIAL only + install_dir = _pses_install_dir(cls.ls_resources_dir(solidlsp_settings), pses_version) + install_dir.mkdir(parents=True, exist_ok=True) + + log.info(f"Downloading PowerShell Editor Services from {download_url}...") + FileUtils.download_and_extract_archive_verified( + download_url, + str(install_dir), + "zip", + expected_sha256=_pses_sha(pses_version), + allowed_hosts=PSES_ALLOWED_HOSTS, + ) + + start_script = install_dir / "PowerShellEditorServices" / "Start-EditorServices.ps1" + if not start_script.exists(): + raise RuntimeError(f"Failed to find Start-EditorServices.ps1 after extraction at {start_script}") + + log.info(f"PowerShell Editor Services installed at: {install_dir}") + return str(start_script) + + @classmethod + def _setup_runtime_dependency(cls, solidlsp_settings: SolidLSPSettings) -> tuple[str, str, str]: + """ + Check if required PowerShell runtime dependencies are available. + Downloads PowerShell Editor Services if not present. + + Returns: + tuple: (pwsh_path, start_script_path, bundled_modules_path) + + """ + # Check for PowerShell Core + pwsh_path = cls._get_pwsh_path() + if not pwsh_path: + raise RuntimeError( + "PowerShell Core (pwsh) is not installed or not in PATH. " + "Please install PowerShell 7+ from https://github.com/PowerShell/PowerShell" + ) + + # Check for PowerShell Editor Services + pses_path = cls._get_pses_path(solidlsp_settings) + if not pses_path: + log.info("PowerShell Editor Services not found. Downloading...") + pses_path = cls._download_pses(solidlsp_settings) + + # The bundled modules path is the directory containing PowerShellEditorServices + bundled_modules_path = str(Path(pses_path).parent) + psscriptanalyzer_version = solidlsp_settings.get_ls_specific_settings(Language.POWERSHELL).get( + "psscriptanalyzer_version", PSSCRIPTANALYZER_VERSION + ) + psscriptanalyzer_path = Path(bundled_modules_path) / "PSScriptAnalyzer" / psscriptanalyzer_version + if not psscriptanalyzer_path.exists(): + log.info(f"PSScriptAnalyzer {psscriptanalyzer_version} not found. Installing...") + subprocess.run( + [ + pwsh_path, + "-NoLogo", + "-NoProfile", + "-Command", + ( + "Save-Module " + "-Name PSScriptAnalyzer " + f"-RequiredVersion '{psscriptanalyzer_version}' " + f"-Path '{bundled_modules_path}' " + "-Force " + "-ErrorAction Stop" + ), + ], + check=True, + ) + + return pwsh_path, pses_path, bundled_modules_path + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + pwsh_path, pses_path, bundled_modules_path = self._setup_runtime_dependency(solidlsp_settings) + + # Create a temp directory for PSES logs and session details + pses_temp_dir = Path(tempfile.gettempdir()) / "solidlsp_pses" + pses_temp_dir.mkdir(parents=True, exist_ok=True) + log_path = pses_temp_dir / "pses.log" + session_details_path = pses_temp_dir / "session.json" + + # Build the command to start PowerShell Editor Services in stdio mode + # PSES requires several parameters beyond just -Stdio + # Using list format for robust argument handling - the PowerShell command + # after -Command must be a single string element + pses_command = ( + f"& '{pses_path}' " + f"-HostName 'SolidLSP' " + f"-HostProfileId 'solidlsp' " + f"-HostVersion '1.0.0' " + f"-BundledModulesPath '{bundled_modules_path}' " + f"-LogPath '{log_path}' " + f"-LogLevel 'Information' " + f"-SessionDetailsPath '{session_details_path}' " + f"-Stdio" + ) + cmd: list[str] = [ + pwsh_path, + "-NoLogo", + "-NoProfile", + "-Command", + pses_command, + ] + + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), + "powershell", + solidlsp_settings, + ) + self.server_ready = threading.Event() + + @override + def _document_symbols_cache_fingerprint(self) -> Hashable: + normalize_symbol_name_version = 1 + return normalize_symbol_name_version + + @override + def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: + original_name = symbol["name"] + symbol_kind = symbol.get("kind") + + # normalize class declarations + if symbol_kind == SymbolKind.Class: + return original_name.removeprefix("class ").split("{", 1)[0].strip() + + # normalize method signatures + if symbol_kind == SymbolKind.Method: + return original_name.split("(", 1)[0].rsplit(None, 1)[-1] + + # normalize function declarations + if symbol_kind == SymbolKind.Function: + return original_name.removeprefix("function ").split("(", 1)[0].strip() + + return original_name + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the PowerShell Editor Services. + """ + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": { + "dynamicRegistration": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + }, + }, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + }, + }, + "codeAction": {"dynamicRegistration": True}, + "formatting": {"dynamicRegistration": True}, + "rangeFormatting": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "configuration": True, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """ + Starts the PowerShell Editor Services, waits for the server to be ready. + """ + self._dynamic_capabilities: set[str] = set() + + def register_capability_handler(params: dict) -> None: + """Handle dynamic capability registration from PSES.""" + registrations = params.get("registrations", []) + for reg in registrations: + method = reg.get("method", "") + log.info(f"PSES registered dynamic capability: {method}") + self._dynamic_capabilities.add(method) + # Mark server ready when we get document symbol registration + if method == "textDocument/documentSymbol": + self.server_ready.set() + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + # Check for PSES ready signals + message_text = msg.get("message", "") + if "started" in message_text.lower() or "ready" in message_text.lower(): + log.info("PowerShell Editor Services ready signal detected") + self.server_ready.set() + + def do_nothing(params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("powerShell/executionStatusChanged", do_nothing) + + log.info("Starting PowerShell Editor Services process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.info(f"Received initialize response from PowerShell server: {init_response}") + + # Verify server capabilities - PSES uses dynamic capability registration + # so we check for either static or dynamic capabilities + capabilities = init_response.get("capabilities", {}) + log.info(f"Server capabilities: {capabilities}") + + # Send initialized notification to trigger dynamic capability registration + self.server.notify.initialized({}) + + # Wait for server readiness with timeout + log.info("Waiting for PowerShell Editor Services to be ready...") + if not self.server_ready.wait(timeout=10.0): + # Fallback: assume server is ready after timeout + log.info("Timeout waiting for PSES ready signal, proceeding anyway") + self.server_ready.set() + else: + log.info("PowerShell Editor Services initialization complete") + + @override + def request_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + published_uri = self._get_published_diagnostics_uri(uri) + diagnostics_before_request = self._get_published_diagnostics_generation(published_uri) + + with self.open_file(relative_file_path): + self.server.notify.did_save_text_document( + { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict + LSPConstants.TEXT_DOCUMENT: { + LSPConstants.URI: uri, + } + } + ) + diagnostics = self._wait_for_relevant_published_diagnostics( + uri=published_uri, + after_generation=diagnostics_before_request, + timeout=self._get_published_diagnostics_wait_timeout(True), + allow_cached=True, + ) + + if diagnostics is None: + return [] + + return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) diff --git a/src/solidlsp/language_servers/pyrefly_server.py b/src/solidlsp/language_servers/pyrefly_server.py new file mode 100644 index 0000000..b657895 --- /dev/null +++ b/src/solidlsp/language_servers/pyrefly_server.py @@ -0,0 +1,296 @@ +""" +Python language server integration using Meta's ``pyrefly``. + +You can pass the following entries in ``ls_specific_settings["python_pyrefly"]``: + - ls_path: Override the executable used to start ``pyrefly``. + - pyrefly_version: Override the pinned ``pyrefly`` version used with ``uvx`` / ``uv x`` + (default: the bundled Serena version). + - indexing_mode: Override pyrefly's LSP indexing mode (e.g. ``lazy-blocking``). + - workspace_indexing_limit: Override pyrefly's workspace indexing limit. +""" + +import logging +import os +import pathlib +import sys +import threading +import time +from typing import Any + +from typing_extensions import override + +from solidlsp import ls_types +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderUvx, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.lsp_protocol_handler.lsp_types import LSPErrorCodes +from solidlsp.lsp_protocol_handler.server import LSPError, PayloadLike +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +PYREFLY_VERSION = "1.1.1" +PYREFLY_CONFIG_DOC_URL = "https://pyrefly.org/en/docs/configuration/" + +# Pyrefly cancels in-flight requests with these error codes whenever its workspace state mutates +# (e.g. background re-indexing triggered by opening files). Such cancellations are transient. +_MUTATION_RETRY_ERROR_CODES = (LSPErrorCodes.RequestCancelled, LSPErrorCodes.ContentModified) + + +class PyreflyLanguageServer(SolidLanguageServer): + """ + Provides Python specific instantiation of the LanguageServer class using ``pyrefly``. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a PyreflyLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + super().__init__( + config, + repository_root_path, + None, + str(config.code_language), + solidlsp_settings, + ) + self._ensure_workspace_pyrefly_config() + self._indexing_complete = threading.Event() + self._active_progress_tokens: set[Any] = set() + + def _ensure_workspace_pyrefly_config(self) -> None: + """ + Ensure pyrefly has a stable workspace boundary by creating an empty ``pyrefly.toml`` when + neither ``pyrefly.toml`` nor ``pyproject.toml`` exists in the repository root. + """ + if not os.path.isdir(self.repository_root_path): + return + + pyrefly_config_path = os.path.join(self.repository_root_path, "pyrefly.toml") + pyproject_config_path = os.path.join(self.repository_root_path, "pyproject.toml") + + if not os.path.exists(pyrefly_config_path) and not os.path.exists(pyproject_config_path): + try: + pathlib.Path(pyrefly_config_path).touch() + log.warning( + "No config found in repository root (%s): neither `pyrefly.toml` nor `pyproject.toml` exists. " + "Created empty `%s` as a fallback so pyrefly won't search parent directories and mis-resolve imports. " + "See pyrefly config docs: %s", + self.repository_root_path, + pyrefly_config_path, + PYREFLY_CONFIG_DOC_URL, + ) + except OSError as exc: + log.warning("Failed to create fallback pyrefly config at %s: %s", pyrefly_config_path, exc) + + def _install_mutation_retry(self, max_retries: int = 5, retry_delay: float = 0.2) -> None: + """ + Wraps the underlying ``send_request`` so that requests canceled by pyrefly due to a + concurrent workspace mutation are transparently retried. + + Pyrefly performs background re-indexing (e.g. when files are opened) and cancels any + in-flight request with ``RequestCancelled`` (-32800) or ``ContentModified`` (-32801) + while that mutation is in progress. These cancellations are transient, so we re-issue + the request after a short delay instead of surfacing the error to the caller. + """ + inner_send_request = self.server.send_request + + def send_request_with_retry(method: str, params: dict | None = None) -> PayloadLike: + last_exc: SolidLSPException | None = None + for attempt in range(1, max_retries + 1): + try: + return inner_send_request(method, params) + except SolidLSPException as e: + cause = e.cause + if not (isinstance(cause, LSPError) and int(cause.code) in _MUTATION_RETRY_ERROR_CODES): + raise + last_exc = e + log.info( + "Pyrefly request %s canceled by concurrent mutation (code=%s); retrying (%d/%d)", + method, + cause.code, + attempt, + max_retries, + ) + time.sleep(retry_delay) + assert last_exc is not None + raise last_exc + + self.server.send_request = send_request_with_retry # type: ignore[method-assign] + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + extra_args = ["lsp"] + indexing_mode = self._custom_settings.get("indexing_mode") + if indexing_mode is not None: + extra_args.extend(["--indexing-mode", str(indexing_mode)]) + workspace_indexing_limit = self._custom_settings.get("workspace_indexing_limit") + if workspace_indexing_limit is not None: + extra_args.extend(["--workspace-indexing-limit", str(workspace_indexing_limit)]) + return LanguageServerDependencyProviderUvx( + self._custom_settings, + self._ls_resources_dir, + package="pyrefly", + entrypoint="pyrefly", + default_version=PYREFLY_VERSION, + version_setting_key="pyrefly_version", + extra_args=extra_args, + ) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"] + + @override + def request_defining_symbol( + self, + relative_file_path: str, + line: int, + column: int, + include_body: bool = False, + ) -> ls_types.UnifiedSymbolInformation | None: + symbol = super().request_defining_symbol(relative_file_path, line, column, include_body=include_body) + if symbol is None or symbol.get("name") != "__init__": + return symbol + + # Pyrefly resolves a constructor call (e.g. ``User(...)``) to the class's ``__init__`` method, + # whereas jedi/pyright resolve it to the class itself. For parity, return the enclosing class + # unless the caller pointed directly at the ``__init__`` definition. + location = symbol.get("location") + if location is not None: + def_path = location.get("relativePath") + def_line = location.get("range", {}).get("start", {}).get("line") + if (def_path, def_line) == (relative_file_path, line): + return symbol + + enclosing = self.request_container_of_symbol(symbol, include_body=include_body) + if enclosing is not None and enclosing.get("kind") == ls_types.SymbolKind.Class: + return enclosing + return symbol + + @override + def _get_language_id_for_file(self, relative_file_path: str) -> str: + return "python" + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the pyrefly language server. + """ + initialize_params = { + "capabilities": { + "window": { + "workDoneProgress": True, + }, + "workspace": { + "workspaceEdit": {"documentChanges": True}, + "configuration": True, + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + }, + "textDocument": { + "synchronization": { + "dynamicRegistration": True, + "willSave": True, + "willSaveWaitUntil": True, + "didSave": True, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "hierarchicalDocumentSymbolSupport": True, + }, + "implementation": {"dynamicRegistration": True, "linkSupport": True}, + "publishDiagnostics": {"relatedInformation": True}, + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """ + Starts the pyrefly language server and waits for initial workspace indexing to complete. + + Pyrefly defers indexing until it receives a workspace/configuration response, + then reports progress via $/progress notifications. + """ + + def execute_client_command_handler(params: dict) -> list: + return [] + + def do_nothing(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info("LSP: window/logMessage: %s", msg.get("message", "")) + + def workspace_configuration_handler(params: dict) -> list[dict]: + items = params.get("items", []) if isinstance(params, dict) else [] + log.info("Pyrefly workspace/configuration request received: %d items", len(items)) + config = { + "pythonPath": sys.executable, + "pyrefly": { + # Ensure workspace-wide analysis parity with VSCode's project mode behavior. + "diagnosticMode": "workspace", + }, + } + return [config.copy() for _ in items] + + def work_done_progress_create(params: dict) -> None: + return + + def on_progress(params: dict) -> None: + token = params.get("token") + value = params.get("value", {}) + kind = value.get("kind") + if kind == "begin": + self._active_progress_tokens.add(token) + log.info("Pyrefly progress begin: %s (token=%s)", value.get("title", ""), token) + elif kind == "end": + self._active_progress_tokens.discard(token) + log.info("Pyrefly progress end (token=%s)", token) + if not self._active_progress_tokens: + self._indexing_complete.set() + + # setting up handlers + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_request("workspace/configuration", workspace_configuration_handler) + self.server.on_request("window/workDoneProgress/create", work_done_progress_create) + self.server.on_notification("language/status", do_nothing) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", on_progress) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + # starting and initializing the server + log.info("Starting pyrefly language server process") + self.server.start() + # retry requests that pyrefly cancels while re-indexing the workspace in the background + self._install_mutation_retry() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to pyrefly server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.info("Received initialize response from pyrefly server: %s", init_response) + + capabilities = init_response["capabilities"] + assert "textDocumentSync" in capabilities + assert "definitionProvider" in capabilities + assert "referencesProvider" in capabilities + assert "documentSymbolProvider" in capabilities + + # completing the initialization handshake + self.server.notify.initialized({}) + + # wait for pyrefly to finish initial workspace indexing + log.info("Waiting for pyrefly to complete initial workspace indexing...") + if self._indexing_complete.wait(timeout=30.0): + log.info("Pyrefly initial indexing complete, server ready") + else: + log.warning("Timeout waiting for pyrefly indexing completion, proceeding anyway") diff --git a/src/solidlsp/language_servers/pyright_server.py b/src/solidlsp/language_servers/pyright_server.py new file mode 100644 index 0000000..b808d8f --- /dev/null +++ b/src/solidlsp/language_servers/pyright_server.py @@ -0,0 +1,240 @@ +""" +Provides Python specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Python. +""" + +import logging +import re +import threading +from typing import cast + +from overrides import override + +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderUvx, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +PYRIGHT_VERSION = "1.1.403" + + +class PyrightServer(SolidLanguageServer): + """ + Provides Python specific instantiation of the LanguageServer class using Pyright. + Contains various configurations and settings specific to Python. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a PyrightServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + super().__init__( + config, + repository_root_path, + None, + "python", + solidlsp_settings, + ) + + # Event to signal when initial workspace analysis is complete + self.analysis_complete = threading.Event() + self.found_source_files = False + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return LanguageServerDependencyProviderUvx( + self._custom_settings, + self._ls_resources_dir, + package="pyright", + entrypoint="pyright-langserver", + default_version=PYRIGHT_VERSION, + version_setting_key="pyright_version", + extra_args=("--stdio",), + ) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"] + + def _create_base_initialize_params(self) -> dict: + initialize_params = { + "initializationOptions": { + "exclude": [ + "**/__pycache__", + "**/.venv", + "**/.env", + "**/build", + "**/dist", + "**/.pixi", + ], + "reportMissingImports": "error", + }, + "capabilities": { + "workspace": { + "workspaceEdit": {"documentChanges": True}, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "executeCommand": {"dynamicRegistration": True}, + }, + "textDocument": { + "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + }, + }, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "hierarchicalDocumentSymbolSupport": True, + }, + "publishDiagnostics": {"relatedInformation": True}, + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """ + Starts the Pyright Language Server and waits for initial workspace analysis to complete. + + This prevents zombie processes by ensuring Pyright has finished its initial background + tasks before we consider the server ready. + + Usage: + ``` + async with lsp.start_server(): + # LanguageServer has been initialized and workspace analysis is complete + await lsp.request_definition(...) + await lsp.request_references(...) + # Shutdown the LanguageServer on exit from scope + # LanguageServer has been shutdown cleanly + ``` + """ + + def execute_client_command_handler(params: dict) -> list: + return [] + + def do_nothing(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + """ + Monitor Pyright's log messages to detect when initial analysis is complete. + Pyright logs "Found X source files" when it finishes scanning the workspace. + """ + message_text = msg.get("message", "") + log.info(f"LSP: window/logMessage: {message_text}") + + # Look for "Found X source files" which indicates workspace scanning is complete + # Unfortunately, pyright is unreliable and there seems to be no better way + if re.search(r"Found \d+ source files?", message_text): + log.info("Pyright workspace scanning complete") + self.found_source_files = True + self.analysis_complete.set() + + def handle_pyright_progress_notification(progress_kind: str, params: object | None) -> None: + """Tracks Pyright-specific progress notifications. + + Pyright can emit custom progress notifications instead of only using + ``$/progress``. Handling them avoids noisy unhandled-method warnings + and provides an additional signal that initial analysis has quiesced. + """ + # normalizing the notification payload + message_text = "" + percentage: object | None = None + if isinstance(params, dict): + params_dict = cast("dict[str, object]", params) + raw_message = params_dict.get("message") + message_text = "" if raw_message is None else str(raw_message) + percentage = params_dict.get("percentage") + elif params is not None: + message_text = str(params) + + progress_label = f"{message_text} ({percentage}%)" if percentage is not None else message_text + + # logging the progress transition + if progress_kind == "begin": + log.info("Pyright progress started: %s", progress_label) + return + + if progress_kind == "report": + log.debug("Pyright progress update: %s", progress_label) + return + + log.info("Pyright progress finished: %s", progress_label) + self.analysis_complete.set() + + def pyright_begin_progress(params: object | None) -> None: + """Handles the ``pyright/beginProgress`` notification.""" + # delegating to the shared progress handler + handle_pyright_progress_notification("begin", params) + + def pyright_report_progress(params: object | None) -> None: + """Handles the ``pyright/reportProgress`` notification.""" + # delegating to the shared progress handler + handle_pyright_progress_notification("report", params) + + def pyright_end_progress(params: object | None) -> None: + """Handles the ``pyright/endProgress`` notification.""" + # delegating to the shared progress handler + handle_pyright_progress_notification("end", params) + + def check_experimental_status(params: dict) -> None: + """ + Also listen for experimental/serverStatus as a backup signal + """ + if params.get("quiescent") == True: + log.info("Received experimental/serverStatus with quiescent=true") + if not self.found_source_files: + self.analysis_complete.set() + + # Set up notification handlers + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_notification("language/status", do_nothing) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("pyright/beginProgress", pyright_begin_progress) + self.server.on_notification("pyright/reportProgress", pyright_report_progress) + self.server.on_notification("pyright/endProgress", pyright_end_progress) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + self.server.on_notification("experimental/serverStatus", check_experimental_status) + + log.info("Starting pyright-langserver server process") + self.server.start() + + # Send proper initialization parameters + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to pyright server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.info(f"Received initialize response from pyright server: {init_response}") + + # Verify that the server supports our required features + assert "textDocumentSync" in init_response["capabilities"] + assert "completionProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + + # Complete the initialization handshake + self.server.notify.initialized({}) + + # Wait for Pyright to complete its initial workspace analysis + # This prevents zombie processes by ensuring background tasks finish + log.info("Waiting for Pyright to complete initial workspace analysis...") + if self.analysis_complete.wait(timeout=5.0): + log.info("Pyright initial analysis complete, server ready") + else: + log.warning("Timeout waiting for Pyright analysis completion, proceeding anyway") + # Fallback: assume analysis is complete after timeout + self.analysis_complete.set() diff --git a/src/solidlsp/language_servers/r_language_server.py b/src/solidlsp/language_servers/r_language_server.py new file mode 100644 index 0000000..8caeeed --- /dev/null +++ b/src/solidlsp/language_servers/r_language_server.py @@ -0,0 +1,146 @@ +import logging +import subprocess +from typing import Any + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class RLanguageServer(SolidLanguageServer): + """R Language Server implementation using the languageserver R package.""" + + @override + def _get_wait_time_for_cross_file_referencing(self) -> float: + return 5.0 # R language server needs extra time for workspace indexing in CI environments + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # For R projects, ignore common directories + return super().is_ignored_dirname(dirname) or dirname in [ + "renv", # R environment management + "packrat", # Legacy R package management + ".Rproj.user", # RStudio project files + "vignettes", # Package vignettes (often large) + ] + + @staticmethod + def _check_r_installation() -> None: + """Check if R and languageserver are available.""" + try: + # Check R installation + result = subprocess.run(["R", "--version"], capture_output=True, text=True, check=False) + if result.returncode != 0: + raise RuntimeError("R is not installed or not in PATH") + + # Check languageserver package + result = subprocess.run( + ["R", "--vanilla", "--quiet", "--slave", "-e", "if (!require('languageserver', quietly=TRUE)) quit(status=1)"], + capture_output=True, + text=True, + check=False, + ) + + if result.returncode != 0: + raise RuntimeError( + "R languageserver package is not installed.\nInstall it with: R -e \"install.packages('languageserver')\"" + ) + + except FileNotFoundError: + raise RuntimeError("R is not installed. Please install R from https://www.r-project.org/") + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + # Check R installation + self._check_r_installation() + + # R command to start language server + # Use --vanilla for minimal startup and --quiet to suppress all output except LSP + # Set specific options to improve parsing stability + r_cmd = 'R --vanilla --quiet --slave -e "options(languageserver.debug_mode = FALSE); languageserver::run()"' + + super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=r_cmd, cwd=repository_root_path), "r", solidlsp_settings) + + def _create_base_initialize_params(self) -> dict: + """Initialize params for R Language Server.""" + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": { + "dynamicRegistration": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + }, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "formatting": {"dynamicRegistration": True}, + "rangeFormatting": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """Start R Language Server process.""" + + def window_log_message(msg: dict) -> None: + log.info(f"R LSP: window/logMessage: {msg}") + + def do_nothing(params: Any) -> None: + return + + def register_capability_handler(params: Any) -> None: + return + + # Register LSP message handlers + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting R Language Server process") + self.server.start() + + initialize_params = self._create_initialize_params() + log.info( + "Sending initialize request to R Language Server", + ) + + init_response = self.server.send.initialize(initialize_params) + + # Verify server capabilities + capabilities = init_response.get("capabilities", {}) + assert "textDocumentSync" in capabilities + if "completionProvider" in capabilities: + log.info("R LSP completion provider available") + if "definitionProvider" in capabilities: + log.info("R LSP definition provider available") + + self.server.notify.initialized({}) + + # R Language Server is ready after initialization diff --git a/src/solidlsp/language_servers/regal_server.py b/src/solidlsp/language_servers/regal_server.py new file mode 100644 index 0000000..c19f556 --- /dev/null +++ b/src/solidlsp/language_servers/regal_server.py @@ -0,0 +1,117 @@ +"""Regal Language Server implementation for Rego policy files.""" + +import logging +import shutil + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class RegalLanguageServer(SolidLanguageServer): + """ + Provides Rego specific instantiation of the LanguageServer class using Regal. + + Regal is the official linter and language server for Rego (Open Policy Agent's policy language). + See: https://github.com/StyraInc/regal + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [".regal", ".opa"] + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a RegalLanguageServer instance. + + This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + + :param config: Language server configuration + :param repository_root_path: Path to the repository root + :param solidlsp_settings: Settings for solidlsp + """ + # Regal should be installed system-wide (via CI or user installation) + regal_executable_path = shutil.which("regal") + if not regal_executable_path: + raise RuntimeError( + "Regal language server not found. Please install it from https://github.com/StyraInc/regal or via your package manager." + ) + + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=f"{regal_executable_path} language-server", cwd=repository_root_path), + "rego", + solidlsp_settings, + ) + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Regal Language Server. + + :return: LSP initialization parameters + """ + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "codeAction": {"dynamicRegistration": True}, + "formatting": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """Start Regal language server process and wait for initialization.""" + + def register_capability_handler(params) -> None: + return + + def window_log_message(msg) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(params) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting Regal language server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info( + "Sending initialize request from LSP client to LSP server and awaiting response", + ) + init_response = self.server.send.initialize(initialize_params) + + # Verify server capabilities + assert "capabilities" in init_response + assert "textDocumentSync" in init_response["capabilities"] + + self.server.notify.initialized({}) + + # Regal server is ready immediately after initialization diff --git a/src/solidlsp/language_servers/ruby_lsp.py b/src/solidlsp/language_servers/ruby_lsp.py new file mode 100644 index 0000000..0e66b16 --- /dev/null +++ b/src/solidlsp/language_servers/ruby_lsp.py @@ -0,0 +1,497 @@ +""" +Ruby LSP Language Server implementation using Shopify's ruby-lsp. +Provides modern Ruby language server capabilities with improved performance. + +You can pass the following entries in ``ls_specific_settings["ruby"]``: + - ruby_lsp_version: Override the pinned ruby-lsp gem version installed by + Serena when no project-local or global ruby-lsp is already available + (default: the bundled Serena version). +""" + +import json +import logging +import os +import shutil +import subprocess +import threading + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.lsp_protocol_handler.lsp_types import InitializeResult +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +RUBY_LSP_VERSION = "0.26.8" + + +class RubyLsp(SolidLanguageServer): + """ + Provides Ruby specific instantiation of the LanguageServer class using ruby-lsp. + Contains various configurations and settings specific to Ruby with modern LSP features. + Supports overriding the bundled gem version via ``ruby_lsp_version``. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a RubyLsp instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + ruby_lsp_executable = self._setup_runtime_dependencies(config, repository_root_path, solidlsp_settings) + super().__init__( + config, repository_root_path, ProcessLaunchInfo(cmd=ruby_lsp_executable, cwd=repository_root_path), "ruby", solidlsp_settings + ) + self.analysis_complete = threading.Event() + self.service_ready_event = threading.Event() + + # Set timeout for ruby-lsp requests - ruby-lsp is fast + self.set_request_timeout(30.0) # 30 seconds for initialization and requests + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + """Override to ignore Ruby-specific directories that cause performance issues.""" + ruby_ignored_dirs = [ + "vendor", # Ruby vendor directory + ".bundle", # Bundler cache + "tmp", # Temporary files + "log", # Log files + "coverage", # Test coverage reports + ".yardoc", # YARD documentation cache + "doc", # Generated documentation + "node_modules", # Node modules (for Rails with JS) + "storage", # Active Storage files (Rails) + "public/packs", # Webpacker output + "public/webpack", # Webpack output + "public/assets", # Rails compiled assets + ".ruby-lsp", + ] + return super().is_ignored_dirname(dirname) or dirname in ruby_ignored_dirs + + @override + def _get_wait_time_for_cross_file_referencing(self) -> float: + """Override to provide optimal wait time for ruby-lsp cross-file reference resolution. + + ruby-lsp typically initializes quickly, but may need a brief moment + for cross-file analysis in larger projects. + """ + return 0.5 # 500ms should be sufficient for ruby-lsp + + @staticmethod + def _find_executable_with_extensions(executable_name: str) -> str | None: + """ + Find executable with Windows-specific extensions (.bat, .cmd, .exe) if on Windows. + Returns the full path to the executable or None if not found. + """ + import platform + + if platform.system() == "Windows": + # Try Windows-specific extensions first + for ext in [".bat", ".cmd", ".exe"]: + path = shutil.which(f"{executable_name}{ext}") + if path: + return path + # Fall back to default search + return shutil.which(executable_name) + else: + # Unix systems + return shutil.which(executable_name) + + @staticmethod + def _setup_runtime_dependencies( + config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ) -> list[str]: + """ + Setup runtime dependencies for ruby-lsp and return the command list to start the server. + Installation strategy: Bundler project > global ruby-lsp > gem install ruby-lsp at the pinned version + """ + ls_specific_settings = solidlsp_settings.get_ls_specific_settings(Language.RUBY) + ruby_lsp_version = ls_specific_settings.get("ruby_lsp_version", RUBY_LSP_VERSION) + # Detect Ruby version manager environment + # Using the version manager's exec wrapper ensures commands run with the correct Ruby version + # and its gems. + # + # Priority order: + # 1. rbenv - .ruby-version + rbenv binary; uses "rbenv exec " + # 2. mise - .ruby-version + mise binary; uses "mise exec ruby -- " (scoped to ruby + # tool only, avoids activating unrelated tools in .tool-versions) + # 3. asdf - .tool-versions + asdf binary; uses "asdf exec " + # 4. RVM - .ruby-version + rvm-exec binary; uses "rvm-exec ruby-X.Y.Z " + # rvm-exec requires the version string as first arg (e.g. "ruby-3.2.0"); + # may live at ~/.rvm/bin/rvm-exec if not on PATH + # 5. system Ruby - fallback, may cause version/gem mismatches + ruby_version_file = os.path.join(repository_root_path, ".ruby-version") + tool_versions_file = os.path.join(repository_root_path, ".tool-versions") + rvm_exec_path = shutil.which("rvm-exec") or os.path.join(os.path.expanduser("~"), ".rvm", "bin", "rvm-exec") + + use_rbenv = os.path.exists(ruby_version_file) and shutil.which("rbenv") is not None + use_mise = os.path.exists(ruby_version_file) and not use_rbenv and shutil.which("mise") is not None + use_asdf = os.path.exists(tool_versions_file) and not use_rbenv and not use_mise and shutil.which("asdf") is not None + use_rvm = os.path.exists(ruby_version_file) and not use_rbenv and not use_mise and not use_asdf and os.path.exists(rvm_exec_path) + + if use_rbenv: + ruby_cmd = ["rbenv", "exec", "ruby"] + bundle_cmd = ["rbenv", "exec", "bundle"] + log.info(f"Using rbenv-managed Ruby (found {ruby_version_file})") + elif use_mise: + ruby_cmd = ["mise", "exec", "ruby", "--", "ruby"] + bundle_cmd = ["mise", "exec", "ruby", "--", "bundle"] + log.info(f"Using mise-managed Ruby (found {ruby_version_file})") + elif use_asdf: + ruby_cmd = ["asdf", "exec", "ruby"] + bundle_cmd = ["asdf", "exec", "bundle"] + log.info(f"Using asdf-managed Ruby (found {tool_versions_file})") + elif use_rvm: + with open(ruby_version_file) as _f: + raw_version = _f.read().strip() + rvm_ruby_version = raw_version if raw_version.startswith("ruby-") else f"ruby-{raw_version}" + ruby_cmd = [rvm_exec_path, rvm_ruby_version, "ruby"] + bundle_cmd = [rvm_exec_path, rvm_ruby_version, "bundle"] + log.info(f"Using RVM-managed Ruby (found {ruby_version_file}, version={rvm_ruby_version})") + else: + ruby_cmd = ["ruby"] + bundle_cmd = ["bundle"] + if os.path.exists(ruby_version_file) or os.path.exists(tool_versions_file): + log.warning( + "Found Ruby version file but no supported version manager (rbenv, mise, asdf, rvm) detected. " + "Using system Ruby. Consider installing mise: https://mise.jdx.dev", + ) + else: + log.info("No Ruby version file found, using system Ruby") + + # Check if Ruby is installed + try: + result = subprocess.run(ruby_cmd + ["--version"], check=True, capture_output=True, cwd=repository_root_path, text=True) + ruby_version = result.stdout.strip() + log.info(f"Ruby version: {ruby_version}") + + # Extract version number for compatibility checks + import re + + version_match = re.search(r"ruby (\d+)\.(\d+)\.(\d+)", ruby_version) + if version_match: + major, minor, patch = map(int, version_match.groups()) + if major < 2 or (major == 2 and minor < 6): + log.warning(f"Warning: Ruby {major}.{minor}.{patch} detected. ruby-lsp works best with Ruby 2.6+") + + except subprocess.CalledProcessError as e: + error_msg = e.stderr if isinstance(e.stderr, str) else e.stderr.decode() if e.stderr else "Unknown error" + raise RuntimeError( + f"Error checking Ruby installation: {error_msg}. Please ensure Ruby is properly installed and in PATH." + ) from e + except FileNotFoundError as e: + raise RuntimeError( + "Ruby is not installed or not found in PATH. Please install Ruby using one of these methods:\n" + " - Using mise: mise install ruby && mise use ruby (https://mise.jdx.dev)\n" + " - Using rbenv: rbenv install 3.0.0 && rbenv global 3.0.0\n" + " - Using asdf: asdf install ruby 3.0.0 && asdf global ruby 3.0.0\n" + " - Using RVM: rvm install 3.0.0 && rvm use 3.0.0 --default\n" + " - System package manager (brew install ruby, apt install ruby, etc.)" + ) from e + + # Check for Bundler project (Gemfile exists) + gemfile_path = os.path.join(repository_root_path, "Gemfile") + gemfile_lock_path = os.path.join(repository_root_path, "Gemfile.lock") + is_bundler_project = os.path.exists(gemfile_path) + + if is_bundler_project: + log.info("Detected Bundler project (Gemfile found)") + + # Check if bundle command is available using Windows-compatible search + bundle_path = RubyLsp._find_executable_with_extensions(bundle_cmd[0] if len(bundle_cmd) == 1 else "bundle") + if not bundle_path: + # Try common bundle executables + for bundle_executable in ["bin/bundle", "bundle"]: + bundle_full_path: str | None + if bundle_executable.startswith("bin/"): + bundle_full_path = os.path.join(repository_root_path, bundle_executable) + else: + bundle_full_path = RubyLsp._find_executable_with_extensions(bundle_executable) + if bundle_full_path and os.path.exists(bundle_full_path): + bundle_path = bundle_full_path if bundle_executable.startswith("bin/") else bundle_executable + break + + if not bundle_path: + log.warning( + "Bundler project detected but 'bundle' command not found. Falling back to global ruby-lsp installation.", + ) + else: + # Check if ruby-lsp is in Gemfile.lock + ruby_lsp_in_bundle = False + if os.path.exists(gemfile_lock_path): + try: + with open(gemfile_lock_path) as f: + content = f.read() + ruby_lsp_in_bundle = "ruby-lsp" in content.lower() + except Exception as e: + log.warning(f"Warning: Could not read Gemfile.lock: {e}") + + if ruby_lsp_in_bundle: + log.info("Found ruby-lsp in Gemfile.lock") + return bundle_cmd + ["exec", "ruby-lsp"] + else: + log.info( + "ruby-lsp not found in Gemfile.lock. Consider adding 'gem \"ruby-lsp\"' to your Gemfile for better compatibility.", + ) + # Fall through to global installation check + + # Check if ruby-lsp is available globally using Windows-compatible search + ruby_lsp_path = RubyLsp._find_executable_with_extensions("ruby-lsp") + if ruby_lsp_path: + log.info(f"Found ruby-lsp at: {ruby_lsp_path}") + return [ruby_lsp_path] + + # Try to install ruby-lsp globally + log.info("ruby-lsp not found, attempting to install globally...") + try: + subprocess.run( + ["gem", "install", "ruby-lsp", "-v", ruby_lsp_version], + check=True, + capture_output=True, + cwd=repository_root_path, + ) + log.info("Successfully installed ruby-lsp globally") + # Find the newly installed ruby-lsp executable + ruby_lsp_path = RubyLsp._find_executable_with_extensions("ruby-lsp") + return [ruby_lsp_path] if ruby_lsp_path else ["ruby-lsp"] + except subprocess.CalledProcessError as e: + error_msg = e.stderr if isinstance(e.stderr, str) else e.stderr.decode() if e.stderr else str(e) + if is_bundler_project: + raise RuntimeError( + f"Failed to install ruby-lsp globally: {error_msg}\n" + "For Bundler projects, please add 'gem \"ruby-lsp\"' to your Gemfile and run 'bundle install'.\n" + f"Alternatively, install globally: gem install ruby-lsp -v {ruby_lsp_version}" + ) from e + raise RuntimeError( + f"Failed to install ruby-lsp: {error_msg}\nPlease try installing manually: gem install ruby-lsp -v {ruby_lsp_version}" + ) from e + + @staticmethod + def _detect_rails_project(repository_root_path: str) -> bool: + """ + Detect if this is a Rails project by checking for Rails-specific files. + """ + rails_indicators = [ + "config/application.rb", + "config/environment.rb", + "app/controllers/application_controller.rb", + "Rakefile", + ] + + for indicator in rails_indicators: + if os.path.exists(os.path.join(repository_root_path, indicator)): + return True + + # Check for Rails in Gemfile + gemfile_path = os.path.join(repository_root_path, "Gemfile") + if os.path.exists(gemfile_path): + try: + with open(gemfile_path) as f: + content = f.read().lower() + if "gem 'rails'" in content or 'gem "rails"' in content: + return True + except Exception: + pass + + return False + + @staticmethod + def _get_ruby_exclude_patterns(repository_root_path: str) -> list[str]: + """ + Get Ruby and Rails-specific exclude patterns for better performance. + """ + base_patterns = [ + "**/vendor/**", # Ruby vendor directory + "**/.bundle/**", # Bundler cache + "**/tmp/**", # Temporary files + "**/log/**", # Log files + "**/coverage/**", # Test coverage reports + "**/.yardoc/**", # YARD documentation cache + "**/doc/**", # Generated documentation + "**/.git/**", # Git directory + "**/node_modules/**", # Node modules (for Rails with JS) + "**/public/assets/**", # Rails compiled assets + ] + + # Add Rails-specific patterns if this is a Rails project + if RubyLsp._detect_rails_project(repository_root_path): + base_patterns.extend( + [ + "**/app/assets/builds/**", # Rails 7+ CSS builds + "**/storage/**", # Active Storage + "**/public/packs/**", # Webpacker + "**/public/webpack/**", # Webpack + ] + ) + + return base_patterns + + def _create_base_initialize_params(self) -> dict: + """ + Returns ruby-lsp specific initialization parameters. + """ + exclude_patterns = self._get_ruby_exclude_patterns(self.repository_root_path) + + initialize_params = { + "capabilities": { + "workspace": { + "workspaceEdit": {"documentChanges": True}, + "configuration": True, + }, + "window": { + "workDoneProgress": True, + }, + "textDocument": { + "documentSymbol": { + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "completion": { + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + } + }, + }, + }, + "initializationOptions": { + # ruby-lsp enables all features by default, so we don't need to specify enabledFeatures + "experimentalFeaturesEnabled": False, + "featuresConfiguration": {}, + "indexing": { + "includedPatterns": ["**/*.rb", "**/*.rake", "**/*.ru", "**/*.erb"], + "excludedPatterns": exclude_patterns, + }, + }, + } + + return initialize_params + + def _start_server(self) -> None: + """ + Starts the ruby-lsp Language Server for Ruby + """ + + def register_capability_handler(params: dict) -> None: + assert "registrations" in params + for registration in params["registrations"]: + log.info(f"Registered capability: {registration['method']}") + return + + def lang_status_handler(params: dict) -> None: + log.info(f"LSP: language/status: {params}") + if params.get("type") == "ready": + log.info("ruby-lsp service is ready.") + self.analysis_complete.set() + + def execute_client_command_handler(params: dict) -> list: + return [] + + def do_nothing(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def progress_handler(params: dict) -> None: + # ruby-lsp sends progress notifications during indexing + log.debug(f"LSP: $/progress: {params}") + if "value" in params: + value = params["value"] + # Check for completion indicators + if value.get("kind") == "end": + log.info("ruby-lsp indexing complete ($/progress end)") + self.analysis_complete.set() + elif value.get("kind") == "begin": + log.info("ruby-lsp indexing started ($/progress begin)") + elif "percentage" in value: + percentage = value.get("percentage", 0) + log.debug(f"ruby-lsp indexing progress: {percentage}%") + # Handle direct progress format (fallback) + elif "token" in params and "value" in params: + token = params.get("token") + if isinstance(token, str) and "indexing" in token.lower(): + value = params.get("value", {}) + if value.get("kind") == "end" or value.get("percentage") == 100: + log.info("ruby-lsp indexing complete (token progress)") + self.analysis_complete.set() + + def window_work_done_progress_create(params: dict) -> dict: + """Handle workDoneProgress/create requests from ruby-lsp""" + log.debug(f"LSP: window/workDoneProgress/create: {params}") + return {} + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("language/status", lang_status_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", progress_handler) + self.server.on_request("window/workDoneProgress/create", window_work_done_progress_create) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting ruby-lsp server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + log.info(f"Sending init params: {json.dumps(initialize_params, indent=4)}") + init_response = self.server.send.initialize(initialize_params) + log.info(f"Received init response: {init_response}") + + # Verify expected capabilities + # Note: ruby-lsp may return textDocumentSync in different formats (number or object) + text_document_sync = init_response["capabilities"].get("textDocumentSync") + if isinstance(text_document_sync, int): + assert text_document_sync in [1, 2], f"Unexpected textDocumentSync value: {text_document_sync}" + elif isinstance(text_document_sync, dict): + # ruby-lsp returns an object with change property + assert "change" in text_document_sync, "textDocumentSync object should have 'change' property" + + assert "completionProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + # Wait for ruby-lsp to complete its initial indexing + # ruby-lsp has fast indexing + log.info("Waiting for ruby-lsp to complete initial indexing...") + if self.analysis_complete.wait(timeout=30.0): + log.info("ruby-lsp initial indexing complete, server ready") + else: + log.warning("Timeout waiting for ruby-lsp indexing completion, proceeding anyway") + # Fallback: assume indexing is complete after timeout + self.analysis_complete.set() + + def _handle_initialization_response(self, init_response: InitializeResult) -> None: + """ + Handle the initialization response from ruby-lsp and validate capabilities. + """ + if "capabilities" in init_response: + capabilities = init_response["capabilities"] + + # Validate textDocumentSync (ruby-lsp may return different formats) + text_document_sync = capabilities.get("textDocumentSync") + if isinstance(text_document_sync, int): + assert text_document_sync in [1, 2], f"Unexpected textDocumentSync value: {text_document_sync}" + elif isinstance(text_document_sync, dict): + # ruby-lsp returns an object with change property + assert "change" in text_document_sync, "textDocumentSync object should have 'change' property" + + # Log important capabilities + important_capabilities = [ + "completionProvider", + "hoverProvider", + "definitionProvider", + "referencesProvider", + "documentSymbolProvider", + "codeActionProvider", + "documentFormattingProvider", + "semanticTokensProvider", + ] + + for cap in important_capabilities: + if cap in capabilities: + log.debug(f"ruby-lsp {cap}: available") + + # Signal that the service is ready + self.service_ready_event.set() diff --git a/src/solidlsp/language_servers/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer.py new file mode 100644 index 0000000..2c01ed8 --- /dev/null +++ b/src/solidlsp/language_servers/rust_analyzer.py @@ -0,0 +1,741 @@ +""" +Provides Rust specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Rust. +""" + +import logging +import os +import pathlib +import platform +import shutil +import subprocess +import threading + +from overrides import override + +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class RustAnalyzer(SolidLanguageServer): + """ + Provides Rust specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Rust. + """ + + @classmethod + def supports_implementation_request(cls) -> bool: + return True + + @staticmethod + def _determine_log_level(line: str) -> int: + """Classify rust-analyzer stderr output to avoid false-positive errors.""" + line_lower = line.lower() + + # Known informational/warning messages from rust-analyzer that aren't critical errors + if any( + [ + "failed to find any projects in" in line_lower, + "fetchworkspaceerror" in line_lower, + ] + ): + return logging.DEBUG + + return SolidLanguageServer._determine_log_level(line) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + @staticmethod + def _get_rustup_version() -> str | None: + """Get installed rustup version or None if not found.""" + try: + result = subprocess.run(["rustup", "--version"], capture_output=True, text=True, check=False) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + return None + return None + + @staticmethod + def _get_rust_analyzer_via_rustup() -> str | None: + """Get rust-analyzer path via rustup. Returns None if not found.""" + try: + result = subprocess.run(["rustup", "which", "rust-analyzer"], capture_output=True, text=True, check=False) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + pass + return None + + @staticmethod + def _is_rust_analyzer_functional(path: str) -> bool: + """Check if a rust-analyzer binary is functional by running --version. + + This catches the case where rust-analyzer in PATH is actually a rustup proxy + that fails because the component is not installed. + """ + try: + result = subprocess.run([path, "--version"], capture_output=True, text=True, check=False, timeout=10) + return result.returncode == 0 + except (FileNotFoundError, OSError, subprocess.TimeoutExpired): + return False + + @staticmethod + def _ensure_rust_analyzer_installed() -> str: + """ + Ensure rust-analyzer is available. + + Priority order: + 1. Rustup existing installation (preferred - matches toolchain version) + 2. Rustup auto-install if rustup is available (ensures correct version) + 3. System PATH (covers Nix, distro packages, and other standalone installs) + 4. Common installation locations as final fallback + + :return: path to rust-analyzer executable + """ + # Try rustup FIRST (preferred - avoids picking up incompatible versions from PATH) + rustup_path = RustAnalyzer.DependencyProvider._get_rust_analyzer_via_rustup() + if rustup_path: + return rustup_path + + # If rustup is available but rust-analyzer not installed, auto-install it BEFORE + # checking PATH. This ensures we get the correct version matching the toolchain. + if RustAnalyzer.DependencyProvider._get_rustup_version(): + result = subprocess.run(["rustup", "component", "add", "rust-analyzer"], check=False, capture_output=True, text=True) + if result.returncode == 0: + # Verify installation worked + rustup_path = RustAnalyzer.DependencyProvider._get_rust_analyzer_via_rustup() + if rustup_path: + return rustup_path + # If auto-install failed, fall through to PATH and common paths + + # Check system PATH early - this covers Nix, distro packages, and other standalone installs. + # We verify the binary is functional to avoid picking up broken rustup proxy symlinks. + path_result = shutil.which("rust-analyzer") + if path_result and os.path.isfile(path_result) and os.access(path_result, os.X_OK): + if RustAnalyzer.DependencyProvider._is_rust_analyzer_functional(path_result): + return path_result + + # Determine platform-specific binary name and paths + is_windows = platform.system() == "Windows" + binary_name = "rust-analyzer.exe" if is_windows else "rust-analyzer" + + # Fallback to common installation locations + common_paths: list[str | None] = [] + + if is_windows: + # Windows-specific paths + home = pathlib.Path.home() + common_paths.extend( + [ + str(home / ".cargo" / "bin" / binary_name), # cargo install / rustup + str(home / "scoop" / "shims" / binary_name), # Scoop package manager + str(home / "scoop" / "apps" / "rust-analyzer" / "current" / binary_name), # Scoop direct + str( + pathlib.Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "rust-analyzer" / binary_name + ), # Standalone install + ] + ) + else: + # Unix-like paths (macOS, Linux) + common_paths.extend( + [ + "/opt/homebrew/bin/rust-analyzer", # macOS Homebrew (Apple Silicon) + "/usr/local/bin/rust-analyzer", # macOS Homebrew (Intel) / Linux system + os.path.expanduser("~/.cargo/bin/rust-analyzer"), # cargo install + os.path.expanduser("~/.local/bin/rust-analyzer"), # User local bin + ] + ) + + for path in common_paths: + if path and os.path.isfile(path) and os.access(path, os.X_OK): + return path + + # Provide helpful error message with all searched locations + searched = [p for p in common_paths if p] + install_instructions = [ + " - Rustup: rustup component add rust-analyzer", + " - Cargo: cargo install rust-analyzer", + ] + if is_windows: + install_instructions.extend( + [ + " - Scoop: scoop install rust-analyzer", + " - Chocolatey: choco install rust-analyzer", + " - Standalone: Download from https://github.com/rust-lang/rust-analyzer/releases", + ] + ) + else: + install_instructions.extend( + [ + " - Homebrew (macOS): brew install rust-analyzer", + " - System package manager (Linux): apt/dnf/pacman install rust-analyzer", + ] + ) + + raise RuntimeError( + "rust-analyzer is not installed or not in PATH.\n" + "Searched locations:\n" + "\n".join(f" - {p}" for p in searched) + "\n" + "Please install rust-analyzer via:\n" + "\n".join(install_instructions) + ) + + def _get_or_install_core_dependency(self) -> str: + return self._ensure_rust_analyzer_installed() + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path] + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a RustAnalyzer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + super().__init__( + config, + repository_root_path, + None, + "rust", + solidlsp_settings, + ) + self.server_ready = threading.Event() + self.service_ready_event = threading.Event() + self.initialize_searcher_command_available = threading.Event() + self.resolve_main_method_available = threading.Event() + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["target"] + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Rust Analyzer Language Server. + """ + initialize_params = { + "locale": "en", + "capabilities": { + "workspace": { + "applyEdit": True, + "workspaceEdit": { + "documentChanges": True, + "resourceOperations": ["create", "rename", "delete"], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": True, + "changeAnnotationSupport": {"groupsOnLabel": True}, + }, + "configuration": True, + "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "tagSupport": {"valueSet": [1]}, + "resolveSupport": {"properties": ["location.range"]}, + }, + "codeLens": {"refreshSupport": True}, + "executeCommand": {"dynamicRegistration": True}, + "didChangeConfiguration": {"dynamicRegistration": True}, + "workspaceFolders": True, + "semanticTokens": {"refreshSupport": True}, + "fileOperations": { + "dynamicRegistration": True, + "didCreate": True, + "didRename": True, + "didDelete": True, + "willCreate": True, + "willRename": True, + "willDelete": True, + }, + "inlineValue": {"refreshSupport": True}, + "inlayHint": {"refreshSupport": True}, + "diagnostics": {"refreshSupport": True}, + }, + "textDocument": { + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [1, 2]}, + "codeDescriptionSupport": True, + "dataSupport": True, + }, + "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, + "completion": { + "dynamicRegistration": True, + "contextSupport": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + "tagSupport": {"valueSet": [1]}, + "insertReplaceSupport": True, + "resolveSupport": {"properties": ["documentation", "detail", "additionalTextEdits"]}, + "insertTextModeSupport": {"valueSet": [1, 2]}, + "labelDetailsSupport": True, + }, + "insertTextMode": 2, + "completionItemKind": { + "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25] + }, + "completionList": {"itemDefaults": ["commitCharacters", "editRange", "insertTextFormat", "insertTextMode"]}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + "activeParameterSupport": True, + }, + "contextSupport": True, + }, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentHighlight": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "hierarchicalDocumentSymbolSupport": True, + "tagSupport": {"valueSet": [1]}, + "labelSupport": True, + }, + "codeAction": { + "dynamicRegistration": True, + "isPreferredSupport": True, + "disabledSupport": True, + "dataSupport": True, + "resolveSupport": {"properties": ["edit"]}, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + ] + } + }, + "honorsChangeAnnotations": False, + }, + "codeLens": {"dynamicRegistration": True}, + "formatting": {"dynamicRegistration": True}, + "rangeFormatting": {"dynamicRegistration": True}, + "onTypeFormatting": {"dynamicRegistration": True}, + "rename": { + "dynamicRegistration": True, + "prepareSupport": True, + "prepareSupportDefaultBehavior": 1, + "honorsChangeAnnotations": True, + }, + "documentLink": {"dynamicRegistration": True, "tooltipSupport": True}, + "typeDefinition": {"dynamicRegistration": True, "linkSupport": True}, + "implementation": {"dynamicRegistration": True, "linkSupport": True}, + "colorProvider": {"dynamicRegistration": True}, + "foldingRange": { + "dynamicRegistration": True, + "rangeLimit": 5000, + "lineFoldingOnly": True, + "foldingRangeKind": {"valueSet": ["comment", "imports", "region"]}, + "foldingRange": {"collapsedText": False}, + }, + "declaration": {"dynamicRegistration": True, "linkSupport": True}, + "selectionRange": {"dynamicRegistration": True}, + "callHierarchy": {"dynamicRegistration": True}, + "semanticTokens": { + "dynamicRegistration": True, + "tokenTypes": [ + "namespace", + "type", + "class", + "enum", + "interface", + "struct", + "typeParameter", + "parameter", + "variable", + "property", + "enumMember", + "event", + "function", + "method", + "macro", + "keyword", + "modifier", + "comment", + "string", + "number", + "regexp", + "operator", + "decorator", + ], + "tokenModifiers": [ + "declaration", + "definition", + "readonly", + "static", + "deprecated", + "abstract", + "async", + "modification", + "documentation", + "defaultLibrary", + ], + "formats": ["relative"], + "requests": {"range": True, "full": {"delta": True}}, + "multilineTokenSupport": False, + "overlappingTokenSupport": False, + "serverCancelSupport": True, + "augmentsSyntaxTokens": False, + }, + "linkedEditingRange": {"dynamicRegistration": True}, + "typeHierarchy": {"dynamicRegistration": True}, + "inlineValue": {"dynamicRegistration": True}, + "inlayHint": { + "dynamicRegistration": True, + "resolveSupport": {"properties": ["tooltip", "textEdits", "label.tooltip", "label.location", "label.command"]}, + }, + "diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False}, + }, + "window": { + "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, + "showDocument": {"support": True}, + "workDoneProgress": True, + }, + "general": { + "staleRequestSupport": { + "cancel": True, + "retryOnContentModified": [ + "textDocument/semanticTokens/full", + "textDocument/semanticTokens/range", + "textDocument/semanticTokens/full/delta", + ], + }, + "regularExpressions": {"engine": "ECMAScript", "version": "ES2020"}, + "markdown": { + "parser": "marked", + "version": "1.1.0", + "allowedTags": [ + "ul", + "li", + "p", + "code", + "blockquote", + "ol", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "em", + "pre", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + "div", + "del", + "a", + "strong", + "br", + "img", + "span", + ], + }, + "positionEncodings": ["utf-16"], + }, + "notebookDocument": {"synchronization": {"dynamicRegistration": True, "executionSummarySupport": True}}, + "experimental": { + "snippetTextEdit": True, + "codeActionGroup": True, + "hoverActions": True, + "serverStatusNotification": True, + "colorDiagnosticOutput": True, + "openServerLogs": True, + "localDocs": True, + "commands": { + "commands": [ + "rust-analyzer.runSingle", + "rust-analyzer.debugSingle", + "rust-analyzer.showReferences", + "rust-analyzer.gotoLocation", + "editor.action.triggerParameterHints", + ] + }, + }, + }, + "initializationOptions": { + "cargoRunner": None, + "runnables": {"extraEnv": None, "problemMatcher": ["$rustc"], "command": None, "extraArgs": []}, + "statusBar": {"clickAction": "openLogs"}, + "server": {"path": None, "extraEnv": None}, + "trace": {"server": "verbose", "extension": False}, + "debug": { + "engine": "auto", + "sourceFileMap": {"/rustc/": "${env:USERPROFILE}/.rustup/toolchains//lib/rustlib/src/rust"}, + "openDebugPane": False, + "engineSettings": {}, + }, + "restartServerOnConfigChange": False, + "typing": {"continueCommentsOnNewline": True, "autoClosingAngleBrackets": {"enable": False}}, + "diagnostics": { + "previewRustcOutput": False, + "useRustcErrorCode": False, + "disabled": [], + "enable": True, + "experimental": {"enable": False}, + "remapPrefix": {}, + "warningsAsHint": [], + "warningsAsInfo": [], + }, + "discoverProjectRunner": None, + "showUnlinkedFileNotification": True, + "showDependenciesExplorer": True, + "assist": {"emitMustUse": False, "expressionFillDefault": "todo"}, + "cachePriming": {"enable": True, "numThreads": 0}, + "cargo": { + "autoreload": True, + "buildScripts": { + "enable": True, + "invocationLocation": "workspace", + "invocationStrategy": "per_workspace", + "overrideCommand": None, + "useRustcWrapper": True, + }, + "cfgs": [], + "extraArgs": [], + "extraEnv": {}, + "features": [], + "noDefaultFeatures": False, + "sysroot": "discover", + "sysrootSrc": None, + "target": None, + "unsetTest": ["core"], + }, + "checkOnSave": True, + "check": { + "allTargets": True, + "command": "check", + "extraArgs": [], + "extraEnv": {}, + "features": None, + "ignore": [], + "invocationLocation": "workspace", + "invocationStrategy": "per_workspace", + "noDefaultFeatures": None, + "overrideCommand": None, + "targets": None, + }, + "completion": { + "autoimport": {"enable": True}, + "autoself": {"enable": True}, + "callable": {"snippets": "fill_arguments"}, + "fullFunctionSignatures": {"enable": False}, + "limit": None, + "postfix": {"enable": True}, + "privateEditable": {"enable": False}, + "snippets": { + "custom": { + "Arc::new": { + "postfix": "arc", + "body": "Arc::new(${receiver})", + "requires": "std::sync::Arc", + "description": "Put the expression into an `Arc`", + "scope": "expr", + }, + "Rc::new": { + "postfix": "rc", + "body": "Rc::new(${receiver})", + "requires": "std::rc::Rc", + "description": "Put the expression into an `Rc`", + "scope": "expr", + }, + "Box::pin": { + "postfix": "pinbox", + "body": "Box::pin(${receiver})", + "requires": "std::boxed::Box", + "description": "Put the expression into a pinned `Box`", + "scope": "expr", + }, + "Ok": { + "postfix": "ok", + "body": "Ok(${receiver})", + "description": "Wrap the expression in a `Result::Ok`", + "scope": "expr", + }, + "Err": { + "postfix": "err", + "body": "Err(${receiver})", + "description": "Wrap the expression in a `Result::Err`", + "scope": "expr", + }, + "Some": { + "postfix": "some", + "body": "Some(${receiver})", + "description": "Wrap the expression in an `Option::Some`", + "scope": "expr", + }, + } + }, + }, + "files": {"excludeDirs": [], "watcher": "client"}, + "highlightRelated": { + "breakPoints": {"enable": True}, + "closureCaptures": {"enable": True}, + "exitPoints": {"enable": True}, + "references": {"enable": True}, + "yieldPoints": {"enable": True}, + }, + "hover": { + "actions": { + "debug": {"enable": True}, + "enable": True, + "gotoTypeDef": {"enable": True}, + "implementations": {"enable": True}, + "references": {"enable": False}, + "run": {"enable": True}, + }, + "documentation": {"enable": True, "keywords": {"enable": True}}, + "links": {"enable": True}, + "memoryLayout": {"alignment": "hexadecimal", "enable": True, "niches": False, "offset": "hexadecimal", "size": "both"}, + }, + "imports": { + "granularity": {"enforce": False, "group": "crate"}, + "group": {"enable": True}, + "merge": {"glob": True}, + "preferNoStd": False, + "preferPrelude": False, + "prefix": "plain", + }, + "inlayHints": { + "bindingModeHints": {"enable": False}, + "chainingHints": {"enable": True}, + "closingBraceHints": {"enable": True, "minLines": 25}, + "closureCaptureHints": {"enable": False}, + "closureReturnTypeHints": {"enable": "never"}, + "closureStyle": "impl_fn", + "discriminantHints": {"enable": "never"}, + "expressionAdjustmentHints": {"enable": "never", "hideOutsideUnsafe": False, "mode": "prefix"}, + "lifetimeElisionHints": {"enable": "never", "useParameterNames": False}, + "maxLength": 25, + "parameterHints": {"enable": True}, + "reborrowHints": {"enable": "never"}, + "renderColons": True, + "typeHints": {"enable": True, "hideClosureInitialization": False, "hideNamedConstructor": False}, + }, + "interpret": {"tests": False}, + "joinLines": {"joinAssignments": True, "joinElseIf": True, "removeTrailingComma": True, "unwrapTrivialBlock": True}, + "lens": { + "debug": {"enable": True}, + "enable": True, + "forceCustomCommands": True, + "implementations": {"enable": True}, + "location": "above_name", + "references": { + "adt": {"enable": False}, + "enumVariant": {"enable": False}, + "method": {"enable": False}, + "trait": {"enable": False}, + }, + "run": {"enable": True}, + }, + "linkedProjects": [], + "lru": {"capacity": None, "query": {"capacities": {}}}, + "notifications": {"cargoTomlNotFound": True}, + "numThreads": None, + "procMacro": {"attributes": {"enable": True}, "enable": True, "ignored": {}, "server": None}, + "references": {"excludeImports": False}, + "rust": {"analyzerTargetDir": None}, + "rustc": {"source": None}, + "rustfmt": {"extraArgs": [], "overrideCommand": None, "rangeFormatting": {"enable": False}}, + "semanticHighlighting": { + "doc": {"comment": {"inject": {"enable": True}}}, + "nonStandardTokens": True, + "operator": {"enable": True, "specialization": {"enable": False}}, + "punctuation": {"enable": False, "separate": {"macro": {"bang": False}}, "specialization": {"enable": False}}, + "strings": {"enable": True}, + }, + "signatureInfo": {"detail": "full", "documentation": {"enable": True}}, + "workspace": {"symbol": {"search": {"kind": "only_types", "limit": 128, "scope": "workspace"}}}, + }, + "trace": "verbose", + } + return initialize_params + + def _start_server(self) -> None: + """ + Starts the Rust Analyzer Language Server + """ + + def register_capability_handler(params: dict) -> None: + for registration in params.get("registrations", []): + if registration.get("method") == "workspace/executeCommand": + self.initialize_searcher_command_available.set() + self.resolve_main_method_available.set() + return + + def lang_status_handler(params: dict) -> None: + # TODO: Should we wait for + # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} + # Before proceeding? + if params.get("type") == "ServiceReady" and params.get("message") == "ServiceReady": + self.service_ready_event.set() + + def execute_client_command_handler(params: dict) -> list: + return [] + + def do_nothing(params: dict) -> None: + return + + def check_experimental_status(params: dict) -> None: + if params.get("quiescent") is True: + self.server_ready.set() + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("language/status", lang_status_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + self.server.on_notification("experimental/serverStatus", check_experimental_status) + + log.info("Starting RustAnalyzer server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + # Validate key server capabilities. These are sanity checks, not hard requirements: + # rust-analyzer may evolve its advertised capabilities across versions, so a mismatch is + # logged rather than asserted. (An over-strict equality check on completionProvider here + # previously crashed startup whenever upstream added or changed a completionProvider field.) + capabilities = init_response.get("capabilities", {}) if isinstance(init_response, dict) else {} + text_document_sync = capabilities.get("textDocumentSync") + change_kind = text_document_sync.get("change") if isinstance(text_document_sync, dict) else text_document_sync + if change_kind != 2: + log.warning("rust-analyzer: unexpected textDocumentSync.change=%r (expected 2 = incremental)", change_kind) + if "completionProvider" not in capabilities: + log.warning("rust-analyzer: server did not advertise a completionProvider capability") + self.server.notify.initialized({}) + + # Wait for rust-analyzer to finish initial indexing (it emits experimental/serverStatus with + # quiescent=true when ready). Use a timeout so a server that crashes or never signals + # readiness cannot hang startup indefinitely (previously this waited with no timeout). + _SERVER_READY_TIMEOUT = 120.0 + log.info("Waiting for rust-analyzer to signal readiness (quiescent)...") + if self.server_ready.wait(timeout=_SERVER_READY_TIMEOUT): + log.info("rust-analyzer ready") + else: + log.warning("rust-analyzer did not signal readiness within %.0fs; proceeding anyway", _SERVER_READY_TIMEOUT) diff --git a/src/solidlsp/language_servers/scala_language_server.py b/src/solidlsp/language_servers/scala_language_server.py new file mode 100644 index 0000000..f360d1c --- /dev/null +++ b/src/solidlsp/language_servers/scala_language_server.py @@ -0,0 +1,317 @@ +""" +Provides Scala specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Scala. +""" + +import logging +import os +import shutil +import subprocess +from enum import Enum + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_utils import PlatformUtils +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +if not PlatformUtils.get_platform_id().value.startswith("win"): + pass + + +log = logging.getLogger(__name__) + +# Default configuration constants +DEFAULT_METALS_VERSION = "1.6.4" +DEFAULT_CLIENT_NAME = "Serena" +DEFAULT_ON_STALE_LOCK = "auto-clean" +DEFAULT_LOG_MULTI_INSTANCE_NOTICE = True + + +class StaleLockMode(Enum): + """Mode for handling stale Metals H2 database locks.""" + + AUTO_CLEAN = "auto-clean" + """Automatically remove stale lock files (default, recommended).""" + + WARN = "warn" + """Log a warning but proceed; may result in degraded experience.""" + + FAIL = "fail" + """Raise an error and refuse to start.""" + + +def _get_scala_settings(solidlsp_settings: SolidLSPSettings) -> dict[str, object]: + """ + Extract Scala-specific settings with defaults applied. + + Returns a dictionary with keys: + - metals_version: str + - client_name: str + - on_stale_lock: StaleLockMode + - log_multi_instance_notice: bool + """ + from solidlsp.ls_config import Language + + defaults: dict[str, object] = { + "metals_version": DEFAULT_METALS_VERSION, + "client_name": DEFAULT_CLIENT_NAME, + "on_stale_lock": StaleLockMode.AUTO_CLEAN, + "log_multi_instance_notice": DEFAULT_LOG_MULTI_INSTANCE_NOTICE, + } + + if not solidlsp_settings.ls_specific_settings: + return defaults + + scala_settings = solidlsp_settings.get_ls_specific_settings(Language.SCALA) + + # Parse stale lock mode with validation + on_stale_lock_str = scala_settings.get("on_stale_lock", DEFAULT_ON_STALE_LOCK) + try: + on_stale_lock = StaleLockMode(on_stale_lock_str) + except ValueError: + log.warning(f"Invalid on_stale_lock value '{on_stale_lock_str}', using '{DEFAULT_ON_STALE_LOCK}'") + on_stale_lock = StaleLockMode.AUTO_CLEAN + + return { + "metals_version": scala_settings.get("metals_version", DEFAULT_METALS_VERSION), + "client_name": scala_settings.get("client_name", DEFAULT_CLIENT_NAME), + "on_stale_lock": on_stale_lock, + "log_multi_instance_notice": scala_settings.get("log_multi_instance_notice", DEFAULT_LOG_MULTI_INSTANCE_NOTICE), + } + + +class ScalaLanguageServer(SolidLanguageServer): + """ + Provides Scala specific instantiation of the LanguageServer class. + Contains various configurations and settings specific to Scala. + + Configurable options in ls_specific_settings (in serena_config.yml): + + ls_specific_settings: + scala: + # Stale lock handling: auto-clean | warn | fail + on_stale_lock: 'auto-clean' + # Log notice when another Metals instance is detected + log_multi_instance_notice: true + # Metals version to bootstrap (default: DEFAULT_METALS_VERSION) + metals_version: '1.6.4' + # Client identifier sent to Metals (default: DEFAULT_CLIENT_NAME) + client_name: 'Serena' + + Multi-instance support: + Metals uses H2 AUTO_SERVER mode (enabled by default) to support multiple + concurrent instances sharing the same database. Running Serena's Metals + alongside VS Code's Metals is designed to work. The only issue is stale + locks from crashed processes, which this class can detect and clean up. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a ScalaLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + # Check for stale locks before setting up dependencies (fail-fast) + self._check_metals_db_status(repository_root_path, solidlsp_settings) + + scala_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=scala_lsp_executable_path, cwd=repository_root_path), + config.code_language.value, + solidlsp_settings, + ) + + def _check_metals_db_status(self, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None: + """ + Check the Metals H2 database status and handle stale locks. + + This method is called before setting up runtime dependencies to fail-fast + if there's a stale lock that the user has configured to fail on. + """ + from pathlib import Path + + from solidlsp.ls_exceptions import MetalsStaleLockError + from solidlsp.util.metals_db_utils import ( + MetalsDbStatus, + check_metals_db_status, + cleanup_stale_lock, + ) + + project_path = Path(repository_root_path) + status, lock_info = check_metals_db_status(project_path) + + # Get settings using the shared helper function + settings = _get_scala_settings(solidlsp_settings) + on_stale_lock: StaleLockMode = settings["on_stale_lock"] # type: ignore[assignment] + log_multi_instance_notice: bool = settings["log_multi_instance_notice"] # type: ignore[assignment] + + if status == MetalsDbStatus.ACTIVE_INSTANCE: + if log_multi_instance_notice and lock_info: + log.info( + f"Another Metals instance detected (PID: {lock_info.pid}). " + "This is fine - Metals supports multiple instances via H2 AUTO_SERVER. " + "Both instances will share the database and Bloop build server." + ) + + elif status == MetalsDbStatus.STALE_LOCK: + lock_path = lock_info.lock_path if lock_info else project_path / ".metals" / "metals.mv.db.lock.db" + lock_path_str = str(lock_path) + + if on_stale_lock == StaleLockMode.AUTO_CLEAN: + log.info(f"Stale Metals lock detected, cleaning up: {lock_path_str}") + cleanup_success = cleanup_stale_lock(lock_path) + if not cleanup_success: + log.warning( + f"Failed to clean up stale lock at {lock_path_str}. " + "Metals may fall back to in-memory database (degraded experience)." + ) + + elif on_stale_lock == StaleLockMode.WARN: + log.warning( + f"Stale Metals lock detected at {lock_path_str}. " + "A previous Metals process may have crashed. " + "Metals will fall back to in-memory database (degraded experience). " + "Consider removing the lock file manually or setting on_stale_lock='auto-clean'." + ) + + elif on_stale_lock == StaleLockMode.FAIL: + raise MetalsStaleLockError(lock_path_str) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [ + ".bloop", + ".metals", + "target", + ] + + @classmethod + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> list[str]: + """ + Setup runtime dependencies for Scala Language Server and return the command to start the server. + """ + assert shutil.which("java") is not None, "JDK is not installed or not in PATH." + + # Check if metals is available globally in PATH + global_metals = shutil.which("metals") + if global_metals: + log.info(f"Found metals in PATH: {global_metals}") + return [global_metals] + + # Get settings using the shared helper function + settings = _get_scala_settings(solidlsp_settings) + metals_version: str = settings["metals_version"] # type: ignore[assignment] + client_name: str = settings["client_name"] # type: ignore[assignment] + + metals_home = os.path.join(cls.ls_resources_dir(solidlsp_settings), "metals-lsp") + os.makedirs(metals_home, exist_ok=True) + metals_executable = os.path.join(metals_home, metals_version, "metals") + + if not os.path.exists(metals_executable): + coursier_command_path = shutil.which("coursier") + cs_command_path = shutil.which("cs") + assert cs_command_path is not None or coursier_command_path is not None, "coursier is not installed or not in PATH." + + if not cs_command_path: + assert coursier_command_path is not None + log.info("'cs' command not found. Trying to install it using 'coursier'.") + try: + log.info("Running 'coursier setup --yes' to install 'cs'...") + subprocess.run([coursier_command_path, "setup", "--yes"], check=True, capture_output=True, text=True) + except subprocess.CalledProcessError as e: + raise RuntimeError(f"Failed to set up 'cs' command with 'coursier setup'. Stderr: {e.stderr}") + + cs_command_path = shutil.which("cs") + if not cs_command_path: + raise RuntimeError( + "'cs' command not found after running 'coursier setup'. Please check your PATH or install it manually." + ) + log.info("'cs' command installed successfully.") + + log.info(f"metals executable not found at {metals_executable}, bootstrapping...") + subprocess.run(["mkdir", "-p", os.path.join(metals_home, metals_version)], check=True) + artifact = f"org.scalameta:metals_2.13:{metals_version}" + cmd = [ + cs_command_path, + "bootstrap", + "--java-opt", + "-XX:+UseG1GC", + "--java-opt", + "-XX:+UseStringDeduplication", + "--java-opt", + "-Xss4m", + "--java-opt", + "-Xms100m", + "--java-opt", + f"-Dmetals.client={client_name}", + artifact, + "-o", + metals_executable, + "-f", + ] + log.info("Bootstrapping metals...") + subprocess.run(cmd, cwd=metals_home, check=True) + log.info("Bootstrapping metals finished.") + return [metals_executable] + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Scala Language Server. + """ + initialize_params = { + "locale": "en", + "initializationOptions": { + "compilerOptions": { + "completionCommand": None, + "isCompletionItemDetailEnabled": True, + "isCompletionItemDocumentationEnabled": True, + "isCompletionItemResolve": True, + "isHoverDocumentationEnabled": True, + "isSignatureHelpDocumentationEnabled": True, + "overrideDefFormat": "ascli", + "snippetAutoIndent": False, + }, + "debuggingProvider": True, + "decorationProvider": False, + "didFocusProvider": False, + "doctorProvider": False, + "executeClientCommandProvider": False, + "globSyntax": "uri", + "icons": "unicode", + "inputBoxProvider": False, + "isVirtualDocumentSupported": False, + "isExitOnShutdown": True, + "isHttpEnabled": True, + "openFilesOnRenameProvider": False, + "quickPickProvider": False, + "renameFileThreshold": 200, + "statusBarProvider": "false", + "treeViewProvider": False, + "testExplorerProvider": False, + "openNewWindowProvider": False, + "copyWorksheetOutputProvider": False, + "doctorVisibilityProvider": False, + }, + "capabilities": {"textDocument": {"documentSymbol": {"hierarchicalDocumentSymbolSupport": True}}}, + } + return initialize_params + + def _start_server(self) -> None: + """ + Starts the Scala Language Server + """ + log.info("Starting Scala server process") + self.server.start() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + + initialize_params = self._create_initialize_params() + self.server.send.initialize(initialize_params) + self.server.notify.initialized({}) + + @override + def _get_wait_time_for_cross_file_referencing(self) -> float: + return 5 diff --git a/src/solidlsp/language_servers/solargraph.py b/src/solidlsp/language_servers/solargraph.py new file mode 100644 index 0000000..b0ae3b0 --- /dev/null +++ b/src/solidlsp/language_servers/solargraph.py @@ -0,0 +1,353 @@ +""" +Provides Ruby specific instantiation of the LanguageServer class using Solargraph. +Contains various configurations and settings specific to Ruby. +""" + +import json +import logging +import os +import re +import shutil +import subprocess +import threading + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class Solargraph(SolidLanguageServer): + """ + Provides Ruby specific instantiation of the LanguageServer class using Solargraph. + Contains various configurations and settings specific to Ruby. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a Solargraph instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + solargraph_executable_path = self._setup_runtime_dependencies(config, repository_root_path) + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=f"{solargraph_executable_path} stdio", cwd=repository_root_path), + "ruby", + solidlsp_settings, + ) + # Override internal language enum for file matching (excludes .erb files) + # while keeping LSP languageId as "ruby" for protocol compliance + from solidlsp.ls_config import Language + + self.language = Language.RUBY_SOLARGRAPH + self.analysis_complete = threading.Event() + self.service_ready_event = threading.Event() + self.initialize_searcher_command_available = threading.Event() + self.resolve_main_method_available = threading.Event() + + # Set timeout for Solargraph requests - Bundler environments may need more time + self.set_request_timeout(120.0) # 120 seconds for initialization and requests + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + ruby_ignored_dirs = [ + "vendor", # Ruby vendor directory + ".bundle", # Bundler cache + "tmp", # Temporary files + "log", # Log files + "coverage", # Test coverage reports + ".yardoc", # YARD documentation cache + "doc", # Generated documentation + "node_modules", # Node modules (for Rails with JS) + "storage", # Active Storage files (Rails) + ".ruby-lsp", + ] + return super().is_ignored_dirname(dirname) or dirname in ruby_ignored_dirs + + @staticmethod + def _setup_runtime_dependencies(config: LanguageServerConfig, repository_root_path: str) -> str: + """ + Setup runtime dependencies for Solargraph and return the command to start the server. + """ + # Check if Ruby is installed + try: + result = subprocess.run(["ruby", "--version"], check=True, capture_output=True, cwd=repository_root_path, text=True) + ruby_version = result.stdout.strip() + log.info(f"Ruby version: {ruby_version}") + + # Extract version number for compatibility checks + version_match = re.search(r"ruby (\d+)\.(\d+)\.(\d+)", ruby_version) + if version_match: + major, minor, patch = map(int, version_match.groups()) + if major < 2 or (major == 2 and minor < 6): + log.warning(f"Warning: Ruby {major}.{minor}.{patch} detected. Solargraph works best with Ruby 2.6+") + + except subprocess.CalledProcessError as e: + error_msg = e.stderr.decode() if e.stderr else "Unknown error" + raise RuntimeError( + f"Error checking Ruby installation: {error_msg}. Please ensure Ruby is properly installed and in PATH." + ) from e + except FileNotFoundError as e: + raise RuntimeError( + "Ruby is not installed or not found in PATH. Please install Ruby using one of these methods:\n" + " - Using rbenv: rbenv install 3.0.0 && rbenv global 3.0.0\n" + " - Using RVM: rvm install 3.0.0 && rvm use 3.0.0 --default\n" + " - Using asdf: asdf install ruby 3.0.0 && asdf global ruby 3.0.0\n" + " - System package manager (brew install ruby, apt install ruby, etc.)" + ) from e + + # Helper function for Windows-compatible executable search + def find_executable_with_extensions(executable_name: str) -> str | None: + """Find executable with Windows-specific extensions if on Windows.""" + import platform + + if platform.system() == "Windows": + for ext in [".bat", ".cmd", ".exe"]: + path = shutil.which(f"{executable_name}{ext}") + if path: + return path + return shutil.which(executable_name) + else: + return shutil.which(executable_name) + + # Check for Bundler project (Gemfile exists) + gemfile_path = os.path.join(repository_root_path, "Gemfile") + gemfile_lock_path = os.path.join(repository_root_path, "Gemfile.lock") + is_bundler_project = os.path.exists(gemfile_path) + + if is_bundler_project: + log.info("Detected Bundler project (Gemfile found)") + + # Check if bundle command is available + bundle_path = find_executable_with_extensions("bundle") + if not bundle_path: + # Try common bundle executables + for bundle_cmd in ["bin/bundle", "bundle"]: + if bundle_cmd.startswith("bin/"): + bundle_full_path = os.path.join(repository_root_path, bundle_cmd) + else: + bundle_full_path = find_executable_with_extensions(bundle_cmd) + if bundle_full_path and os.path.exists(bundle_full_path): + bundle_path = bundle_full_path if bundle_cmd.startswith("bin/") else bundle_cmd + break + + if not bundle_path: + raise RuntimeError( + "Bundler project detected but 'bundle' command not found. Please install Bundler:\n" + " - gem install bundler\n" + " - Or use your Ruby version manager's bundler installation\n" + " - Ensure the bundle command is in your PATH" + ) + + # Check if solargraph is in Gemfile.lock + solargraph_in_bundle = False + if os.path.exists(gemfile_lock_path): + try: + with open(gemfile_lock_path) as f: + content = f.read() + solargraph_in_bundle = "solargraph" in content.lower() + except Exception as e: + log.warning(f"Warning: Could not read Gemfile.lock: {e}") + + if solargraph_in_bundle: + log.info("Found solargraph in Gemfile.lock") + return f"{bundle_path} exec solargraph" + else: + log.warning( + "solargraph not found in Gemfile.lock. Please add 'gem \"solargraph\"' to your Gemfile and run 'bundle install'", + ) + # Fall through to global installation check + + # Check if solargraph is installed globally + # First, try to find solargraph in PATH (includes asdf shims) with Windows support + solargraph_path = find_executable_with_extensions("solargraph") + if solargraph_path: + log.info(f"Found solargraph at: {solargraph_path}") + return solargraph_path + + # Fallback to gem exec (for non-Bundler projects or when global solargraph not found) + if not is_bundler_project: + runtime_dependencies = [ + { + "url": "https://rubygems.org/downloads/solargraph-0.51.1.gem", + "installCommand": "gem install solargraph -v 0.51.1", + "binaryName": "solargraph", + "archiveType": "gem", + } + ] + + dependency = runtime_dependencies[0] + try: + result = subprocess.run( + ["gem", "list", "^solargraph$", "-i"], check=False, capture_output=True, text=True, cwd=repository_root_path + ) + if result.stdout.strip() == "false": + log.info("Installing Solargraph...") + subprocess.run(dependency["installCommand"].split(), check=True, capture_output=True, cwd=repository_root_path) + + return "gem exec solargraph" + except subprocess.CalledProcessError as e: + error_msg = e.stderr.decode() if e.stderr else str(e) + raise RuntimeError( + f"Failed to check or install Solargraph: {error_msg}\nPlease try installing manually: gem install solargraph" + ) from e + else: + raise RuntimeError( + "This appears to be a Bundler project, but solargraph is not available. " + "Please add 'gem \"solargraph\"' to your Gemfile and run 'bundle install'." + ) + + @staticmethod + def _detect_rails_project(repository_root_path: str) -> bool: + """ + Detect if this is a Rails project by checking for Rails-specific files. + """ + rails_indicators = [ + "config/application.rb", + "config/environment.rb", + "app/controllers/application_controller.rb", + "Rakefile", + ] + + for indicator in rails_indicators: + if os.path.exists(os.path.join(repository_root_path, indicator)): + return True + + # Check for Rails in Gemfile + gemfile_path = os.path.join(repository_root_path, "Gemfile") + if os.path.exists(gemfile_path): + try: + with open(gemfile_path) as f: + content = f.read().lower() + if "gem 'rails'" in content or 'gem "rails"' in content: + return True + except Exception: + pass + + return False + + @staticmethod + def _get_ruby_exclude_patterns(repository_root_path: str) -> list[str]: + """ + Get Ruby and Rails-specific exclude patterns for better performance. + """ + base_patterns = [ + "**/vendor/**", # Ruby vendor directory (similar to node_modules) + "**/.bundle/**", # Bundler cache + "**/tmp/**", # Temporary files + "**/log/**", # Log files + "**/coverage/**", # Test coverage reports + "**/.yardoc/**", # YARD documentation cache + "**/doc/**", # Generated documentation + "**/.git/**", # Git directory + "**/node_modules/**", # Node modules (for Rails with JS) + "**/public/assets/**", # Rails compiled assets + ] + + # Add Rails-specific patterns if this is a Rails project + if Solargraph._detect_rails_project(repository_root_path): + rails_patterns = [ + "**/public/packs/**", # Webpacker output + "**/public/webpack/**", # Webpack output + "**/storage/**", # Active Storage files + "**/tmp/cache/**", # Rails cache + "**/tmp/pids/**", # Process IDs + "**/tmp/sessions/**", # Session files + "**/tmp/sockets/**", # Socket files + "**/db/*.sqlite3", # SQLite databases + ] + base_patterns.extend(rails_patterns) + + return base_patterns + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Solargraph Language Server. + """ + exclude_patterns = Solargraph._get_ruby_exclude_patterns(self.repository_root_path) + + initialize_params = { + "initializationOptions": { + "exclude": exclude_patterns, + }, + "capabilities": { + "workspace": { + "workspaceEdit": {"documentChanges": True}, + }, + "textDocument": { + "documentSymbol": { + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + }, + }, + "trace": "verbose", + } + return initialize_params + + def _start_server(self) -> None: + """ + Starts the Solargraph Language Server for Ruby + """ + + def register_capability_handler(params: dict) -> None: + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "workspace/executeCommand": + self.initialize_searcher_command_available.set() + self.resolve_main_method_available.set() + return + + def lang_status_handler(params: dict) -> None: + log.info(f"LSP: language/status: {params}") + if params.get("type") == "ServiceReady" and params.get("message") == "Service is ready.": + log.info("Solargraph service is ready.") + self.analysis_complete.set() + + def execute_client_command_handler(params: dict) -> list: + return [] + + def do_nothing(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("language/status", lang_status_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + + log.info("Starting solargraph server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + log.info(f"Sending init params: {json.dumps(initialize_params, indent=4)}") + init_response = self.server.send.initialize(initialize_params) + log.info(f"Received init response: {init_response}") + assert init_response["capabilities"]["textDocumentSync"] == 2 + assert "completionProvider" in init_response["capabilities"] + assert init_response["capabilities"]["completionProvider"] == { + "resolveProvider": True, + "triggerCharacters": [".", ":", "@"], + } + self.server.notify.initialized({}) + + # Wait for Solargraph to complete its initial workspace analysis + # This prevents issues by ensuring background tasks finish + log.info("Waiting for Solargraph to complete initial workspace analysis...") + if self.analysis_complete.wait(timeout=60.0): + log.info("Solargraph initial analysis complete, server ready") + else: + log.warning("Timeout waiting for Solargraph analysis completion, proceeding anyway") + # Fallback: assume analysis is complete after timeout + self.analysis_complete.set() diff --git a/src/solidlsp/language_servers/solidity_language_server.py b/src/solidlsp/language_servers/solidity_language_server.py new file mode 100644 index 0000000..434c536 --- /dev/null +++ b/src/solidlsp/language_servers/solidity_language_server.py @@ -0,0 +1,425 @@ +""" +Provides Solidity-specific instantiation of the LanguageServer class using +the Nomic Foundation Solidity Language Server (@nomicfoundation/solidity-language-server). +""" + +import glob +import logging +import os +import pathlib +import platform +import shutil +import threading +from time import monotonic, sleep +from typing import Any + +from overrides import override + +from solidlsp import ls_types +from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, LSPConstants, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_SOLIDITY_LANGUAGE_SERVER_VERSION = "0.8.4" +DEFAULT_SOLIDITY_LANGUAGE_SERVER_VERSION = "0.8.4" +INITIAL_FORGE_VERSION = "1.5.1" +DEFAULT_FORGE_VERSION = "1.5.1" + + +class SolidityLanguageServer(SolidLanguageServer): + """ + Provides Solidity-specific instantiation of the LanguageServer class using + the Nomic Foundation Solidity Language Server (@nomicfoundation/solidity-language-server). + Supports go-to-definition, find references, document symbols, hover, and diagnostics. + Requires Node.js and npm to be installed. + """ + + _VALIDATION_COMPLETION_TIMEOUT = 60.0 + """upper bound for awaiting the validation-completion signal (``custom/validation-job-status``), + aligned with the indexing wait; on cold environments validation includes downloading the pinned + solc version""" + + _POST_VALIDATION_PUBLISH_WAIT = 2.5 + """grace period for the diagnostics publication that accompanies the completion signal""" + + @staticmethod + def _determine_log_level(line: str) -> int: + """Suppress known non-critical stderr output from the Solidity language server.""" + line_lower = line.lower() + if any( + [ + "telemetry" in line_lower, + "could not find" in line_lower and "hardhat" in line_lower, + "no workspaceroot" in line_lower, + ] + ): + return logging.DEBUG + return SolidLanguageServer._determine_log_level(line) + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a SolidityLanguageServer instance. Not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + super().__init__( + config, + repository_root_path, + None, + "solidity", + solidlsp_settings, + ) + self._validation_completed = threading.Event() + """set when the language server reports an asynchronous validation run as finished + (``custom/validation-job-status``); cleared at the start of each diagnostics request""" + self._last_validation_status: Any | None = None + """payload of the most recent ``custom/validation-job-status`` notification, kept for logging""" + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """ + Install @nomicfoundation/solidity-language-server via npm and return the + path to the solidity-language-server executable. + """ + is_node_installed = shutil.which("node") is not None + assert is_node_installed, "node is not installed or isn't in PATH. Please install Node.js and try again." + is_npm_installed = shutil.which("npm") is not None + assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." + solidity_language_server_version = self._custom_settings.get( + "solidity_language_server_version", DEFAULT_SOLIDITY_LANGUAGE_SERVER_VERSION + ) + forge_version = self._custom_settings.get("forge_version", DEFAULT_FORGE_VERSION) + npm_registry = self._custom_settings.get("npm_registry") + + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="solidity-language-server", + description="Nomic Foundation Solidity Language Server", + command=build_npm_install_command( + "@nomicfoundation/solidity-language-server", + solidity_language_server_version, + npm_registry, + ), + platform_id="any", + ), + RuntimeDependency( + id="forge", + description="Foundry forge CLI", + # the @foundry-rs/forge meta-package's postinstall uses POSIX env-var + # syntax that fails on Windows cmd.exe, so we install the platform-specific + # subpackage directly to bypass it + command=build_npm_install_command(self._get_forge_npm_package(), forge_version, npm_registry), + platform_id="any", + ), + ] + ) + + solidity_ls_dir = self._resolve_solidity_ls_dir(solidity_language_server_version, forge_version) + managed_bin_dir = os.path.join(solidity_ls_dir, "node_modules", ".bin") + solidity_executable_path = os.path.join(managed_bin_dir, "nomicfoundation-solidity-language-server") + forge_executable_path = os.path.join(managed_bin_dir, "forge") + + if os.name == "nt": + solidity_executable_path += ".cmd" + forge_executable_path += ".cmd" + + if not os.path.exists(solidity_executable_path) or not os.path.exists(forge_executable_path): + log.info("Solidity Language Server dependencies missing. Installing...") + deps.install(solidity_ls_dir) + log.info("Solidity language server dependencies installed successfully.") + + if not os.path.exists(solidity_executable_path): + raise FileNotFoundError( + f"nomicfoundation-solidity-language-server executable not found at {solidity_executable_path}. " + "Something went wrong with the installation." + ) + + return solidity_executable_path + + @staticmethod + def _get_forge_npm_package() -> str: + """ + Returns the platform-specific @foundry-rs forge npm subpackage name. Installing + the meta-package fails on Windows due to a POSIX-only postinstall script. + """ + machine = platform.machine().lower() + if machine in ("x86_64", "amd64"): + arch = "amd64" + elif machine in ("arm64", "aarch64"): + arch = "arm64" + else: + raise RuntimeError(f"Unsupported architecture for foundry forge: {machine}") + + if os.name == "nt": + if arch != "amd64": + raise RuntimeError(f"foundry forge does not publish a Windows {arch} npm package") + return "@foundry-rs/forge-win32-amd64" + if platform.system() == "Darwin": + return f"@foundry-rs/forge-darwin-{arch}" + return f"@foundry-rs/forge-linux-{arch}" + + def create_launch_command_env(self) -> dict[str, str]: + solidity_language_server_version = self._custom_settings.get( + "solidity_language_server_version", DEFAULT_SOLIDITY_LANGUAGE_SERVER_VERSION + ) + forge_version = self._custom_settings.get("forge_version", DEFAULT_FORGE_VERSION) + solidity_ls_dir = self._resolve_solidity_ls_dir(solidity_language_server_version, forge_version) + managed_bin_dir = os.path.join(solidity_ls_dir, "node_modules", ".bin") + return {"PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", "")} + + def _resolve_solidity_ls_dir(self, solidity_language_server_version: str, forge_version: str) -> str: + # legacy unversioned dir reserved for INITIAL pair; any other combination goes into a versioned subdir + is_initial = ( + solidity_language_server_version == INITIAL_SOLIDITY_LANGUAGE_SERVER_VERSION and forge_version == INITIAL_FORGE_VERSION + ) + ls_dirname = "solidity-lsp" if is_initial else f"solidity-lsp-{solidity_language_server_version}-{forge_version}" + return os.path.join(self._ls_resources_dir, ls_dirname) + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "--stdio"] + + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in {"artifacts", "cache", "typechain-types"} + + def _create_base_initialize_params(self) -> dict: + """Return LSP InitializeParams for the Solidity language server.""" + return { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": { + "dynamicRegistration": True, + "didSave": True, + }, + "completion": { + "dynamicRegistration": True, + "completionItem": {"snippetSupport": True}, + }, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + "publishDiagnostics": {"relatedInformation": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + "initializationOptions": {}, + } + + def _get_wait_time_for_cross_file_referencing(self) -> float: + # Small buffer for any post-indexing analysis the LSP performs after file-indexed events. + return 3.0 + + def _start_server(self) -> None: + """Start the Solidity language server and wait for project indexing to finish.""" + + def do_nothing(params: Any) -> None: + return + + def register_capability_handler(params: Any) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + # Count .sol files in the project to know when indexing is complete. + sol_files = glob.glob(os.path.join(self.repository_root_path, "**", "*.sol"), recursive=True) + expected_count = len(sol_files) + indexed_count = [0] + all_indexed = threading.Event() + + def on_file_indexed(params: Any) -> None: + indexed_count[0] += 1 + uri = (params or {}).get("uri", "") + log.debug(f"Solidity LSP: file indexed ({indexed_count[0]}/{expected_count}): {uri}") + if indexed_count[0] >= expected_count: + all_indexed.set() + + def on_validation_job_status(params: Any) -> None: + # signals completion of an asynchronous validation run (a forge/solc compile); + # the payload carries no document URI, so the event is global to the server instance + self._last_validation_status = params + log.debug(f"Solidity LSP: validation job finished: {params}") + self._validation_completed.set() + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("custom/file-indexed", on_file_indexed) + self.server.on_notification("custom/validation-job-status", on_validation_job_status) + + log.info("Starting Solidity language server process") + self.server.start() + + initialize_params = self._create_initialize_params() + log.debug("Sending initialize request to Solidity language server") + init_response = self.server.send.initialize(initialize_params) + log.debug(f"Received initialize response from Solidity server: {init_response}") + + if "documentSymbolProvider" in init_response.get("capabilities", {}): + log.debug("Solidity server supports document symbols") + else: + log.warning("Solidity server does not report document symbol support") + + self.server.notify.initialized({}) + + if expected_count > 0: + log.info(f"Waiting for Solidity LSP to index {expected_count} .sol file(s)…") + completed = all_indexed.wait(timeout=60) + if completed: + log.info(f"Solidity LSP indexing complete ({indexed_count[0]}/{expected_count} files indexed)") + else: + log.warning( + f"Solidity LSP indexing timed out ({indexed_count[0]}/{expected_count} files indexed). " + "Waiting additional 30s for slow environments (e.g., CI)." + ) + sleep(30) + log.info(f"Additional wait complete ({indexed_count[0]}/{expected_count} files indexed)") + else: + log.info("No .sol files found; skipping indexing wait") + + log.info("Solidity language server initialization complete") + + @override + def request_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + uri = self._get_validation_document_uri(relative_file_path) + diagnostics_before_request = self._get_published_diagnostics_generation(uri) + + absolute_path = pathlib.Path(self.repository_root_path, relative_file_path) + contents = absolute_path.read_text(encoding=self._encoding) + lines = contents.split("\n") + end_position = { + "line": len(lines) - 1, + "character": len(lines[-1]), + } + + # any validation that finishes after this point may satisfy the fallback wait below + self._validation_completed.clear() + + self.server.notify.did_open_text_document( + { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict + LSPConstants.TEXT_DOCUMENT: { + LSPConstants.URI: uri, + LSPConstants.LANGUAGE_ID: self.language_id, + LSPConstants.VERSION: 0, + LSPConstants.TEXT: contents, + } + } + ) + try: + self.server.notify.did_change_text_document( + { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict + LSPConstants.TEXT_DOCUMENT: { + LSPConstants.URI: uri, + LSPConstants.VERSION: 1, + }, + LSPConstants.CONTENT_CHANGES: [ + { + LSPConstants.RANGE: { + "start": {"line": 0, "character": 0}, + "end": end_position, + }, + "text": contents, + } + ], + } + ) + # fast path: wait for a non-empty diagnostics publication (warm environments) + diagnostics = self._wait_for_relevant_published_diagnostics( + uri=uri, + after_generation=diagnostics_before_request, + timeout=self._get_published_diagnostics_wait_timeout(True), + allow_cached=True, + ) + + # fallback: validation (a forge/solc compile) runs asynchronously in the server and can + # exceed the initial wait on slow or cold environments (e.g. CI runners downloading the + # pinned solc); await its completion signal, then take the publication made for this + # request, accepting an empty result as the validated answer. The signal carries no + # document URI, so a completion belonging to another document can wake the wait early; + # re-arm and keep waiting until the deadline in that case. + if diagnostics is None: + # fast path missed; validation is still running -- await its completion signal so the + # wait below is visible in logs on slow/cold environments + log.debug( + f"Solidity diagnostics not yet published for {uri}; " + f"awaiting validation completion (up to {self._VALIDATION_COMPLETION_TIMEOUT:.0f}s)" + ) + deadline = monotonic() + self._VALIDATION_COMPLETION_TIMEOUT + while diagnostics is None: + remaining = deadline - monotonic() + if remaining <= 0 or not self._validation_completed.wait(timeout=remaining): + log.warning( + f"Solidity validation completion was not signalled within " + f"{self._VALIDATION_COMPLETION_TIMEOUT:.0f}s for {uri} " + f"(last status: {self._last_validation_status}); returning without diagnostics" + ) + break + diagnostics = self._wait_for_published_diagnostics( + uri=uri, + after_generation=diagnostics_before_request, + timeout=self._POST_VALIDATION_PUBLISH_WAIT, + ) + if diagnostics is None: + # a completion fired but published nothing for our URI (e.g. a run for another + # document); re-arm and keep waiting so the ongoing wait stays visible + log.debug(f"Solidity validation completed without diagnostics for {uri} yet; continuing to wait") + self._validation_completed.clear() + else: + log.debug( + f"Solidity diagnostics resolved via validation completion " + f"(status={self._last_validation_status}): {len(diagnostics)} diagnostic(s)" + ) + finally: + self.server.notify.did_close_text_document( + { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict + LSPConstants.TEXT_DOCUMENT: { + LSPConstants.URI: uri, + } + } + ) + + if diagnostics is None: + return [] + + return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) + + def _get_validation_document_uri(self, relative_file_path: str) -> str: + absolute_path = pathlib.Path(self.repository_root_path, relative_file_path) + + if os.name != "nt": + return absolute_path.as_uri() + + path = absolute_path.as_posix() + if len(path) >= 2 and path[1] == ":": + path = f"{path[0].lower()}%3A{path[2:]}" + + return f"file:///{path.lstrip('/')}" diff --git a/src/solidlsp/language_servers/some_sass_language_server.py b/src/solidlsp/language_servers/some_sass_language_server.py new file mode 100644 index 0000000..abce659 --- /dev/null +++ b/src/solidlsp/language_servers/some_sass_language_server.py @@ -0,0 +1,226 @@ +""" +Provides SCSS / Sass / CSS instantiation of the LanguageServer class using the +``some-sass-language-server`` npm package (https://github.com/wkillerud/some-sass). + +Some Sass is the dedicated, actively maintained SCSS LSP. It also accepts plain +``.css`` files via the same ``vscode-css-languageservice`` engine that powers +Microsoft's standalone CSS LS — so Serena routes ``.scss`` / ``.sass`` / ``.css`` +through this single server. + +Compared to the generic ``vscode-css-language-server`` server, Some Sass also +provides full ``@use`` / ``@forward`` workspace navigation (cross-file +go-to-definition and find-references for mixins, functions, variables, +placeholders), SassDoc, and the indented Sass syntax. + +Caveats: + * Cross-file Sass navigation requires the workspace to be configured (the LS + scans the project root after initialization). + * For ``.css`` files, every ``somesass.css.*.enabled`` toggle defaults to + ``false`` upstream; we flip them on at initialization. See + ``SOMESASS_CSS_FEATURES`` below for the full set. + * Language is registered as experimental. +""" + +from __future__ import annotations + +import logging +import os +import shutil +import threading + +from overrides import override + +from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +DEFAULT_PACKAGE_VERSION = "2.3.8" +LS_BIN_NAME = "some-sass-language-server" + +# Every ``somesass.css.*.enabled`` toggle (per the upstream package.json +# ``contributes.configuration``) defaults to false in some-sass — meaning a request +# for ``.css`` files would be hard-gated off at the top of every handler. Flipping +# the full set on at initialization is what makes plain CSS usable through Some Sass. +# We deliberately leave ``diagnostics.lint.enabled`` off because the lint rules are +# opinionated (vendor prefixes, empty rules, etc.) and would be noisy on user code. +SOMESASS_CSS_FEATURES: dict[str, dict[str, object]] = { + "codeAction": {"enabled": True}, + "colors": {"enabled": True}, + "completion": {"enabled": True}, + "definition": {"enabled": True}, + "diagnostics": {"enabled": True, "lint": {"enabled": False}}, + "documentSymbols": {"enabled": True}, + "foldingRanges": {"enabled": True}, + "highlights": {"enabled": True}, + "hover": {"enabled": True}, + "links": {"enabled": True}, + "references": {"enabled": True}, + "rename": {"enabled": True}, + "selectionRanges": {"enabled": True}, + "signatureHelp": {"enabled": True}, + "workspaceSymbol": {"enabled": True}, +} + +SOMESASS_INIT_OPTIONS: dict[str, object] = { + # See https://wkillerud.github.io/some-sass/user-guide/settings.html + "somesass": { + "css": SOMESASS_CSS_FEATURES, + "workspace": {"loadPaths": []}, + "suggest": {"suggestFromUseOnly": False}, + }, +} + + +class SomeSassLanguageServer(SolidLanguageServer): + """ + SCSS / Sass language server (Some Sass by wkillerud). + + ``ls_specific_settings["scss"]`` keys: + * ``some_sass_version``: version of ``some-sass-language-server`` to install + (default: ``2.3.8``). + * ``npm_registry``: optional alternative npm registry URL. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + super().__init__( + config, + repository_root_path, + None, + "scss", + solidlsp_settings, + ) + self.server_ready = threading.Event() + + @override + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + @override + def _get_language_id_for_file(self, relative_file_path: str) -> str: + # Some Sass switches behavior off the LSP languageId in its + # ``languageConfiguration()`` selector, picking the matching + # ``LanguageServerConfiguration.{css,sass,scss}`` slice. Sending the wrong id + # for plain CSS would route the file to the SCSS parser and skip the + # ``somesass.css.*`` feature gate entirely. + ext = os.path.splitext(relative_file_path)[1].lower() + if ext == ".sass": + return "sass" + if ext == ".css": + return "css" + return "scss" + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["node_modules", "dist", "build", "coverage"] + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + assert shutil.which("node") is not None, "node is not installed or isn't in PATH. Please install NodeJS and try again." + assert shutil.which("npm") is not None, "npm is not installed or isn't in PATH. Please install npm and try again." + + package_version = self._custom_settings.get("some_sass_version", DEFAULT_PACKAGE_VERSION) + npm_registry = self._custom_settings.get("npm_registry") + + ls_dirname = f"some-sass-{package_version}" + install_dir = os.path.join(self._ls_resources_dir, ls_dirname) + executable_path = os.path.join(install_dir, "node_modules", ".bin", LS_BIN_NAME) + if os.name == "nt": + executable_path += ".cmd" + + if not os.path.exists(executable_path): + expected_version = f"some-sass-language-server@{package_version}" + log.info("Installing %s...", expected_version) + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="some-sass-language-server", + description="Some Sass language server (SCSS / Sass / CSS)", + command=build_npm_install_command("some-sass-language-server", package_version, npm_registry), + platform_id="any", + ), + ] + ) + deps.install(install_dir) + + if not os.path.exists(executable_path): + raise FileNotFoundError( + f"{LS_BIN_NAME} executable not found at {executable_path}; " + f"npm install of some-sass-language-server@{package_version} did not produce the expected binary." + ) + return executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "--stdio"] + + def _create_base_initialize_params(self) -> dict: + initialize_params: dict = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "rename": {"dynamicRegistration": True, "prepareSupport": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + "initializationOptions": SOMESASS_INIT_OPTIONS, + } + return initialize_params + + @staticmethod + def _handle_workspace_configuration(params: dict) -> list[dict]: + # Some Sass calls workspace/configuration after init to fetch ``somesass`` and + # ``editor`` sections. The LSP contract is one entry per requested item, in order. + # We respond with our pinned somesass slice for the somesass section and an empty + # dict for everything else (currently only ``editor``). + items = params.get("items", []) if isinstance(params, dict) else [] + somesass_section = SOMESASS_INIT_OPTIONS["somesass"] + result: list[dict] = [] + for item in items: + section = (item or {}).get("section") if isinstance(item, dict) else None + if section == "somesass": + result.append(somesass_section) # type: ignore[arg-type] + else: + # ``editor`` is the only other section Some Sass currently asks for; an unknown + # section means upstream added a new config slice we should consider supplying. + if section not in (None, "editor"): + log.debug("workspace/configuration: unknown section %r; responding with empty dict", section) + result.append({}) + return result or [{}] + + def _start_server(self) -> None: + def do_nothing(_params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("$/progress", do_nothing) + self.server.on_request("client/registerCapability", lambda _params: None) + self.server.on_request("workspace/configuration", self._handle_workspace_configuration) + + log.info("Starting some-sass-language-server") + self.server.start() + init_params = self._create_initialize_params() + init_response = self.server.send.initialize(init_params) + log.debug("Some Sass LS initialize response: %s", init_response) + assert "completionProvider" in init_response["capabilities"], "Some Sass LSP did not advertise completionProvider" + self.server.notify.initialized({}) + self.server_ready.set() diff --git a/src/solidlsp/language_servers/sourcekit_lsp.py b/src/solidlsp/language_servers/sourcekit_lsp.py new file mode 100644 index 0000000..e77ba74 --- /dev/null +++ b/src/solidlsp/language_servers/sourcekit_lsp.py @@ -0,0 +1,371 @@ +import logging +import os +import subprocess +import time +from collections.abc import Hashable + +from overrides import override + +from solidlsp import ls_types +from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_types import SymbolKind +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class SourceKitLSP(SolidLanguageServer): + """ + Provides Swift specific instantiation of the LanguageServer class using sourcekit-lsp. + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # For Swift projects, we should ignore: + # - .build: Swift Package Manager build artifacts + # - .swiftpm: Swift Package Manager metadata + # - node_modules: if the project has JavaScript components + # - dist/build: common output directories + return super().is_ignored_dirname(dirname) or dirname in [".build", ".swiftpm", "node_modules", "dist", "build"] + + @staticmethod + def _get_sourcekit_lsp_version() -> str: + """Get the installed sourcekit-lsp version or raise error if sourcekit was not found.""" + try: + result = subprocess.run(["sourcekit-lsp", "-h"], capture_output=True, text=True, check=False) + if result.returncode == 0: + return result.stdout.strip() + else: + raise Exception(f"`sourcekit-lsp -h` resulted in: {result}") + except Exception as e: + raise RuntimeError( + "Could not find sourcekit-lsp, please install it as described in https://github.com/apple/sourcekit-lsp#installation" + "And make sure it is available on your PATH." + ) from e + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + sourcekit_version = self._get_sourcekit_lsp_version() + log.info(f"Starting sourcekit lsp with version: {sourcekit_version}") + + super().__init__( + config, repository_root_path, ProcessLaunchInfo(cmd="sourcekit-lsp", cwd=repository_root_path), "swift", solidlsp_settings + ) + self.request_id = 0 + self._did_sleep_before_requesting_references = False + self._initialization_timestamp: float | None = None + + @override + def _document_symbols_cache_fingerprint(self) -> Hashable: + normalize_symbol_name_version = 1 + return normalize_symbol_name_version + + @override + def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: + original_name = symbol["name"] + + if symbol.get("kind") not in (SymbolKind.Function, SymbolKind.Method, SymbolKind.Constructor): + return original_name + + if "(" not in original_name: + return original_name + + return original_name.split("(", 1)[0].strip() + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Swift Language Server. + """ + initialize_params = { + "capabilities": { + "general": { + "markdown": {"parser": "marked", "version": "1.1.0"}, + "positionEncodings": ["utf-16"], + "regularExpressions": {"engine": "ECMAScript", "version": "ES2020"}, + "staleRequestSupport": { + "cancel": True, + "retryOnContentModified": [ + "textDocument/semanticTokens/full", + "textDocument/semanticTokens/range", + "textDocument/semanticTokens/full/delta", + ], + }, + }, + "notebookDocument": {"synchronization": {"dynamicRegistration": True, "executionSummarySupport": True}}, + "textDocument": { + "callHierarchy": {"dynamicRegistration": True}, + "codeAction": { + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + ] + } + }, + "dataSupport": True, + "disabledSupport": True, + "dynamicRegistration": True, + "honorsChangeAnnotations": True, + "isPreferredSupport": True, + "resolveSupport": {"properties": ["edit"]}, + }, + "codeLens": {"dynamicRegistration": True}, + "colorProvider": {"dynamicRegistration": True}, + "completion": { + "completionItem": { + "commitCharactersSupport": True, + "deprecatedSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "insertReplaceSupport": True, + "insertTextModeSupport": {"valueSet": [1, 2]}, + "labelDetailsSupport": True, + "preselectSupport": True, + "resolveSupport": {"properties": ["documentation", "detail", "additionalTextEdits"]}, + "snippetSupport": True, + "tagSupport": {"valueSet": [1]}, + }, + "completionItemKind": { + "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25] + }, + "completionList": {"itemDefaults": ["commitCharacters", "editRange", "insertTextFormat", "insertTextMode", "data"]}, + "contextSupport": True, + "dynamicRegistration": True, + "insertTextMode": 2, + }, + "declaration": {"dynamicRegistration": True, "linkSupport": True}, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False}, + "documentHighlight": {"dynamicRegistration": True}, + "documentLink": {"dynamicRegistration": True, "tooltipSupport": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "labelSupport": True, + "symbolKind": { + "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26] + }, + "tagSupport": {"valueSet": [1]}, + }, + "foldingRange": { + "dynamicRegistration": True, + "foldingRange": {"collapsedText": False}, + "foldingRangeKind": {"valueSet": ["comment", "imports", "region"]}, + "lineFoldingOnly": True, + "rangeLimit": 5000, + }, + "formatting": {"dynamicRegistration": True}, + "hover": {"contentFormat": ["markdown", "plaintext"], "dynamicRegistration": True}, + "implementation": {"dynamicRegistration": True, "linkSupport": True}, + "inlayHint": { + "dynamicRegistration": True, + "resolveSupport": {"properties": ["tooltip", "textEdits", "label.tooltip", "label.location", "label.command"]}, + }, + "inlineValue": {"dynamicRegistration": True}, + "linkedEditingRange": {"dynamicRegistration": True}, + "onTypeFormatting": {"dynamicRegistration": True}, + "publishDiagnostics": { + "codeDescriptionSupport": True, + "dataSupport": True, + "relatedInformation": True, + "tagSupport": {"valueSet": [1, 2]}, + "versionSupport": False, + }, + "rangeFormatting": {"dynamicRegistration": True, "rangesSupport": True}, + "references": {"dynamicRegistration": True}, + "rename": { + "dynamicRegistration": True, + "honorsChangeAnnotations": True, + "prepareSupport": True, + "prepareSupportDefaultBehavior": 1, + }, + "selectionRange": {"dynamicRegistration": True}, + "semanticTokens": { + "augmentsSyntaxTokens": True, + "dynamicRegistration": True, + "formats": ["relative"], + "multilineTokenSupport": False, + "overlappingTokenSupport": False, + "requests": {"full": {"delta": True}, "range": True}, + "serverCancelSupport": True, + "tokenModifiers": [ + "declaration", + "definition", + "readonly", + "static", + "deprecated", + "abstract", + "async", + "modification", + "documentation", + "defaultLibrary", + ], + "tokenTypes": [ + "namespace", + "type", + "class", + "enum", + "interface", + "struct", + "typeParameter", + "parameter", + "variable", + "property", + "enumMember", + "event", + "function", + "method", + "macro", + "keyword", + "modifier", + "comment", + "string", + "number", + "regexp", + "operator", + "decorator", + ], + }, + "signatureHelp": { + "contextSupport": True, + "dynamicRegistration": True, + "signatureInformation": { + "activeParameterSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + }, + }, + "synchronization": {"didSave": True, "dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True}, + "typeDefinition": {"dynamicRegistration": True, "linkSupport": True}, + "typeHierarchy": {"dynamicRegistration": True}, + }, + "window": { + "showDocument": {"support": True}, + "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, + "workDoneProgress": True, + }, + "workspace": { + "applyEdit": True, + "codeLens": {"refreshSupport": True}, + "configuration": True, + "diagnostics": {"refreshSupport": True}, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, + "executeCommand": {"dynamicRegistration": True}, + "fileOperations": { + "didCreate": True, + "didDelete": True, + "didRename": True, + "dynamicRegistration": True, + "willCreate": True, + "willDelete": True, + "willRename": True, + }, + "foldingRange": {"refreshSupport": True}, + "inlayHint": {"refreshSupport": True}, + "inlineValue": {"refreshSupport": True}, + "semanticTokens": {"refreshSupport": False}, + "symbol": { + "dynamicRegistration": True, + "resolveSupport": {"properties": ["location.range"]}, + "symbolKind": { + "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26] + }, + "tagSupport": {"valueSet": [1]}, + }, + "workspaceEdit": { + "changeAnnotationSupport": {"groupsOnLabel": True}, + "documentChanges": True, + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": True, + "resourceOperations": ["create", "rename", "delete"], + }, + "workspaceFolders": True, + }, + }, + "initializationOptions": { + "backgroundIndexing": True, + "backgroundPreparationMode": "enabled", + "textDocument/codeLens": {"supportedCommands": {"swift.debug": "swift.debug", "swift.run": "swift.run"}}, + "window/didChangeActiveDocument": True, + "workspace/getReferenceDocument": True, + "workspace/peekDocuments": True, + }, + "locale": "en", + } + + return initialize_params + + def _start_server(self) -> None: + """Start sourcekit-lsp server process""" + + def register_capability_handler(_params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(_params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting sourcekit-lsp server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + capabilities = init_response["capabilities"] + log.info(f"SourceKit LSP capabilities: {list(capabilities.keys())}") + + assert "textDocumentSync" in capabilities, "textDocumentSync capability missing" + assert "definitionProvider" in capabilities, "definitionProvider capability missing" + + self.server.notify.initialized({}) + + # Mark initialization timestamp for smarter delay calculation + self._initialization_timestamp = time.time() + + @override + def request_references(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: + # SourceKit LSP needs initialization + indexing time after startup + # before it can provide accurate reference information. This sleep + # prevents race conditions where references might not be available yet. + # CI environments need extra time for project indexing and cross-file analysis + if not self._did_sleep_before_requesting_references: + # Calculate minimum delay based on how much time has passed since initialization + if self._initialization_timestamp: + elapsed = time.time() - self._initialization_timestamp + # Increased CI delay for project indexing: 15s CI, 5s local + base_delay = 15 if os.getenv("CI") else 5 + remaining_delay = max(2, base_delay - elapsed) + else: + # Fallback if initialization timestamp is missing + remaining_delay = 15 if os.getenv("CI") else 5 + + log.info(f"Sleeping {remaining_delay:.1f}s before requesting references for the first time (CI needs extra indexing time)") + time.sleep(remaining_delay) + self._did_sleep_before_requesting_references = True + + # Get references with retry logic for CI stability + references = super().request_references(relative_file_path, line, column) + + # In CI, if no references found, retry once after additional delay + if os.getenv("CI") and not references: + log.info("No references found in CI - retrying after additional 5s delay") + time.sleep(5) + references = super().request_references(relative_file_path, line, column) + + return references diff --git a/src/solidlsp/language_servers/svelte_language_server.py b/src/solidlsp/language_servers/svelte_language_server.py new file mode 100644 index 0000000..ad82617 --- /dev/null +++ b/src/solidlsp/language_servers/svelte_language_server.py @@ -0,0 +1,730 @@ +""" +Provides Svelte-specific instantiation of the LanguageServer class using +``svelte-language-server`` from Svelte Language Tools. +""" + +from __future__ import annotations + +import logging +import os +import pathlib +import shutil +from typing import Any, cast + +from overrides import override + +from solidlsp import ls_types +from solidlsp.language_servers.common import ( + RuntimeDependency, + RuntimeDependencyCollection, + build_npm_install_command, +) +from solidlsp.language_servers.typescript_language_server import TypeScriptLanguageServer +from solidlsp.ls import ( + DocumentSymbols, + LanguageServerDependencyProvider, + LanguageServerDependencyProviderSinglePath, + LSPFileBuffer, + SolidLanguageServer, +) +from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) +TS_EXT = frozenset({".ts", ".tsx", ".mts", ".cts"}) +JS_EXT = frozenset({".js", ".jsx", ".mjs", ".cjs"}) +SVELTE_EXT = frozenset({".svelte"}) +# cap the file listing in SvelteCompanionPreparationError so a large repo cannot bloat the message +_MAX_FAILED_FILES_IN_ERROR = 10 + + +class SvelteCompanionPreparationError(RuntimeError): + """Raised when the companion TypeScript server cannot be prepared deterministically.""" + + +def _is_ts_file(uri: str) -> bool: + return uri.lower().endswith(tuple(TS_EXT | JS_EXT)) + + +def _is_svelte_file(uri: str) -> bool: + return uri.lower().endswith(tuple(SVELTE_EXT)) + + +class SvelteTypeScriptServer(TypeScriptLanguageServer): + """Companion TypeScript language server for Svelte projects. + + Loads ``typescript-svelte-plugin`` so the TS graph becomes .svelte-aware: + cross-file rename, find-references, and go-to-definition from .ts/.js files + into .svelte consumers all work correctly through this companion. + + Spawned and owned by :class:`SvelteLanguageServer`; not instantiated directly. + """ + + INDEXING_PROGRESS_TIMEOUT = 120.0 + SERVER_READY_TIMEOUT = 30.0 + + class DependencyProvider(TypeScriptLanguageServer.DependencyProvider): + """Returns the pre-installed typescript-language-server binary. + + The binary is installed by ``SvelteLanguageServer.DependencyProvider``; + this provider just resolves the pre-known path without a separate install. + """ + + def __init__( + self, + custom_settings: SolidLSPSettings.CustomLSSettings, + ls_resources_dir: str, + explicit_executable_path: str, + ) -> None: + super().__init__(custom_settings, ls_resources_dir) + self._explicit_executable_path = explicit_executable_path + + @override + def _get_or_install_core_dependency(self) -> str: + return self._explicit_executable_path + + def __init__( + self, + config: LanguageServerConfig, + repository_root_path: str, + solidlsp_settings: SolidLSPSettings, + svelte_plugin_path: str, + tsdk_path: str, + ts_ls_executable_path: str, + ) -> None: + self._svelte_plugin_path = svelte_plugin_path + self._custom_tsdk_path = tsdk_path + # store as instance state, not class attr, to avoid races across parallel instantiations + self._explicit_ts_ls_executable = ts_ls_executable_path + super().__init__(config, repository_root_path, solidlsp_settings) + + @classmethod + @override + def get_language_enum_instance(cls) -> Language: + """Return TYPESCRIPT; companion uses the TypeScript LS infrastructure.""" + return Language.TYPESCRIPT + + @override + def get_source_fn_matcher(self) -> FilenameMatcher: + # include .svelte so references returned by the plugin are not filtered out + return Language.SVELTE.get_source_fn_matcher() + + @override + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider( + self._custom_settings, + self._ls_resources_dir, + self._explicit_ts_ls_executable, + ) + + @override + def _get_language_id_for_file(self, relative_file_path: str) -> str: + """.svelte files map to 'svelte' to activate the plugin; TS/JS as normal.""" + ext = os.path.splitext(relative_file_path)[1].lower() + if ext in SVELTE_EXT: + return "svelte" + if ext in JS_EXT: + return "javascript" + return "typescript" + + @override + def _create_base_initialize_params(self) -> dict: + params = super()._create_base_initialize_params() + params["initializationOptions"] = { + "plugins": [ + { + "name": "typescript-svelte-plugin", + "location": self._svelte_plugin_path, + "languages": ["svelte"], + } + ], + "tsserver": {"path": self._custom_tsdk_path}, + } + return params + + @override + def _start_server(self) -> None: + def workspace_configuration_handler(params: dict) -> list: + items = params.get("items", []) + return [{} for _ in items] + + self.server.on_request("workspace/configuration", workspace_configuration_handler) + super()._start_server() + + @override + def _handle_server_ready_timeout(self, timeout: float) -> None: + raise TimeoutError(f"Svelte companion TypeScript server did not become ready within {timeout:.0f}s") + + @override + def _handle_project_indexing_timeout(self, timeout: float) -> None: + raise TimeoutError( + f"Svelte companion TypeScript server project indexing did not complete within {timeout:.0f}s ({self.describe_indexing_state()})" + ) + + +class SvelteLanguageServer(SolidLanguageServer): + """ + Svelte language server using ``svelte-language-server``. + + ``ls_specific_settings["svelte"]`` keys: + * ``svelte_language_server_version``: version of ``svelte-language-server`` + to install (default: ``0.18.0``). + * ``npm_registry``: optional alternative npm-compatible registry URL. + * ``indexing_timeout``: optional timeout in seconds for companion TS indexing of + Svelte files. Falls back to ``ls_specific_settings["typescript"].indexing_timeout`` + or the Svelte companion default. + * ``initialization_options_configuration``: optional dict merged into + ``initializeParams.initializationOptions.configuration`` (same top-level keys as in + Svelte Language Tools: ``svelte``, ``prettier``, ``typescript``, …). + """ + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def __init__( + self, + custom_settings: SolidLSPSettings.CustomLSSettings, + ls_resources_dir: str, + ts_settings: SolidLSPSettings.CustomLSSettings, + ) -> None: + super().__init__(custom_settings, ls_resources_dir) + self._ts_settings = ts_settings + + def _get_or_install_core_dependency(self) -> str: + assert shutil.which("node") is not None, "node is not installed or isn't in PATH. Please install NodeJS and try again." + assert shutil.which("npm") is not None, "npm is not installed or isn't in PATH. Please install npm and try again." + + package_version = self._custom_settings.get("svelte_language_server_version", "0.18.0") + npm_registry = self._custom_settings.get("npm_registry", self._ts_settings.get("npm_registry")) + typescript_version = self._custom_settings.get("typescript_version", self._ts_settings.get("typescript_version", "6.0.3")) + typescript_language_server_version = self._custom_settings.get( + "typescript_language_server_version", + self._ts_settings.get("typescript_language_server_version", "5.1.3"), + ) + typescript_svelte_plugin_version = self._custom_settings.get("typescript_svelte_plugin_version", "0.3.52") + + # versioned install dir avoids silently reusing stale language-server binaries + install_dir = os.path.join(self._ls_resources_dir, f"svelte-lsp-{package_version}") + executable_path = os.path.join(install_dir, "node_modules", ".bin", "svelteserver") + if os.name == "nt": + executable_path += ".cmd" + + # version file encodes all four component versions; mismatch triggers reinstall + version_file = os.path.join(install_dir, ".installed_version") + expected_version = ( + f"{package_version}_{typescript_version}_{typescript_language_server_version}_{typescript_svelte_plugin_version}" + ) + needs_install = not os.path.exists(executable_path) + if not needs_install: + if os.path.exists(version_file): + with open(version_file) as fv: + if fv.read().strip() != expected_version: + needs_install = True + else: + # absent version file → old install that predates companion deps + needs_install = True + + if needs_install: + log.info( + "Installing svelte-language-server@%s + typescript@%s + typescript-language-server@%s + typescript-svelte-plugin@%s ...", + package_version, + typescript_version, + typescript_language_server_version, + typescript_svelte_plugin_version, + ) + runtime_deps = [ + RuntimeDependency( + id="svelte-language-server", + description="Svelte language server", + command=build_npm_install_command("svelte-language-server", package_version, npm_registry), + platform_id="any", + ), + RuntimeDependency( + id="typescript", + description="TypeScript language service", + command=build_npm_install_command("typescript", typescript_version, npm_registry), + platform_id="any", + ), + RuntimeDependency( + id="typescript-language-server", + description="TypeScript language server (companion)", + command=build_npm_install_command("typescript-language-server", typescript_language_server_version, npm_registry), + platform_id="any", + ), + RuntimeDependency( + id="typescript-svelte-plugin", + description="TypeScript plugin for Svelte cross-file awareness", + command=build_npm_install_command("typescript-svelte-plugin", typescript_svelte_plugin_version, npm_registry), + platform_id="any", + ), + ] + RuntimeDependencyCollection(runtime_deps).install(install_dir) + with open(version_file, "w") as fv: + fv.write(expected_version) + + if not os.path.exists(executable_path): + raise FileNotFoundError( + f"executable not found at {executable_path}; " + f"npm install of svelte-language-server@{package_version} did not produce the expected binary." + ) + return executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + # stdio suits SolidLSP's subprocess RPC; other hosts may use a different transport. + return [core_path, "--stdio"] + + @override + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + ts_settings = self._solidlsp_settings.get_ls_specific_settings(Language.TYPESCRIPT) + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir, ts_settings) + + def __init__(self, config: LanguageServerConfig, repo_path: str, solidlsp_settings: SolidLSPSettings): + resolved_root = os.path.abspath(repo_path) + super().__init__( + config, + resolved_root, + None, + "svelte", + solidlsp_settings, + ) + self.repo_path: str = resolved_root + self.tsdk_path = self._get_tsdk_path() + self._lsp_configuration: dict[str, Any] = {} + self._ts_server: SvelteTypeScriptServer | None = None + self._ts_server_started: bool = False + self._svelte_files_indexed: bool = False + self._indexed_svelte_file_uris: list[str] = [] + + def _get_tsdk_path(self) -> str: + """ + Compute the local typescript/lib path for the Svelte language server. + Asserts if not found, since DependencyProvider guarantees install. + """ + package_version = self._custom_settings.get("svelte_language_server_version", "0.18.0") + install_dir = os.path.join(self._ls_resources_dir, f"svelte-lsp-{package_version}") + tsdk_candidate = os.path.join(install_dir, "node_modules", "typescript", "lib") + assert os.path.isdir(tsdk_candidate), ( + f"TypeScript SDK not found at expected path: {tsdk_candidate}. Installation via DependencyProvider failed or version mismatch." + ) + return tsdk_candidate + + def _get_install_dir(self) -> str: + """:return: versioned install directory for svelte-language-server and companion deps.""" + version = self._custom_settings.get("svelte_language_server_version", "0.18.0") + return os.path.join(self._ls_resources_dir, f"svelte-lsp-{version}") + + def _get_ts_ls_executable(self) -> str: + """:return: path to the typescript-language-server binary installed alongside the svelte LS.""" + path = os.path.join(self._get_install_dir(), "node_modules", ".bin", "typescript-language-server") + if os.name == "nt": + path += ".cmd" + return path + + def _get_svelte_ts_plugin_path(self) -> str: + """:return: path to the ``typescript-svelte-plugin`` package directory.""" + return os.path.join(self._get_install_dir(), "node_modules", "typescript-svelte-plugin") + + def _find_all_svelte_files(self) -> list[str]: + """:return: relative paths of all .svelte files in the repo (excluding node_modules and dot-dirs).""" + svelte_files = [] + repo = pathlib.Path(self.repo_path) + for svelte_file in repo.rglob("*.svelte"): + try: + relative = str(svelte_file.relative_to(repo)) + if "node_modules" not in relative and not relative.startswith("."): + svelte_files.append(relative) + except Exception as exc: + log.debug("Error processing svelte file %s: %s", svelte_file, exc) + return svelte_files + + def _get_companion_indexing_timeout(self) -> float: + """:return: maximum seconds to wait for companion TS indexing after opening Svelte files.""" + ts_settings = self._solidlsp_settings.get_ls_specific_settings(Language.TYPESCRIPT) + timeout = self._custom_settings.get( + "indexing_timeout", + ts_settings.get("indexing_timeout", SvelteTypeScriptServer.INDEXING_PROGRESS_TIMEOUT), + ) + return float(timeout) + + def _ensure_svelte_files_indexed_on_ts_server(self) -> None: + """Open all .svelte files on the companion TS server so the plugin includes them in the TS program. + + The ``typescript-svelte-plugin``'s ``getExternalFiles`` is called by tsserver when a project + is set up, but only after the first file in that project is opened. Opening each .svelte file + with languageId ``"svelte"`` causes tsserver to invoke the plugin's ``getScriptSnapshot`` + for those files, adding them to the project graph so cross-file rename and references work. + """ + if self._svelte_files_indexed: + return + assert self._ts_server is not None + + log.info("Indexing .svelte files on companion TypeScript server for cross-file awareness") + svelte_files = self._find_all_svelte_files() + log.debug("Found %d .svelte files to index", len(svelte_files)) + + # prepare progress tracking BEFORE opening files to avoid a race + self._ts_server.expect_indexing() + + failed_svelte_files = [] + first_open_error: Exception | None = None + for svelte_file in svelte_files: + try: + with self._ts_server.open_file(svelte_file) as file_buffer: + file_buffer.ref_count += 1 + self._indexed_svelte_file_uris.append(file_buffer.uri) + except Exception as exc: + log.debug("Failed to open %s on companion TS server: %s", svelte_file, exc) + if first_open_error is None: + first_open_error = exc + failed_svelte_files.append(svelte_file) + + if failed_svelte_files: + shown_files = sorted(failed_svelte_files)[:_MAX_FAILED_FILES_IN_ERROR] + remainder = len(failed_svelte_files) - len(shown_files) + listing = ", ".join(shown_files) + (f" and {remainder} more" if remainder else "") + raise SvelteCompanionPreparationError( + f"Failed to open {len(failed_svelte_files)} Svelte file(s) on companion TypeScript server: {listing}" + ) from first_open_error + + self._svelte_files_indexed = True + log.info("Svelte file indexing complete; waiting for companion TS server to finish processing") + + timeout = self._get_companion_indexing_timeout() + if self._ts_server._wait_for_indexing_start_or_completion(timeout=timeout): + log.info("Companion TypeScript server finished indexing .svelte files") + else: + raise TimeoutError( + f"Companion TypeScript server did not finish indexing {len(svelte_files)} .svelte files within {timeout:.0f}s " + f"({self._ts_server.describe_indexing_state()})" + ) + + def _cleanup_indexed_svelte_files(self) -> None: + """Decrement ref-counts for all .svelte files opened during indexing.""" + if not self._indexed_svelte_file_uris or self._ts_server is None: + return + log.debug("Cleaning up %d indexed .svelte files", len(self._indexed_svelte_file_uris)) + for uri in self._indexed_svelte_file_uris: + try: + if uri in self._ts_server.open_file_buffers: + file_buffer = self._ts_server.open_file_buffers[uri] + file_buffer.ref_count -= 1 + if file_buffer.ref_count == 0: + self._ts_server.server.notify.did_close_text_document({"textDocument": {"uri": uri}}) + del self._ts_server.open_file_buffers[uri] + except Exception as exc: + log.debug("Error closing indexed svelte file %s: %s", uri, exc) + self._indexed_svelte_file_uris.clear() + + def _start_typescript_server(self) -> None: + """Spawn the companion :class:`SvelteTypeScriptServer`, wait for ready, then index .svelte files.""" + try: + ts_config = LanguageServerConfig( + code_language=Language.TYPESCRIPT, + trace_lsp_communication=False, + ) + log.info("Creating companion SvelteTypeScriptServer") + self._ts_server = SvelteTypeScriptServer( + config=ts_config, + repository_root_path=self.repo_path, + solidlsp_settings=self._solidlsp_settings, + svelte_plugin_path=self._get_svelte_ts_plugin_path(), + tsdk_path=self.tsdk_path, + ts_ls_executable_path=self._get_ts_ls_executable(), + ) + log.info("Starting companion SvelteTypeScriptServer") + self._ts_server.start() + self._ts_server_started = True + log.info("Companion SvelteTypeScriptServer ready") + self._ensure_svelte_files_indexed_on_ts_server() + except (TimeoutError, SvelteCompanionPreparationError): + log.exception("Failed to prepare companion SvelteTypeScriptServer; aborting Svelte server startup") + self._stop_typescript_server() + raise + except Exception: + log.exception("Error starting companion SvelteTypeScriptServer; TS-side operations degrade to svelte LS") + self._ts_server = None + self._ts_server_started = False + + def _stop_typescript_server(self) -> None: + """Shut down the companion TypeScript server if running.""" + if self._ts_server is not None: + self._cleanup_indexed_svelte_files() + try: + log.info("Stopping companion SvelteTypeScriptServer") + self._ts_server.stop() + except Exception as exc: + log.warning("Error stopping companion SvelteTypeScriptServer: %s", exc) + finally: + self._ts_server = None + self._ts_server_started = False + + def _wrap_notify_send_for_ts_js_mirror(self) -> None: + """Mirror TS/JS didChange via ``$/onDidChangeTsOrJsFile`` so the server updates TS snapshots. + + Unlike upstream ``svelte-vscode`` (svelte-only documentSelector), Serena must also open and + query TS/JS files directly through the same server instance. Standard sync notifications are + therefore kept; ``$/onDidChangeTsOrJsFile`` is sent additionally for didChange so the svelte + LS keeps its internal TS snapshot in sync with the open-buffer content. + """ + _orig_notify_send = self.server.notify.send_notification + + def send_notification_wrapped(method: str, params: dict | None = None) -> None: + _orig_notify_send(method, params) + if method != "textDocument/didChange" or not params: + return + text_document = params.get("textDocument") + if not text_document: + return + uri = text_document.get("uri") + if not uri: + return + fb = self.open_file_buffers.get(uri) + if fb is None or fb.language_id not in ("typescript", "javascript"): + return + changes = params.get("contentChanges") + if changes is None: + return + _orig_notify_send("$/onDidChangeTsOrJsFile", {"uri": uri, "changes": changes}) + + self.server.notify.send_notification = send_notification_wrapped + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Svelte Language Server. + + Builds the full ``initializationOptions.configuration`` section mirroring all + keys expected by ``svelte-language-server`` plugins (svelte, prettier, emmet, + typescript, javascript, js/ts, css, less, scss, html). Caller-supplied overrides + from ``initialization_options_configuration`` are deep-merged on top. + The resulting dict is also stored as :attr:`_lsp_configuration` so + ``workspace/configuration`` requests can be answered with real values. + """ + # base configuration mirroring all plugin-sections from svelte-vscode initializationOptions + lsp_config: dict[str, Any] = { + "svelte": {}, + "prettier": {}, + "emmet": {}, + "javascript": {"tsdk": self.tsdk_path}, + "typescript": {"tsdk": self.tsdk_path}, + "js/ts": {"tsdk": self.tsdk_path}, + "css": {}, + "less": {}, + "scss": {}, + "html": {}, + } + + # apply caller-supplied overrides (same top-level keys) + for key, val in self._custom_settings.get("initialization_options_configuration", {}).items(): + if key in lsp_config and isinstance(lsp_config[key], dict) and isinstance(val, dict): + lsp_config[key] = {**lsp_config[key], **val} + else: + lsp_config[key] = val + + self._lsp_configuration = lsp_config + + initialize_params: dict = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": {"dynamicRegistration": True}, + "codeAction": {"dynamicRegistration": True}, + "rename": {"dynamicRegistration": True, "prepareSupport": True}, + "implementation": {"dynamicRegistration": True}, + "typeDefinition": {"dynamicRegistration": True}, + "diagnostic": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, + }, + "workspace": { + "applyEdit": True, + "configuration": True, + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, + "symbol": {"dynamicRegistration": True}, + "diagnostics": {"refreshSupport": True}, + "fileOperations": {"didRename": True}, + }, + }, + "initializationOptions": { + "isTrusted": True, + "dontFilterIncompleteCompletions": True, + "configuration": lsp_config, + }, + } + return initialize_params + + def _start_server(self) -> None: + def window_log_message(msg: dict) -> None: + log.info("LSP: window/logMessage: %s", msg) + + def register_capability_handler(params: dict) -> None: + assert "registrations" in params + + def configuration_handler(params: dict) -> list: + items = params.get("items", []) + result = [] + for item in items: + section = item.get("section", "") if isinstance(item, dict) else "" + result.append(self._lsp_configuration.get(section, {})) + return result + + def workspace_apply_edit_handler(_params: dict) -> dict[str, Any]: + return {"applied": False} + + def work_done_progress_create(_params: dict) -> dict: + return {} + + def do_nothing(_params: dict) -> None: + pass + + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_request("window/workDoneProgress/create", work_done_progress_create) + self.server.on_request("workspace/applyEdit", workspace_apply_edit_handler) + self.server.on_request("workspace/configuration", configuration_handler) + self.server.on_request("workspace/diagnostic/refresh", do_nothing) + self.server.on_request("workspace/inlayHints/refresh", do_nothing) + self.server.on_request("workspace/semanticTokens/refresh", do_nothing) + self._wrap_notify_send_for_ts_js_mirror() + self.server.start() + + init_params = self._create_initialize_params() + init_response = self.server.send.initialize(init_params) + + assert "documentSymbolProvider" in init_response["capabilities"], "Svelte LSP did not advertise documentSymbolProvider" + assert "definitionProvider" in init_response["capabilities"], "Svelte LSP did not advertise definitionProvider" + + self.server.notify.initialized({}) + self._start_typescript_server() + + @staticmethod + def _deduplicate_reference_locations(a: list[ls_types.Location], b: list[ls_types.Location]) -> list[ls_types.Location]: + seen = set() + + for loc in a: + start = loc["range"]["start"] + seen.add((loc["uri"], start["line"], start["character"])) + + deduped_refs = list(a) + + for loc in b: + start = loc["range"]["start"] + key = (loc["uri"], start["line"], start["character"]) + + if key not in seen: + seen.add(key) + deduped_refs.append(loc) + + return deduped_refs + + @override + def stop(self, shutdown_timeout: float = 5.0) -> None: + self._stop_typescript_server() + super().stop(shutdown_timeout) + + @override + def request_references(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: + """Combine references from svelte LS and companion TS server. + + For .svelte files: svelte LS + ``$/getComponentReferences``. + For .ts/.js files: companion TS server (svelte-plugin-aware) merged with + svelte LS ``$/getFileReferences`` to maximise cross-file coverage. + Falls back to svelte-LS-only behaviour when companion is unavailable. + """ + symbol_refs = super().request_references(relative_file_path, line, column) + normalize_helper = self.ReferencesLocationRequest(self, relative_file_path, line, column) + + if _is_ts_file(relative_file_path): + # augment with svelte LS file-level references + raw = self.server.send_request("$/getFileReferences", cast(Any, self._resolve_file_uri(relative_file_path))) + file_refs = normalize_helper.normalize_response(raw if isinstance(raw, list) else []) + symbol_refs = self._deduplicate_reference_locations(symbol_refs, file_refs) + + # augment with companion TS server (typescript-svelte-plugin gives .svelte awareness) + if self._ts_server is not None: + with self._ts_server.open_file(relative_file_path): + ts_refs = self._ts_server.request_references(relative_file_path, line, column) + symbol_refs = self._deduplicate_reference_locations(symbol_refs, ts_refs) + + elif _is_svelte_file(relative_file_path): + raw = self.server.send_request("$/getComponentReferences", cast(Any, self._resolve_file_uri(relative_file_path))) + comp_refs = normalize_helper.normalize_response(raw if isinstance(raw, list) else []) + symbol_refs = self._deduplicate_reference_locations(symbol_refs, comp_refs) + + return symbol_refs + + @override + def request_rename_symbol_edit(self, relative_file_path: str, line: int, column: int, new_name: str) -> ls_types.WorkspaceEdit | None: + """Delegate TS/JS renames to the companion so the svelte plugin handles cross-file edits. + + Falls back to the svelte LS when the companion is unavailable or when the file is .svelte. + """ + if _is_ts_file(relative_file_path) and self._ts_server is not None: + with self._ts_server.open_file(relative_file_path): + return self._ts_server.request_rename_symbol_edit(relative_file_path, line, column, new_name) + return super().request_rename_symbol_edit(relative_file_path, line, column, new_name) + + @override + def request_definition(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: + """Delegate TS/JS go-to-definition to the companion for .svelte-aware resolution. + + Falls back to the svelte LS when the companion is unavailable or when the file is .svelte. + """ + if _is_ts_file(relative_file_path) and self._ts_server is not None: + with self._ts_server.open_file(relative_file_path): + return self._ts_server.request_definition(relative_file_path, line, column) + return super().request_definition(relative_file_path, line, column) + + @override + def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: + """Delegate document-symbol requests for .ts/.js files to the companion TS server. + + The base svelte LS only provides ``documentSymbol`` for .svelte files and returns nothing for + .ts/.js files. Without this override, symbols defined in plain .ts/.js modules would be + undiscoverable via ``find_symbol``/``get_symbols_overview`` and ``find_referencing_symbols`` + would fail outright, since it must first locate the target symbol via ``documentSymbol``. + Routing .ts/.js files to the companion (svelte-plugin-aware) typescript LS mirrors the + existing references/definition/rename delegations. + + The companion's own ``file_buffer`` is not reused here: the provided buffer (if any) is bound + to this svelte LS, so the companion opens and reads the file itself. + Falls back to the svelte LS when the companion is unavailable or when the file is .svelte. + """ + if _is_ts_file(relative_file_path) and self._ts_server is not None: + return self._ts_server.request_document_symbols(relative_file_path) + return super().request_document_symbols(relative_file_path, file_buffer) + + @override + def request_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + if _is_ts_file(relative_file_path) and self._ts_server is not None: + with self._ts_server.open_file(relative_file_path): + return self._ts_server.request_text_document_diagnostics(relative_file_path, start_line, end_line, min_severity) + return super().request_text_document_diagnostics(relative_file_path, start_line, end_line, min_severity) + + @override + def _get_language_id_for_file(self, relative_file_path: str) -> str: + ext = os.path.splitext(relative_file_path)[1].lower() + if ext in TS_EXT: + return "typescript" + if ext in JS_EXT: + return "javascript" + return self.language_id + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["dist", "build", "coverage"] diff --git a/src/solidlsp/language_servers/systemverilog_server.py b/src/solidlsp/language_servers/systemverilog_server.py new file mode 100644 index 0000000..dd7f7b0 --- /dev/null +++ b/src/solidlsp/language_servers/systemverilog_server.py @@ -0,0 +1,214 @@ +""" +SystemVerilog language server using verible-verilog-ls. +""" + +import logging +import os +import shutil +import subprocess +from typing import Any + +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +from .common import RuntimeDependency, RuntimeDependencyCollection + +log = logging.getLogger(__name__) + +VERIBLE_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") + + +class SystemVerilogLanguageServer(SolidLanguageServer): + """ + SystemVerilog language server using verible-verilog-ls. + Supports .sv, .svh, .v, .vh files. + + You can pass the following entries in ``ls_specific_settings["systemverilog"]``: + - verible_version: Override the pinned Verible release version downloaded + by Serena (default: the bundled Serena version). + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None: + super().__init__(config, repository_root_path, None, "systemverilog", solidlsp_settings) + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + # 1. Check PATH first for system-installed verible + system_verible = shutil.which("verible-verilog-ls") + if system_verible: + # Log version information + try: + result = subprocess.run( + [system_verible, "--version"], + capture_output=True, + text=True, + check=False, + timeout=5, + ) + if result.returncode == 0: + version_info = result.stdout.strip().split("\n")[0] + log.info(f"Using system-installed verible-verilog-ls: {version_info}") + else: + log.info(f"Using system-installed verible-verilog-ls at {system_verible}") + except Exception: + log.info(f"Using system-installed verible-verilog-ls at {system_verible}") + return system_verible + + # 2. Not found in PATH, try to download + verible_version = self._custom_settings.get("verible_version", "v0.0-4051-g9fdb4057") + base_url = f"https://github.com/chipsalliance/verible/releases/download/{verible_version}" + + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="verible-ls", + description="verible-verilog-ls for Linux (x64)", + url=f"{base_url}/verible-{verible_version}-linux-static-x86_64.tar.gz", + platform_id="linux-x64", + archive_type="gztar", + binary_name=f"verible-{verible_version}/bin/verible-verilog-ls", + sha256="f52e5920ef63f70620a6086e09dea8bd778147cd7a9ff827bb7de5d6316b1754", + allowed_hosts=VERIBLE_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="verible-ls", + description="verible-verilog-ls for Linux (arm64)", + url=f"{base_url}/verible-{verible_version}-linux-static-arm64.tar.gz", + platform_id="linux-arm64", + archive_type="gztar", + binary_name=f"verible-{verible_version}/bin/verible-verilog-ls", + sha256="30dd9c6f6e0f4840d6ba0c9e81ea2774a50b5a1a523a855245f9a9b4beb6b58b", + allowed_hosts=VERIBLE_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="verible-ls", + description="verible-verilog-ls for macOS", + url=f"{base_url}/verible-{verible_version}-macOS.tar.gz", + platform_id="osx-x64", + archive_type="gztar", + binary_name=f"verible-{verible_version}/bin/verible-verilog-ls", + sha256="9ef92e9ad345285dd593763e10ca61c8532fcf47bbb6cf4448f9a9423882d662", + allowed_hosts=VERIBLE_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="verible-ls", + description="verible-verilog-ls for macOS", + url=f"{base_url}/verible-{verible_version}-macOS.tar.gz", + platform_id="osx-arm64", + archive_type="gztar", + binary_name=f"verible-{verible_version}/bin/verible-verilog-ls", + sha256="9ef92e9ad345285dd593763e10ca61c8532fcf47bbb6cf4448f9a9423882d662", + allowed_hosts=VERIBLE_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="verible-ls", + description="verible-verilog-ls for Windows (x64)", + url=f"{base_url}/verible-{verible_version}-win64.zip", + platform_id="win-x64", + archive_type="zip", + binary_name=f"verible-{verible_version}/bin/verible-verilog-ls.exe", + sha256="729aa244036da4a4f87bc026d33555456fc7f7be79778d983ebe9c893f4a0ca3", + allowed_hosts=VERIBLE_ALLOWED_HOSTS, + ), + ] + ) + + try: + dep = deps.get_single_dep_for_current_platform() + except RuntimeError: + dep = None + + if dep is None: + raise FileNotFoundError( + "verible-verilog-ls is not installed on your system.\n" + + "Please install verible using one of the following methods:\n" + + " conda: conda install -c conda-forge verible\n" + + " Homebrew: brew install verible\n" + + " GitHub: Download from https://github.com/chipsalliance/verible/releases\n" + + "See https://github.com/chipsalliance/verible for more details." + ) + + verible_ls_dir = os.path.join(self._ls_resources_dir, "verible-ls") + executable_path = deps.binary_path(verible_ls_dir) + + if not os.path.exists(executable_path): + log.info(f"verible-verilog-ls not found. Downloading from {dep.url}") + _ = deps.install(verible_ls_dir) + + if not os.path.exists(executable_path): + raise FileNotFoundError(f"verible-verilog-ls not found at {executable_path}") + + os.chmod(executable_path, 0o755) + return executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path] + + def _create_base_initialize_params(self) -> dict: + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": { + "dynamicRegistration": True, + "completionItem": {"snippetSupport": True}, + }, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "codeAction": {"dynamicRegistration": True}, + "formatting": {"dynamicRegistration": True}, + "documentHighlight": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + def do_nothing(params: Any) -> None: + return + + def on_log_message(params: Any) -> None: + message = params.get("message", "") if isinstance(params, dict) else str(params) + log.info(f"verible-verilog-ls: {message}") + + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("window/logMessage", on_log_message) + + log.info("Starting verible-verilog-ls process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request") + init_response = self.server.send.initialize(initialize_params) + + # Validate server capabilities (follows Gopls/Bash pattern) + capabilities = init_response.get("capabilities", {}) + log.info(f"Initialize response capabilities: {list(capabilities.keys())}") + assert "textDocumentSync" in capabilities, "verible-verilog-ls must support textDocumentSync" + if "documentSymbolProvider" not in capabilities: + log.warning("verible-verilog-ls does not advertise documentSymbolProvider") + if "definitionProvider" not in capabilities: + log.warning("verible-verilog-ls does not advertise definitionProvider") + + self.server.notify.initialized({}) diff --git a/src/solidlsp/language_servers/taplo_server.py b/src/solidlsp/language_servers/taplo_server.py new file mode 100644 index 0000000..69511d7 --- /dev/null +++ b/src/solidlsp/language_servers/taplo_server.py @@ -0,0 +1,283 @@ +""" +Provides TOML specific instantiation of the LanguageServer class using Taplo. +Contains various configurations and settings specific to TOML files. + +You can pass the following entries in ``ls_specific_settings["toml"]``: + - taplo_version: Override the pinned Taplo version downloaded by Serena + (default: the bundled Serena version). +""" + +import logging +import os +import platform +import shutil +import stat +from typing import Any + +from overrides import override + +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_utils import FileUtils +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +TAPLO_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +# To update: download each release file and run: sha256sum , copy values into DEFAULT_*. +INITIAL_TAPLO_VERSION = "0.10.0" +INITIAL_TAPLO_SHA256_CHECKSUMS: dict[str, str] = { + "taplo-windows-x86_64.zip": "1615eed140039bd58e7089109883b1c434de5d6de8f64a993e6e8c80ca57bdf9", + "taplo-windows-x86.zip": "b825701daab10dcfc0251e6d668cd1a9c0e351e7f6762dd20844c3f3f3553aa0", + "taplo-darwin-x86_64.gz": "898122cde3a0b1cd1cbc2d52d3624f23338218c91b5ddb71518236a4c2c10ef2", + "taplo-darwin-aarch64.gz": "713734314c3e71894b9e77513c5349835eefbd52908445a0d73b0c7dc469347d", + "taplo-linux-x86_64.gz": "8fe196b894ccf9072f98d4e1013a180306e17d244830b03986ee5e8eabeb6156", + "taplo-linux-aarch64.gz": "033681d01eec8376c3fd38fa3703c79316f5e14bb013d859943b60a07bccdcc3", + "taplo-linux-armv7.gz": "6b728896afe2573522f38b8e668b1ff40eb5928fd9d6d0c253ecae508274d417", +} +DEFAULT_TAPLO_VERSION = "0.10.0" +DEFAULT_TAPLO_SHA256_CHECKSUMS: dict[str, str] = { + "taplo-windows-x86_64.zip": "1615eed140039bd58e7089109883b1c434de5d6de8f64a993e6e8c80ca57bdf9", + "taplo-windows-x86.zip": "b825701daab10dcfc0251e6d668cd1a9c0e351e7f6762dd20844c3f3f3553aa0", + "taplo-darwin-x86_64.gz": "898122cde3a0b1cd1cbc2d52d3624f23338218c91b5ddb71518236a4c2c10ef2", + "taplo-darwin-aarch64.gz": "713734314c3e71894b9e77513c5349835eefbd52908445a0d73b0c7dc469347d", + "taplo-linux-x86_64.gz": "8fe196b894ccf9072f98d4e1013a180306e17d244830b03986ee5e8eabeb6156", + "taplo-linux-aarch64.gz": "033681d01eec8376c3fd38fa3703c79316f5e14bb013d859943b60a07bccdcc3", + "taplo-linux-armv7.gz": "6b728896afe2573522f38b8e668b1ff40eb5928fd9d6d0c253ecae508274d417", +} + + +def _taplo_sha(version: str, archive_filename: str) -> str | None: + if version == INITIAL_TAPLO_VERSION: + return INITIAL_TAPLO_SHA256_CHECKSUMS.get(archive_filename) + if version == DEFAULT_TAPLO_VERSION: + return DEFAULT_TAPLO_SHA256_CHECKSUMS.get(archive_filename) + return None + + +def _get_taplo_download_url(version: str = DEFAULT_TAPLO_VERSION) -> tuple[str, str]: + """ + Get the appropriate Taplo download URL for the current platform. + + Returns: + Tuple of (download_url, executable_name) + + """ + system = platform.system().lower() + machine = platform.machine().lower() + + # Map machine architecture to Taplo naming convention + arch_map = { + "x86_64": "x86_64", + "amd64": "x86_64", + "x86": "x86", + "i386": "x86", + "i686": "x86", + "aarch64": "aarch64", + "arm64": "aarch64", + "armv7l": "armv7", + } + + arch = arch_map.get(machine, "x86_64") # Default to x86_64 + + if system == "windows": + filename = f"taplo-windows-{arch}.zip" + executable = "taplo.exe" + elif system == "darwin": + filename = f"taplo-darwin-{arch}.gz" + executable = "taplo" + else: # Linux and others + filename = f"taplo-linux-{arch}.gz" + executable = "taplo" + + return f"https://github.com/tamasfe/taplo/releases/download/{version}/{filename}", executable + + +class TaploServer(SolidLanguageServer): + """ + Provides TOML specific instantiation of the LanguageServer class using Taplo. + Taplo is a TOML toolkit with LSP support for validation, formatting, and schema support. + """ + + @staticmethod + def _determine_log_level(line: str) -> int: + """Classify Taplo stderr output to avoid false-positive errors.""" + line_lower = line.lower() + + # Known informational messages from Taplo + if any( + [ + "schema" in line_lower and "not found" in line_lower, + "warning" in line_lower, + ] + ): + return logging.DEBUG + + return SolidLanguageServer._determine_log_level(line) + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a TaploServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + super().__init__( + config, + repository_root_path, + None, + "toml", + solidlsp_settings, + ) + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """ + Setup runtime dependencies for Taplo and return the command to start the server. + """ + # First check if taplo is already installed system-wide + system_taplo = shutil.which("taplo") + if system_taplo: + log.info(f"Using system-installed Taplo at: {system_taplo}") + return system_taplo + + # Setup local installation directory; legacy unversioned dir reserved for INITIAL only + taplo_version = self._custom_settings.get("taplo_version", DEFAULT_TAPLO_VERSION) + ls_dirname = "taplo" if taplo_version == INITIAL_TAPLO_VERSION else f"taplo-{taplo_version}" + taplo_dir = os.path.join(self._ls_resources_dir, ls_dirname) + os.makedirs(taplo_dir, exist_ok=True) + _, executable_name = _get_taplo_download_url(taplo_version) + taplo_executable = os.path.join(taplo_dir, executable_name) + + if os.path.exists(taplo_executable) and os.access(taplo_executable, os.X_OK): + log.info(f"Using cached Taplo at: {taplo_executable}") + return taplo_executable + + # Download and install Taplo + log.info(f"Taplo not found. Downloading version {taplo_version}...") + self._download_taplo(taplo_dir, taplo_executable, taplo_version) + + if not os.path.exists(taplo_executable): + raise FileNotFoundError( + f"Taplo executable not found at {taplo_executable}. " + "Installation may have failed. Try installing manually: cargo install taplo-cli --locked" + ) + + return taplo_executable + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "lsp", "stdio"] + + @classmethod + def _download_taplo(cls, install_dir: str, executable_path: str, version: str = DEFAULT_TAPLO_VERSION) -> None: + """Download and extract Taplo binary using the shared verified download helper.""" + download_url, _ = _get_taplo_download_url(version) + archive_filename = os.path.basename(download_url) + # only verify the SHA when the resolved version is one of our pinned ones (INITIAL or current DEFAULT) + expected_hash = _taplo_sha(version, archive_filename) + if expected_hash is None and version in (INITIAL_TAPLO_VERSION, DEFAULT_TAPLO_VERSION): + raise RuntimeError(f"No SHA256 checksum configured for Taplo archive: {archive_filename}") + + try: + log.info(f"Downloading Taplo from: {download_url}") + archive_type = "zip" if archive_filename.endswith(".zip") else "gz" + target_path = install_dir if archive_type == "zip" else executable_path + FileUtils.download_and_extract_archive_verified( + download_url, + target_path, + archive_type, + expected_sha256=expected_hash, + allowed_hosts=TAPLO_ALLOWED_HOSTS, + ) + + # Make executable on Unix systems + if os.name != "nt": + os.chmod(executable_path, os.stat(executable_path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + log.info(f"Taplo installed successfully at: {executable_path}") + + except Exception as e: + log.error(f"Failed to download Taplo: {e}") + raise RuntimeError( + f"Failed to download Taplo from {download_url}. Try installing manually: cargo install taplo-cli --locked" + ) from e + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Taplo Language Server. + """ + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "codeAction": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """ + Starts the Taplo Language Server and initializes it. + """ + + def register_capability_handler(params: Any) -> None: + return + + def do_nothing(params: Any) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting Taplo server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request to Taplo server") + init_response = self.server.send.initialize(initialize_params) + log.debug(f"Received initialize response from Taplo: {init_response}") + + # Verify document symbol support + capabilities = init_response.get("capabilities", {}) + if capabilities.get("documentSymbolProvider"): + log.info("Taplo server supports document symbols") + else: + log.warning("Taplo server may have limited document symbol support") + + self.server.notify.initialized({}) + + log.info("Taplo server initialization complete") + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + if dirname == ".vscode": + # special case, one might want to analyze toml files in .vscode + return False + dirnames_to_ignore = ["target", ".cargo", "node_modules"] + return dirname in dirnames_to_ignore or super().is_ignored_dirname(dirname) diff --git a/src/solidlsp/language_servers/terraform_ls.py b/src/solidlsp/language_servers/terraform_ls.py new file mode 100644 index 0000000..a298fee --- /dev/null +++ b/src/solidlsp/language_servers/terraform_ls.py @@ -0,0 +1,273 @@ +import logging +import os +import shutil + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_utils import PlatformUtils +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +from .common import RuntimeDependency, RuntimeDependencyCollection + +log = logging.getLogger(__name__) + +TERRAFORM_LS_ALLOWED_HOSTS = ("releases.hashicorp.com",) + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_TERRAFORM_LS_VERSION = "0.36.5" +INITIAL_TERRAFORM_LS_SHA256_BY_PLATFORM = { + "osx-arm64": "fee8743aa71fe2d8b0b9b91283b844cfa57d58457306a62e53a8f38d143cec8c", + "osx-x64": "17c5c480f8eec7e528292565f1c05d5097a41edf7ef8ee2a9f3a18d288a1415a", + "linux-arm64": "724f45029f32d02d88b1952c7d1526c59fc8cd5dae49e31b9fed676a83f6cae7", + "linux-x64": "37e645cc54fd03e863157e2a3e773e7a5ff1d6cb3d045e4c20860cac1f550a44", + "win-x64": "a9223462cac9e1c0e6ba33043fbf9fb4483609b6970b5681a6306b04366698ec", +} +DEFAULT_TERRAFORM_LS_VERSION = "0.36.5" +DEFAULT_TERRAFORM_LS_SHA256_BY_PLATFORM = { + "osx-arm64": "fee8743aa71fe2d8b0b9b91283b844cfa57d58457306a62e53a8f38d143cec8c", + "osx-x64": "17c5c480f8eec7e528292565f1c05d5097a41edf7ef8ee2a9f3a18d288a1415a", + "linux-arm64": "724f45029f32d02d88b1952c7d1526c59fc8cd5dae49e31b9fed676a83f6cae7", + "linux-x64": "37e645cc54fd03e863157e2a3e773e7a5ff1d6cb3d045e4c20860cac1f550a44", + "win-x64": "a9223462cac9e1c0e6ba33043fbf9fb4483609b6970b5681a6306b04366698ec", +} + + +def _terraform_ls_sha(version: str, platform_key: str) -> str | None: + if version == INITIAL_TERRAFORM_LS_VERSION: + return INITIAL_TERRAFORM_LS_SHA256_BY_PLATFORM[platform_key] + if version == DEFAULT_TERRAFORM_LS_VERSION: + return DEFAULT_TERRAFORM_LS_SHA256_BY_PLATFORM[platform_key] + return None + + +class TerraformLS(SolidLanguageServer): + """ + Provides Terraform specific instantiation of the LanguageServer class using terraform-ls. + + You can pass the following entries in ``ls_specific_settings["terraform"]``: + - terraform_ls_version: Override the pinned terraform-ls version downloaded + by Serena (default: the bundled Serena version). + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [".terraform", "terraform.tfstate.d"] + + @staticmethod + def _determine_log_level(line: str) -> int: + """Classify terraform-ls stderr output to avoid false-positive errors.""" + line_lower = line.lower() + + # File discovery messages that are not actual errors + if any( + [ + "discover.go:" in line_lower, + "walker.go:" in line_lower, + "walking of {file://" in line_lower, + "bus: -> discover" in line_lower, + ] + ): + return logging.DEBUG + + # Known informational messages from terraform-ls that contain "error" but aren't errors + # Note: pattern match is flexible to handle file paths between keywords + if any( + [ + "loading module metadata returned error:" in line_lower and "state not changed" in line_lower, + "incoming notification for" in line_lower, + ] + ): + return logging.DEBUG + + return SolidLanguageServer._determine_log_level(line) + + @staticmethod + def _ensure_tf_command_available() -> None: + log.debug("Starting terraform version detection...") + + # 1. Try to find terraform using shutil.which + terraform_cmd = shutil.which("terraform") + if terraform_cmd is not None: + log.debug(f"Found terraform via shutil.which: {terraform_cmd}") + return + + # TODO: is this needed? + # 2. Fallback to TERRAFORM_CLI_PATH (set by hashicorp/setup-terraform action) + if not terraform_cmd: + terraform_cli_path = os.environ.get("TERRAFORM_CLI_PATH") + if terraform_cli_path: + log.debug(f"Trying TERRAFORM_CLI_PATH: {terraform_cli_path}") + # TODO: use binary name from runtime dependencies if we keep this code + if os.name == "nt": + terraform_binary = os.path.join(terraform_cli_path, "terraform.exe") + else: + terraform_binary = os.path.join(terraform_cli_path, "terraform") + if os.path.exists(terraform_binary): + terraform_cmd = terraform_binary + log.debug(f"Found terraform via TERRAFORM_CLI_PATH: {terraform_cmd}") + return + + raise RuntimeError( + "Terraform executable not found, please ensure Terraform is installed." + "See https://developer.hashicorp.com/terraform/tutorials/aws-get-started/install-cli for instructions." + ) + + @classmethod + def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str: + """ + Setup runtime dependencies for terraform-ls. + Downloads and installs terraform-ls if not already present. + """ + cls._ensure_tf_command_available() + terraform_settings = solidlsp_settings.get_ls_specific_settings(Language.TERRAFORM) + terraform_ls_version = terraform_settings.get("terraform_ls_version", DEFAULT_TERRAFORM_LS_VERSION) + platform_id = PlatformUtils.get_platform_id() + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="TerraformLS", + description="terraform-ls for macOS (ARM64)", + url=f"https://releases.hashicorp.com/terraform-ls/{terraform_ls_version}/terraform-ls_{terraform_ls_version}_darwin_arm64.zip", + platform_id="osx-arm64", + archive_type="zip", + binary_name="terraform-ls", + sha256=_terraform_ls_sha(terraform_ls_version, "osx-arm64"), + allowed_hosts=TERRAFORM_LS_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="TerraformLS", + description="terraform-ls for macOS (x64)", + url=f"https://releases.hashicorp.com/terraform-ls/{terraform_ls_version}/terraform-ls_{terraform_ls_version}_darwin_amd64.zip", + platform_id="osx-x64", + archive_type="zip", + binary_name="terraform-ls", + sha256=_terraform_ls_sha(terraform_ls_version, "osx-x64"), + allowed_hosts=TERRAFORM_LS_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="TerraformLS", + description="terraform-ls for Linux (ARM64)", + url=f"https://releases.hashicorp.com/terraform-ls/{terraform_ls_version}/terraform-ls_{terraform_ls_version}_linux_arm64.zip", + platform_id="linux-arm64", + archive_type="zip", + binary_name="terraform-ls", + sha256=_terraform_ls_sha(terraform_ls_version, "linux-arm64"), + allowed_hosts=TERRAFORM_LS_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="TerraformLS", + description="terraform-ls for Linux (x64)", + url=f"https://releases.hashicorp.com/terraform-ls/{terraform_ls_version}/terraform-ls_{terraform_ls_version}_linux_amd64.zip", + platform_id="linux-x64", + archive_type="zip", + binary_name="terraform-ls", + sha256=_terraform_ls_sha(terraform_ls_version, "linux-x64"), + allowed_hosts=TERRAFORM_LS_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="TerraformLS", + description="terraform-ls for Windows (x64)", + url=f"https://releases.hashicorp.com/terraform-ls/{terraform_ls_version}/terraform-ls_{terraform_ls_version}_windows_amd64.zip", + platform_id="win-x64", + archive_type="zip", + binary_name="terraform-ls.exe", + sha256=_terraform_ls_sha(terraform_ls_version, "win-x64"), + allowed_hosts=TERRAFORM_LS_ALLOWED_HOSTS, + ), + ] + ) + dependency = deps.get_single_dep_for_current_platform() + + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + install_dir = ( + cls.ls_resources_dir(solidlsp_settings) + if terraform_ls_version == INITIAL_TERRAFORM_LS_VERSION + else os.path.join(cls.ls_resources_dir(solidlsp_settings), f"terraform-ls-{terraform_ls_version}") + ) + terraform_ls_executable_path = deps.binary_path(install_dir) + if not os.path.exists(terraform_ls_executable_path): + log.info(f"Downloading terraform-ls from {dependency.url}") + deps.install(install_dir) + + assert os.path.exists(terraform_ls_executable_path), f"terraform-ls executable not found at {terraform_ls_executable_path}" + + # Make the executable file executable on Unix-like systems + if platform_id.value != "win-x64": + os.chmod(terraform_ls_executable_path, 0o755) + + return terraform_ls_executable_path + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a TerraformLS instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + terraform_ls_executable_path = self._setup_runtime_dependencies(solidlsp_settings) + + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=f"{terraform_ls_executable_path} serve", cwd=repository_root_path), + "terraform", + solidlsp_settings, + ) + self.request_id = 0 + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Terraform Language Server. + """ + result = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + }, + "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, + }, + } + return result + + def _start_server(self) -> None: + """Start terraform-ls server process""" + + def register_capability_handler(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting terraform-ls server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "completionProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + + # terraform-ls server is typically ready immediately after initialization diff --git a/src/solidlsp/language_servers/texlab_language_server.py b/src/solidlsp/language_servers/texlab_language_server.py new file mode 100644 index 0000000..91f0e09 --- /dev/null +++ b/src/solidlsp/language_servers/texlab_language_server.py @@ -0,0 +1,186 @@ +""" +Provides LaTeX specific instantiation of the LanguageServer class using texlab. +texlab is downloaded as a prebuilt binary from the latex-lsp/texlab GitHub releases. +""" + +import logging +import os + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_utils import PlatformUtils +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +from .common import RuntimeDependency, RuntimeDependencyCollection + +log = logging.getLogger(__name__) + +TEXLAB_VERSION = "5.25.1" +TEXLAB_BASE_URL = f"https://github.com/latex-lsp/texlab/releases/download/v{TEXLAB_VERSION}" +TEXLAB_ALLOWED_HOSTS = ( + "github.com", + "release-assets.githubusercontent.com", + "objects.githubusercontent.com", +) +TEXLAB_SHA256_BY_PLATFORM = { + "osx-arm64": "3755e9d1d4ad0b25135bdacd2fb453a612e88f48133185f96d660fa550398f66", + "osx-x64": "11289a231f0cf382857a6a4a2eda1ba9f4f4e950af343b455797e3922d13b1ea", + "linux-arm64": "e0d8e0b27b2e6e3526fa5019323bb3fddb1202a0f0049e527672b5ff323cc15e", + "linux-x64": "c8260b2fd2849cbad7d1f54c4ffa0389f34664b049392107bc4f7f9c8ec542ba", + "win-x64": "aa5fc1fe6004c17cd83086a57a8c8f28bb3f360914872711bfbb83490dc3c19e", +} + + +class TexlabLanguageServer(SolidLanguageServer): + """ + Provides LaTeX specific instantiation of the LanguageServer class using texlab. + + Symbol navigation maps onto LaTeX structure: document symbols are the sectioning + hierarchy, definitions/references cover labels and citations. + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["_minted", "_build", "build", "out", "auto"] + + @classmethod + def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str: + """Download and install texlab for the current platform if not already present.""" + platform_id = PlatformUtils.get_platform_id() + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="Texlab", + description="texlab for macOS (ARM64)", + url=f"{TEXLAB_BASE_URL}/texlab-aarch64-macos.tar.gz", + platform_id="osx-arm64", + archive_type="gztar", + binary_name="texlab", + sha256=TEXLAB_SHA256_BY_PLATFORM["osx-arm64"], + allowed_hosts=TEXLAB_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="Texlab", + description="texlab for macOS (x64)", + url=f"{TEXLAB_BASE_URL}/texlab-x86_64-macos.tar.gz", + platform_id="osx-x64", + archive_type="gztar", + binary_name="texlab", + sha256=TEXLAB_SHA256_BY_PLATFORM["osx-x64"], + allowed_hosts=TEXLAB_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="Texlab", + description="texlab for Linux (ARM64)", + url=f"{TEXLAB_BASE_URL}/texlab-aarch64-linux.tar.gz", + platform_id="linux-arm64", + archive_type="gztar", + binary_name="texlab", + sha256=TEXLAB_SHA256_BY_PLATFORM["linux-arm64"], + allowed_hosts=TEXLAB_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="Texlab", + description="texlab for Linux (x64)", + url=f"{TEXLAB_BASE_URL}/texlab-x86_64-linux.tar.gz", + platform_id="linux-x64", + archive_type="gztar", + binary_name="texlab", + sha256=TEXLAB_SHA256_BY_PLATFORM["linux-x64"], + allowed_hosts=TEXLAB_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="Texlab", + description="texlab for Windows (x64)", + url=f"{TEXLAB_BASE_URL}/texlab-x86_64-windows.zip", + platform_id="win-x64", + archive_type="zip", + binary_name="texlab.exe", + sha256=TEXLAB_SHA256_BY_PLATFORM["win-x64"], + allowed_hosts=TEXLAB_ALLOWED_HOSTS, + ), + ] + ) + dependency = deps.get_single_dep_for_current_platform() + install_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), f"texlab-{TEXLAB_VERSION}") + texlab_executable_path = deps.binary_path(install_dir) + if not os.path.exists(texlab_executable_path): + log.info(f"Downloading texlab from {dependency.url}") + deps.install(install_dir) + + assert os.path.exists(texlab_executable_path), f"texlab executable not found at {texlab_executable_path}" + + if platform_id.value != "win-x64": + os.chmod(texlab_executable_path, 0o755) + + return texlab_executable_path + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a TexlabLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + texlab_executable_path = self._setup_runtime_dependencies(solidlsp_settings) + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=texlab_executable_path, cwd=repository_root_path), + "latex", + solidlsp_settings, + ) + + def _create_base_initialize_params(self) -> dict: + """Returns the initialize params for the texlab Language Server.""" + result = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "rename": {"dynamicRegistration": True}, + }, + "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, + }, + } + return result + + def _start_server(self) -> None: + """Start the texlab server process.""" + + def register_capability_handler(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting texlab server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to texlab and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + assert "textDocumentSync" in init_response["capabilities"] + assert "documentSymbolProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) diff --git a/src/solidlsp/language_servers/ty_server.py b/src/solidlsp/language_servers/ty_server.py new file mode 100644 index 0000000..4ce6735 --- /dev/null +++ b/src/solidlsp/language_servers/ty_server.py @@ -0,0 +1,135 @@ +""" +Python language server integration using Astral's ``ty``. + +You can pass the following entries in ``ls_specific_settings["python_ty"]``: + - ls_path: Override the executable used to start ``ty``. + - ty_version: Override the pinned ``ty`` version used with ``uvx`` / ``uv x`` + (default: the bundled Serena version). +""" + +import logging + +from typing_extensions import override + +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderUvx, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +TY_VERSION = "0.0.25" + + +class TyLanguageServer(SolidLanguageServer): + """ + Provides Python specific instantiation of the LanguageServer class using ``ty``. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a TyLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + super().__init__( + config, + repository_root_path, + None, + str(config.code_language), + solidlsp_settings, + ) + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return LanguageServerDependencyProviderUvx( + self._custom_settings, + self._ls_resources_dir, + package="ty", + entrypoint="ty", + default_version=TY_VERSION, + version_setting_key="ty_version", + extra_args=("server",), + ) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"] + + @override + def _get_language_id_for_file(self, relative_file_path: str) -> str: + return "python" + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Ty language server. + """ + initialize_params = { + "capabilities": { + "workspace": { + "workspaceEdit": {"documentChanges": True}, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + }, + "textDocument": { + "synchronization": { + "dynamicRegistration": True, + "willSave": True, + "willSaveWaitUntil": True, + "didSave": True, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "hierarchicalDocumentSymbolSupport": True, + }, + "implementation": {"dynamicRegistration": True, "linkSupport": True}, + "publishDiagnostics": {"relatedInformation": True}, + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """ + Starts the Ty language server. + """ + + def execute_client_command_handler(params: dict) -> list: + return [] + + def do_nothing(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info("LSP: window/logMessage: %s", msg.get("message", "")) + + # setting up lightweight handlers + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_notification("language/status", do_nothing) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + # starting and initializing the server + log.info("Starting ty language server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to ty server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.info("Received initialize response from ty server: %s", init_response) + + capabilities = init_response["capabilities"] + assert "textDocumentSync" in capabilities + assert "definitionProvider" in capabilities + assert "referencesProvider" in capabilities + assert "documentSymbolProvider" in capabilities + + # completing the initialization handshake + self.server.notify.initialized({}) diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py new file mode 100644 index 0000000..03bda1c --- /dev/null +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -0,0 +1,555 @@ +""" +Provides TypeScript specific instantiation of the LanguageServer class. Contains various configurations and settings specific to TypeScript. +""" + +import logging +import os +import shutil +import threading +import time +from typing import Any + +from overrides import override +from sensai.util.logging import LogTime + +from solidlsp import ls_types +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_utils import PlatformId, PlatformUtils +from solidlsp.settings import SolidLSPSettings + +from .common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command + +log = logging.getLogger(__name__) + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_TYPESCRIPT_VERSION = "5.9.3" +DEFAULT_TYPESCRIPT_VERSION = "5.9.3" +INITIAL_TYPESCRIPT_LANGUAGE_SERVER_VERSION = "5.1.3" +DEFAULT_TYPESCRIPT_LANGUAGE_SERVER_VERSION = "5.1.3" + +# Platform-specific imports +if os.name != "nt": # Unix-like systems + import pwd +else: + # Dummy pwd module for Windows + class pwd: + @staticmethod + def getpwuid(uid: Any) -> Any: + return type("obj", (), {"pw_name": os.environ.get("USERNAME", "unknown")})() + + +# Conditionally import pwd module (Unix-only) +if not PlatformUtils.get_platform_id().value.startswith("win"): + pass + + +def prefer_non_node_modules_definition(definitions: list[ls_types.Location]) -> ls_types.Location: + """ + Select the preferred definition, preferring source files over type definitions. + + TypeScript language servers often return both type definitions (.d.ts files + in node_modules) and source definitions. This function prefers: + 1. Files not in node_modules + 2. Falls back to first definition if all are in node_modules + + :param definitions: A non-empty list of definition locations. + :return: The preferred definition location. + """ + for d in definitions: + rel_path = d.get("relativePath", "") + if rel_path and "node_modules" not in rel_path: + return d + return definitions[0] + + +class TypeScriptLanguageServer(SolidLanguageServer): + """ + Provides TypeScript specific instantiation of the LanguageServer class. Contains various configurations and settings specific to TypeScript. + + You can pass the following entries in ls_specific_settings["typescript"]: + - typescript_version: Version of TypeScript to install (default: "5.9.3") + - typescript_language_server_version: Version of typescript-language-server to install (default: "5.1.3") + - indexing_timeout: float, timeout in seconds for project indexing (default: 30.0) + - server_ready_timeout: float, timeout in seconds for the server-ready signal (default: 10.0) + """ + + @classmethod + def supports_implementation_request(cls) -> bool: + return True + + # Safety timeout for $/progress-based indexing wait. Normally the event fires + # well within this window; the timeout is only hit if the server never sends progress. + INDEXING_PROGRESS_TIMEOUT = 30.0 + SERVER_READY_TIMEOUT = 10.0 + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a TypeScriptLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + super().__init__( + config, + repository_root_path, + None, + "typescript", + solidlsp_settings, + ) + self.server_ready = threading.Event() + self.initialize_searcher_command_available = threading.Event() + + # tracking asynchronous diagnostics publication + self._published_diagnostics_timeout = 5.0 + + # tracking project indexing progress + self._progress_lock = threading.Lock() + self._active_progress_tokens: set[str] = set() + self._indexing_complete = threading.Event() + self._indexing_complete.set() # Initially set (no active work) + + def wait_for_indexing(self, timeout: float) -> bool: + """Block until all $/progress tokens complete. + + :param timeout: Maximum seconds to wait. + :return: True if indexing completed, False on timeout. + """ + return self._indexing_complete.wait(timeout=timeout) + + def _wait_for_indexing_start_or_completion(self, timeout: float, start_grace: float | None = None) -> bool: + """Wait until TypeScript indexing has started and drained, or provably never started. + + :param timeout: Maximum seconds to wait once active indexing progress is observed. + :param start_grace: Maximum seconds to wait for progress to begin after opening files. + :return: True if indexing completed or no progress began within the grace period, False on timeout. + """ + grace = self._INDEXING_START_GRACE_S if start_grace is None else start_grace + + # wait for progress to begin + progress_deadline = time.monotonic() + grace + while time.monotonic() < progress_deadline: + with self._progress_lock: + if self._active_progress_tokens: + break + if self._indexing_complete.is_set(): + return True + time.sleep(0.05) + + # treat absent progress as ready + with self._progress_lock: + has_active_progress = bool(self._active_progress_tokens) + if not has_active_progress: + self._indexing_complete.set() + return True + + # wait for active progress to drain + return self.wait_for_indexing(timeout=timeout) + + def expect_indexing(self) -> None: + """Signal that new files are about to be opened and async indexing should be awaited. + + Clears the internal indexing-complete event so that a subsequent + :meth:`wait_for_indexing` call blocks until all $/progress tokens + complete (or the timeout expires). + """ + self._indexing_complete.clear() + + def describe_indexing_state(self) -> str: + """:return: compact diagnostic state for TypeScript indexing progress.""" + with self._progress_lock: + active_tokens = sorted(token for token in self._active_progress_tokens if token) + complete = self._indexing_complete.is_set() + + token_text = ", ".join(active_tokens) if active_tokens else "" + return f"complete={complete}, active_progress_tokens={token_text}" + + def _get_server_ready_timeout(self) -> float: + """:return: maximum seconds to wait for the TypeScript server-ready signal.""" + return float(self._custom_settings.get("server_ready_timeout", self.SERVER_READY_TIMEOUT)) + + def _get_indexing_timeout(self) -> float: + """:return: maximum seconds to wait for TypeScript project indexing.""" + return float(self._custom_settings.get("indexing_timeout", self.INDEXING_PROGRESS_TIMEOUT)) + + def _handle_server_ready_timeout(self, timeout: float) -> None: + """Handle a TypeScript server-ready timeout. + + The base TypeScript server keeps the historical permissive behavior. Strict companion + servers override this hook to fail before serving requests from a cold server. + """ + log.info("Timeout waiting for TypeScript server to become ready after %.0fs, proceeding anyway", timeout) + self.server_ready.set() + + def _handle_project_indexing_timeout(self, timeout: float) -> None: + """Handle a TypeScript project-indexing timeout. + + The base TypeScript server keeps the historical permissive behavior. Strict companion + servers override this hook to fail before serving requests from a partially indexed program. + """ + log.warning( + "TypeScript project indexing did not complete within %.0fs; proceeding anyway (%s)", + timeout, + self.describe_indexing_state(), + ) + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [ + "node_modules", + "dist", + "build", + "coverage", + ] + + @staticmethod + def _determine_log_level(line: str) -> int: + """Classify typescript-language-server stderr output to avoid false-positive errors.""" + return SolidLanguageServer._determine_log_level(line) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """ + Setup runtime dependencies for TypeScript Language Server and return the path to the executable. + """ + platform_id = PlatformUtils.get_platform_id() + + valid_platforms = [ + PlatformId.LINUX_x64, + PlatformId.LINUX_arm64, + PlatformId.OSX, + PlatformId.OSX_x64, + PlatformId.OSX_arm64, + PlatformId.WIN_x64, + PlatformId.WIN_arm64, + ] + assert platform_id in valid_platforms, ( + f"Platform {platform_id} is not supported for multilspy javascript/typescript at the moment" + ) + + # Get version settings from ls_specific_settings or use defaults + language_specific_config = self._custom_settings + typescript_version = language_specific_config.get("typescript_version", DEFAULT_TYPESCRIPT_VERSION) + typescript_language_server_version = language_specific_config.get( + "typescript_language_server_version", DEFAULT_TYPESCRIPT_LANGUAGE_SERVER_VERSION + ) + npm_registry = language_specific_config.get("npm_registry") + + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="typescript", + description="typescript package", + command=build_npm_install_command("typescript", typescript_version, npm_registry), + platform_id="any", + ), + RuntimeDependency( + id="typescript-language-server", + description="typescript-language-server package", + command=build_npm_install_command("typescript-language-server", typescript_language_server_version, npm_registry), + platform_id="any", + ), + ] + ) + + # Verify both node and npm are installed + is_node_installed = shutil.which("node") is not None + assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." + is_npm_installed = shutil.which("npm") is not None + assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." + + # legacy unversioned dir reserved for INITIAL pair; any other version combination goes into a versioned subdir + is_initial = ( + typescript_version == INITIAL_TYPESCRIPT_VERSION + and typescript_language_server_version == INITIAL_TYPESCRIPT_LANGUAGE_SERVER_VERSION + ) + ls_dirname = "ts-lsp" if is_initial else f"ts-lsp-{typescript_version}-{typescript_language_server_version}" + tsserver_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname) + tsserver_executable_path = os.path.join(tsserver_ls_dir, "node_modules", ".bin", "typescript-language-server") + + if not os.path.exists(tsserver_executable_path): + log.info(f"Typescript Language Server executable not found at {tsserver_executable_path}. Installing...") + with LogTime("Installation of TypeScript language server dependencies", logger=log): + deps.install(tsserver_ls_dir) + log.info("TypeScript language server dependencies installed successfully") + + if not os.path.exists(tsserver_executable_path): + raise FileNotFoundError( + f"typescript-language-server executable not found at {tsserver_executable_path}, something went wrong with the installation." + ) + return tsserver_executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "--stdio"] + + def _get_language_id_for_file(self, relative_file_path: str) -> str: + # JSX is parsed as TS without this, which silently truncates symbol + # ranges at the first multi-line JSX expression. + if relative_file_path.endswith(".tsx"): + return "typescriptreact" + if relative_file_path.endswith(".jsx"): + return "javascriptreact" + return self.language_id + + def _create_base_initialize_params(self) -> dict: + initialize_params = { + "locale": "en", + # Disable Automatic Type Acquisition (ATA): with ATA enabled, tsserver fetches + # @types/* packages from npm in the background during indexing, which makes startup + # slow, network-dependent, and nondeterministic (and can hang on offline/locked-down + # machines). Serena relies on the types already installed in the project instead. + "initializationOptions": { + "preferences": { + "disableAutomaticTypingAcquisition": True, + }, + }, + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": {"dynamicRegistration": True}, + "codeAction": {"dynamicRegistration": True}, + "rename": {"dynamicRegistration": True, "prepareSupport": True}, + "publishDiagnostics": {"relatedInformation": True}, + }, + "workspace": { + "workspaceFolders": True, + "configuration": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + "window": { + "workDoneProgress": True, # Enables $/progress notifications for project loading + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """ + Starts the TypeScript Language Server, waits for the server to be ready and yields the LanguageServer instance. + + Usage: + ``` + async with lsp.start_server(): + # LanguageServer has been initialized and ready to serve requests + await lsp.request_definition(...) + await lsp.request_references(...) + # Shutdown the LanguageServer on exit from scope + # LanguageServer has been shutdown + ``` + """ + + def register_capability_handler(params: dict) -> None: + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "workspace/executeCommand": + self.initialize_searcher_command_available.set() + # TypeScript doesn't have a direct equivalent to resolve_main_method + # You might want to set a different flag or remove this line + # self.resolve_main_method_available.set() + return + + def execute_client_command_handler(params: dict) -> list: + return [] + + def configuration_handler(params: dict) -> list: + items = params.get("items", []) + return [{} for _ in items] + + def do_nothing(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def handle_typescript_version(params: dict) -> None: + """ + The $/typescriptVersion notification is sent by typescript-language-server + once tsserver has loaded and reported its version. This is a reliable + signal that tsserver is running and responsive. + """ + log.info(f"TypeScript server version notification received: {params}") + self.server_ready.set() + + def work_done_progress_create(params: dict) -> dict: + """Handle window/workDoneProgress/create: the server is about to report async progress. + + Clear the indexing-complete event so callers waiting on it will block until + all progress tokens finish. This is sent by typescript-language-server when + tsserver starts processing files (e.g. "Initializing JS/TS language features..."). + """ + token = str(params.get("token", "")) + log.debug(f"TypeScript LSP workDoneProgress/create: token={token!r}") + with self._progress_lock: + self._active_progress_tokens.add(token) + self._indexing_complete.clear() + return {} + + def progress_handler(params: dict) -> None: + """Track $/progress begin/end to detect when all async work finishes. + + typescript-language-server sends $/progress for project loading operations + like "Initializing JS/TS language features...". When all progress tokens + complete (kind='end'), _indexing_complete is set. + """ + token = str(params.get("token", "")) + value = params.get("value", {}) + kind = value.get("kind") + if kind == "begin": + title = value.get("title", "") + log.info(f"TypeScript LSP progress [{token}]: started - {title}") + with self._progress_lock: + self._active_progress_tokens.add(token) + self._indexing_complete.clear() + elif kind == "report": + pct = value.get("percentage") + msg = value.get("message", "") + pct_str = f" ({pct}%)" if pct is not None else "" + log.debug(f"TypeScript LSP progress [{token}]: {msg}{pct_str}") + elif kind == "end": + msg = value.get("message", "") + log.info(f"TypeScript LSP progress [{token}]: ended - {msg}") + with self._progress_lock: + self._active_progress_tokens.discard(token) + if not self._active_progress_tokens: + self._indexing_complete.set() + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_request("workspace/configuration", configuration_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_request("window/workDoneProgress/create", work_done_progress_create) + self.server.on_notification("$/progress", progress_handler) + self.server.on_notification("$/typescriptVersion", handle_typescript_version) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting TypeScript server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info( + "Sending initialize request from LSP client to LSP server and awaiting response", + ) + init_response = self.server.send.initialize(initialize_params) + + # TypeScript-specific capability checks + assert init_response["capabilities"]["textDocumentSync"] == 2 + assert "completionProvider" in init_response["capabilities"] + assert init_response["capabilities"]["completionProvider"] == { + "triggerCharacters": [".", '"', "'", "/", "@", "<"], + "resolveProvider": True, + } + + self.server.notify.initialized({}) + server_ready_timeout = self._get_server_ready_timeout() + if self.server_ready.wait(timeout=server_ready_timeout): + log.info("TypeScript server is ready") + else: + self._handle_server_ready_timeout(server_ready_timeout) + + # Wait for any async project loading to complete. + # typescript-language-server may send $/progress for "Initializing JS/TS + # language features…" after initialized. If no progress is sent, + # _indexing_complete stays SET and wait() returns immediately. + indexing_timeout = self._get_indexing_timeout() + log.info("Waiting for TypeScript project indexing to complete (if async)...") + if self.wait_for_indexing(timeout=indexing_timeout): + log.info("TypeScript project indexing complete") + else: + self._handle_project_indexing_timeout(indexing_timeout) + + self._activate_additional_workspaces() + + @override + def _find_representative_source_file(self, directory: str) -> str | None: + """Find a TypeScript file suitable for triggering project loading. + + Prefers a file adjacent to tsconfig.json (indicating the project root), + then falls back to the first .ts/.tsx file found. + """ + for root, dirs, files in os.walk(directory): + dirs[:] = [d for d in dirs if not self.is_ignored_dirname(d)] + if "tsconfig.json" in files: + for f in files: + if f.endswith((".ts", ".tsx")) and not f.endswith(".d.ts"): + return os.path.join(root, f) + src_dir = os.path.join(root, "src") + if os.path.isdir(src_dir): + for f in os.listdir(src_dir): + if f.endswith((".ts", ".tsx")) and not f.endswith(".d.ts"): + return os.path.join(src_dir, f) + + for root, dirs, files in os.walk(directory): + dirs[:] = [d for d in dirs if not self.is_ignored_dirname(d)] + for f in files: + if f.endswith((".ts", ".tsx")) and not f.endswith(".d.ts"): + return os.path.join(root, f) + return None + + @override + def _signal_expect_indexing(self) -> None: + self.expect_indexing() + + @override + def _wait_for_additional_workspace_indexing(self) -> None: + timeout = self._get_indexing_timeout() + if self.wait_for_indexing(timeout=timeout): + log.info("Additional workspace indexing complete") + else: + log.warning( + "Additional workspace indexing did not complete within %.0fs; proceeding anyway (%s)", + timeout, + self.describe_indexing_state(), + ) + + @override + def _get_published_diagnostics_uri(self, request_uri: str) -> str: + if os.name != "nt" or not request_uri.startswith("file:///"): + return request_uri + + path_part = request_uri[len("file:///") :] + if len(path_part) >= 2 and path_part[0].isalpha() and path_part[1] == ":": + return f"file:///{path_part[0].lower()}%3A{path_part[2:]}" + return request_uri + + @override + def _get_published_diagnostics_wait_timeout(self, pull_diagnostics_failed: bool) -> float: + return self._published_diagnostics_timeout + + @override + def _pre_open_for_cross_file_references(self) -> None: + if not self._has_waited_for_cross_file_references: + self.expect_indexing() + + #: how long to wait for tsserver to *start* reporting indexing progress after didOpen; + #: matches the fixed wait the base implementation previously used + _INDEXING_START_GRACE_S = 2.0 + + @override + def _wait_for_cross_file_references_if_needed(self) -> None: + if self._has_waited_for_cross_file_references: + return + + timeout = self._get_indexing_timeout() + if self._wait_for_indexing_start_or_completion(timeout=timeout): + log.info("TypeScript cross-file indexing complete") + else: + log.warning( + "TypeScript cross-file indexing did not complete within %.0fs; proceeding (%s)", timeout, self.describe_indexing_state() + ) + self._has_waited_for_cross_file_references = True + + @override + def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location: + return prefer_non_node_modules_definition(definitions) diff --git a/src/solidlsp/language_servers/vscode_html_language_server.py b/src/solidlsp/language_servers/vscode_html_language_server.py new file mode 100644 index 0000000..9b329bc --- /dev/null +++ b/src/solidlsp/language_servers/vscode_html_language_server.py @@ -0,0 +1,165 @@ +""" +Provides HTML-specific instantiation of the LanguageServer class using +``vscode-html-language-server`` from the ``vscode-langservers-extracted`` npm +package (the same language server VS Code uses). + +By default, the original :pypi:`vscode-langservers-extracted` package is installed +(stable, widely used). Users can opt into the actively-maintained 2026 fork +``@t1ckbase/vscode-langservers-extracted`` (or any other source) by overriding +``vscode_langservers_package`` and ``vscode_langservers_version`` in +``ls_specific_settings.html``. Both packages expose the same ``vscode-html-language-server`` +binary name under ``node_modules/.bin``, so no code changes are needed to switch. + +Caveats: + * HTML LSP provides in-file element/id symbols via ``documentSymbol``; + cross-file ``find references`` / ``go to definition`` is not meaningful for HTML. + * Language is registered as experimental. +""" + +from __future__ import annotations + +import logging +import os +import shutil +import threading + +from overrides import override + +from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +DEFAULT_PACKAGE_NAME = "vscode-langservers-extracted" +DEFAULT_PACKAGE_VERSION = "4.10.0" +LS_BIN_NAME = "vscode-html-language-server" + + +class VsCodeHtmlLanguageServer(SolidLanguageServer): + """ + HTML language server (Microsoft, extracted from VS Code). + + ``ls_specific_settings["html"]`` keys: + * ``vscode_langservers_package``: npm package providing the binary + (default: ``vscode-langservers-extracted``). + * ``vscode_langservers_version``: version of the package to install + (default: ``4.10.0``). + * ``npm_registry``: optional alternative npm registry URL. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + super().__init__( + config, + repository_root_path, + None, + "html", + solidlsp_settings, + ) + self.server_ready = threading.Event() + + @override + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["node_modules", "dist", "build", "coverage"] + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + assert shutil.which("node") is not None, "node is not installed or isn't in PATH. Please install NodeJS and try again." + assert shutil.which("npm") is not None, "npm is not installed or isn't in PATH. Please install npm and try again." + + package_name = self._custom_settings.get("vscode_langservers_package", DEFAULT_PACKAGE_NAME) + package_version = self._custom_settings.get("vscode_langservers_version", DEFAULT_PACKAGE_VERSION) + npm_registry = self._custom_settings.get("npm_registry") + + # Versioned subdir keeps a bump from silently reusing a stale binary. The dir + # is also isolated from any other LS that defaults to the same npm package, + # since users may override ``vscode_langservers_package`` independently per language. + ls_dirname = f"vscode-langservers-html-{package_version}" + install_dir = os.path.join(self._ls_resources_dir, ls_dirname) + executable_path = os.path.join(install_dir, "node_modules", ".bin", LS_BIN_NAME) + if os.name == "nt": + executable_path += ".cmd" + + if not os.path.exists(executable_path): + expected_version = f"{package_name}@{package_version}" + log.info("Installing %s for HTML language server...", expected_version) + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id=package_name, + description=f"{package_name} (provides {LS_BIN_NAME})", + command=build_npm_install_command(package_name, package_version, npm_registry), + platform_id="any", + ), + ] + ) + deps.install(install_dir) + + if not os.path.exists(executable_path): + raise FileNotFoundError( + f"{LS_BIN_NAME} executable not found at {executable_path}; " + f"npm install of {package_name}@{package_version} did not produce the expected binary." + ) + return executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "--stdio"] + + def _create_base_initialize_params(self) -> dict: + initialize_params: dict = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "rename": {"dynamicRegistration": True, "prepareSupport": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + "initializationOptions": { + "embeddedLanguages": {"css": True, "javascript": True}, + "handledSchemas": ["file"], + "provideFormatter": False, + }, + } + return initialize_params + + def _start_server(self) -> None: + def do_nothing(_params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("$/progress", do_nothing) + self.server.on_request("client/registerCapability", lambda _params: None) + self.server.on_request("workspace/configuration", lambda _params: [{}]) + + log.info("Starting vscode-html-language-server") + self.server.start() + init_params = self._create_initialize_params() + init_response = self.server.send.initialize(init_params) + log.debug("HTML LS initialize response: %s", init_response) + # Sanity-check key capabilities; HTML LSP always provides documentSymbol + completion + assert "completionProvider" in init_response["capabilities"], "HTML LSP did not advertise completionProvider" + self.server.notify.initialized({}) + self.server_ready.set() diff --git a/src/solidlsp/language_servers/vts_language_server.py b/src/solidlsp/language_servers/vts_language_server.py new file mode 100644 index 0000000..d0c5dd5 --- /dev/null +++ b/src/solidlsp/language_servers/vts_language_server.py @@ -0,0 +1,274 @@ +""" +Language Server implementation for TypeScript/JavaScript using https://github.com/yioneko/vtsls, +which provides TypeScript language server functionality via VSCode's TypeScript extension +(contrary to typescript-language-server, which uses the TypeScript compiler directly). +""" + +import logging +import os +import shutil +import threading + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_utils import PlatformId, PlatformUtils +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +from .common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command + +log = logging.getLogger(__name__) + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_VTSLS_VERSION = "0.2.9" +DEFAULT_VTSLS_VERSION = "0.2.9" + + +class VtsLanguageServer(SolidLanguageServer): + """ + Provides TypeScript specific instantiation of the LanguageServer class using vtsls. + + Supported entries in ``ls_specific_settings["typescript_vts"]``: + - ``vtsls_version``: version of ``@vtsls/language-server`` to install (default: ``"0.2.9"``). + - ``npm_registry``: custom npm registry for the managed install. + - ``initialization_options``: optional dict forwarded verbatim as LSP + ``initializationOptions``. Useful for Yarn PnP projects, e.g.:: + + initialization_options: + typescript: + tsdk: "project/.yarn/sdks/typescript/lib" + vtsls: + autoUseWorkspaceTsdk: true + + See https://github.com/yioneko/vtsls/issues/169 for the PnP recipe. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a VtsLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + vts_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=vts_lsp_executable_path, cwd=repository_root_path), + "typescript", + solidlsp_settings, + ) + self.server_ready = threading.Event() + self.initialize_searcher_command_available = threading.Event() + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [ + "node_modules", + "dist", + "build", + "coverage", + ] + + @classmethod + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str: + """ + Setup runtime dependencies for VTS Language Server and return the command to start the server. + """ + platform_id = PlatformUtils.get_platform_id() + + valid_platforms = [ + PlatformId.LINUX_x64, + PlatformId.LINUX_arm64, + PlatformId.OSX, + PlatformId.OSX_x64, + PlatformId.OSX_arm64, + PlatformId.WIN_x64, + PlatformId.WIN_arm64, + ] + assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for vtsls at the moment" + vts_config = solidlsp_settings.get_ls_specific_settings(Language.TYPESCRIPT_VTS) + vtsls_version = vts_config.get("vtsls_version", DEFAULT_VTSLS_VERSION) + npm_registry = vts_config.get("npm_registry") + + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="vtsls", + description="vtsls language server package", + command=build_npm_install_command("@vtsls/language-server", vtsls_version, npm_registry), + platform_id="any", + ), + ] + ) + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = "vts-lsp" if vtsls_version == INITIAL_VTSLS_VERSION else f"vts-lsp-{vtsls_version}" + vts_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), ls_dirname) + vts_executable_path = os.path.join(vts_ls_dir, "vtsls") + + # Verify both node and npm are installed + is_node_installed = shutil.which("node") is not None + assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." + is_npm_installed = shutil.which("npm") is not None + assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." + + # Install vtsls if not already installed + if not os.path.exists(vts_ls_dir): + os.makedirs(vts_ls_dir, exist_ok=True) + deps.install(vts_ls_dir) + + vts_executable_path = os.path.join(vts_ls_dir, "node_modules", ".bin", "vtsls") + + assert os.path.exists(vts_executable_path), "vtsls executable not found. Please install @vtsls/language-server and try again." + return f"{vts_executable_path} --stdio" + + @property + def _initialization_options(self) -> dict: + """ + Validated user-provided ``initializationOptions``. + + :raises ValueError: if ``ls_specific_settings.typescript_vts.initialization_options`` + is set to a value that is not a dict. + """ + opts = self._custom_settings.get("initialization_options") + if opts is None: + return {} + if not isinstance(opts, dict): + raise ValueError(f"ls_specific_settings.typescript_vts.initialization_options must be a dict, got {type(opts).__name__}") + return opts + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the VTS Language Server. + + If ``initialization_options`` is set in ``ls_specific_settings["typescript_vts"]``, + it is forwarded verbatim as LSP ``initializationOptions``. + """ + initialize_params: dict = { + "locale": "en", + "initializationOptions": { + "preferences": { + "disableAutomaticTypingAcquisition": True, + }, + }, + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": {"dynamicRegistration": True}, + "codeAction": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + "configuration": True, # This might be needed for vtsls + }, + }, + } + + if self._initialization_options: + log.info("Forwarding user-provided initializationOptions to vtsls: %s", self._initialization_options) + initialize_params["initializationOptions"] = self._initialization_options + + return initialize_params + + def _start_server(self) -> None: + """ + Starts the VTS Language Server, waits for the server to be ready and yields the LanguageServer instance. + + Usage: + ``` + async with lsp.start_server(): + # LanguageServer has been initialized and ready to serve requests + await lsp.request_definition(...) + await lsp.request_references(...) + # Shutdown the LanguageServer on exit from scope + # LanguageServer has been shutdown + ``` + """ + + def register_capability_handler(params: dict) -> None: + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "workspace/executeCommand": + self.initialize_searcher_command_available.set() + return + + def execute_client_command_handler(params: dict) -> list: + return [] + + init_options = self._initialization_options + + def workspace_configuration_handler(params: dict) -> list[object]: + # vtsls pulls settings for sections like "typescript", "vtsls", "javascript". + # Return the matching sub-dicts from the user-provided initialization_options. + return [init_options.get(item.get("section", ""), {}) for item in params["items"]] + + def do_nothing(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def check_experimental_status(params: dict) -> None: + """ + Also listen for experimental/serverStatus as a backup signal + """ + if params.get("quiescent") is True: + self.server_ready.set() + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_request("workspace/configuration", workspace_configuration_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("experimental/serverStatus", check_experimental_status) + + log.info("Starting VTS server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + # VTS-specific capability checks + # Be more flexible with capabilities since vtsls might have different structure + log.debug(f"VTS init response capabilities: {init_response['capabilities']}") + + # Basic checks to ensure essential capabilities are present + assert "textDocumentSync" in init_response["capabilities"] + assert "completionProvider" in init_response["capabilities"] + + # Log the actual values for debugging + log.debug(f"textDocumentSync: {init_response['capabilities']['textDocumentSync']}") + log.debug(f"completionProvider: {init_response['capabilities']['completionProvider']}") + + self.server.notify.initialized({}) + + # vtsls also reads settings via workspace/didChangeConfiguration (in addition + # to initializationOptions and workspace/configuration pulls). Push the same + # user-provided settings on all three channels for maximum compatibility, + # e.g. so that `typescript.tsdk` is honoured for Yarn PnP projects. + if init_options: + self.server.notify.workspace_did_change_configuration({"settings": init_options}) + + if self.server_ready.wait(timeout=1.0): + log.info("VTS server is ready") + else: + log.info("Timeout waiting for VTS server to become ready, proceeding anyway") + # Fallback: assume server is ready after timeout + self.server_ready.set() + + @override + def _get_wait_time_for_cross_file_referencing(self) -> float: + return 1 diff --git a/src/solidlsp/language_servers/vue_language_server.py b/src/solidlsp/language_servers/vue_language_server.py new file mode 100644 index 0000000..b4d7929 --- /dev/null +++ b/src/solidlsp/language_servers/vue_language_server.py @@ -0,0 +1,953 @@ +""" +Vue Language Server implementation using @vue/language-server (Volar) with companion TypeScript LS. +Operates in hybrid mode: Vue LS handles .vue files, TypeScript LS handles .ts/.js files. +""" + +import logging +import os +import pathlib +import shutil +import threading +from collections.abc import Callable +from pathlib import Path, PurePath +from time import sleep +from typing import Any + +from overrides import override + +from solidlsp import ls_types +from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command +from solidlsp.language_servers.typescript_language_server import ( + TypeScriptLanguageServer, + prefer_non_node_modules_definition, +) +from solidlsp.ls import LanguageServerDependencyProvider, LSPFileBuffer, SolidLanguageServer +from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.ls_types import Location +from solidlsp.ls_utils import PathUtils +from solidlsp.lsp_protocol_handler import lsp_types +from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, ExecuteCommandParams, SymbolInformation +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class VueTypeScriptServer(TypeScriptLanguageServer): + """TypeScript LS configured with @vue/typescript-plugin for Vue file support.""" + + @classmethod + @override + def get_language_enum_instance(cls) -> Language: + """Return TYPESCRIPT since this is a TypeScript language server variant. + + Note: VueTypeScriptServer is a companion server that uses TypeScript's language server + with the Vue TypeScript plugin. It reports as TYPESCRIPT to maintain compatibility + with the TypeScript language server infrastructure. + """ + return Language.TYPESCRIPT + + def get_source_fn_matcher(self) -> FilenameMatcher: + # must override with Vue-specific matcher to ensure .vue files are included (as they can be discovered via references, + # for instance; otherwise, we may find references in .vue files but then filter the results out, because .vue files are ignored.) + return Language.VUE.get_source_fn_matcher() + + class DependencyProvider(TypeScriptLanguageServer.DependencyProvider): + """Dependency provider that returns a pre-resolved executable path. + + The Vue LS install (run by ``VueLanguageServer._setup_runtime_dependencies``) + already locates the ``typescript-language-server`` binary alongside the Vue + language server, so the companion does not need to perform another install + lookup — it just returns the path it was constructed with. + """ + + def __init__( + self, + custom_settings: SolidLSPSettings.CustomLSSettings, + ls_resources_dir: str, + explicit_executable_path: str, + ) -> None: + super().__init__(custom_settings, ls_resources_dir) + self._explicit_executable_path = explicit_executable_path + + @override + def _get_or_install_core_dependency(self) -> str: + return self._explicit_executable_path + + @override + def _get_language_id_for_file(self, relative_file_path: str) -> str: + """Return the correct language ID for files. + + Vue files must be opened with language ID "vue" for the @vue/typescript-plugin + to process them correctly. The plugin is configured with "languages": ["vue"] + in the initialization options. + """ + ext = os.path.splitext(relative_file_path)[1].lower() + if ext == ".vue": + return "vue" + elif ext in (".ts", ".tsx", ".mts", ".cts"): + return "typescript" + elif ext in (".js", ".jsx", ".mjs", ".cjs"): + return "javascript" + else: + return "typescript" + + def __init__( + self, + config: LanguageServerConfig, + repository_root_path: str, + solidlsp_settings: SolidLSPSettings, + vue_plugin_path: str, + tsdk_path: str, + ts_ls_executable_path: str, + ): + self._vue_plugin_path = vue_plugin_path + self._custom_tsdk_path = tsdk_path + # Stored as instance state so the override survives across concurrent + # constructions of multiple VueLanguageServer instances. The class + # attribute pattern this replaces was racy: two parallel constructors + # could see each other's value in the brief window between assignment + # and reset. + self._explicit_ts_ls_executable = ts_ls_executable_path + super().__init__(config, repository_root_path, solidlsp_settings) + + @override + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider( + self._custom_settings, + self._ls_resources_dir, + self._explicit_ts_ls_executable, + ) + + @override + def _create_base_initialize_params(self) -> dict: + params = super()._create_base_initialize_params() + + params["initializationOptions"] = { + "plugins": [ + { + "name": "@vue/typescript-plugin", + "location": self._vue_plugin_path, + "languages": ["vue"], + } + ], + "tsserver": { + "path": self._custom_tsdk_path, + }, + } + + if "workspace" in params["capabilities"]: + params["capabilities"]["workspace"]["executeCommand"] = {"dynamicRegistration": True} + + return params + + @override + def _start_server(self) -> None: + def workspace_configuration_handler(params: dict) -> list: + items = params.get("items", []) + return [{} for _ in items] + + self.server.on_request("workspace/configuration", workspace_configuration_handler) + super()._start_server() + + +class VueLanguageServer(SolidLanguageServer): + """ + Language server for Vue Single File Components using @vue/language-server (Volar) with companion TypeScript LS. + + You can pass the following entries in ls_specific_settings["vue"]: + - vue_language_server_version: Version of @vue/language-server to install (default: "3.1.5") + + Note: TypeScript versions are configured via ls_specific_settings["typescript"]: + - typescript_version: Version of TypeScript to install (default: "5.9.3") + - typescript_language_server_version: Version of typescript-language-server to install (default: "5.1.3") + """ + + TS_SERVER_READY_TIMEOUT = 5.0 + VUE_SERVER_READY_TIMEOUT = 3.0 + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + vue_lsp_executable_path, self.tsdk_path, self._ts_ls_cmd = self._setup_runtime_dependencies(config, solidlsp_settings) + self._vue_ls_dir = os.path.join(self.ls_resources_dir(solidlsp_settings), "vue-lsp") + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=vue_lsp_executable_path, cwd=repository_root_path), + "vue", + solidlsp_settings, + ) + self.server_ready = threading.Event() + self.initialize_searcher_command_available = threading.Event() + self._ts_server: VueTypeScriptServer | None = None + self._ts_server_started = False + self._vue_files_indexed = False + self._indexed_vue_file_uris: list[str] = [] + self._ls_operational_ready_event = threading.Event() + self._ls_operational_lock = threading.Lock() + self._ls_operational_thread: threading.Thread | None = None + + def _warm_up_ls_operational_state(self) -> None: + """Warm up the Vue language server operational state asynchronously.""" + # execute the operational warm-up + try: + self._ensure_ls_operational() + except SolidLSPException: + if not self.server_started: + log.debug("Skipping Vue language server operational warm-up because the server is stopping") + return + log.exception("Error while warming up Vue language server operational state") + except Exception: + log.exception("Error while warming up Vue language server operational state") + + def _ensure_ls_operational(self) -> None: + # short-circuit completed warm-up + if self._ls_operational_ready_event.is_set(): + return + + # serialize the warm-up sequence + with self._ls_operational_lock: + # short-circuit repeated callers after waiting for the lock + if self._ls_operational_ready_event.is_set(): + return + + # validate server availability + if not self.server_started: + raise SolidLSPException("Language Server not started") + + # wait for cross-file reference readiness + if not self._has_waited_for_cross_file_references: + sleep(self._get_wait_time_for_cross_file_referencing()) + self._has_waited_for_cross_file_references = True + + # index Vue files on the companion TypeScript server + self._ensure_vue_files_indexed_on_ts_server() + + # publish operational readiness + self._ls_operational_ready_event.set() + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [ + "node_modules", + "dist", + "build", + "coverage", + ".nuxt", + ".output", + ] + + @override + def _get_language_id_for_file(self, relative_file_path: str) -> str: + ext = os.path.splitext(relative_file_path)[1].lower() + if ext == ".vue": + return "vue" + elif ext in (".ts", ".tsx", ".mts", ".cts"): + return "typescript" + elif ext in (".js", ".jsx", ".mjs", ".cjs"): + return "javascript" + else: + return "vue" + + def _is_typescript_file(self, file_path: str) -> bool: + ext = os.path.splitext(file_path)[1].lower() + return ext in (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs") + + def _find_all_vue_files(self) -> list[str]: + vue_files = [] + repo_path = Path(self.repository_root_path) + + for vue_file in repo_path.rglob("*.vue"): + try: + relative_path = str(vue_file.relative_to(repo_path)) + if "node_modules" not in relative_path and not relative_path.startswith("."): + vue_files.append(relative_path) + except Exception as e: + log.debug(f"Error processing Vue file {vue_file}: {e}") + + return vue_files + + def _ensure_vue_files_indexed_on_ts_server(self) -> None: + if self._vue_files_indexed: + return + + assert self._ts_server is not None + log.info("Indexing .vue files on TypeScript server for cross-file references") + vue_files = self._find_all_vue_files() + log.debug(f"Found {len(vue_files)} .vue files to index") + + # Prepare the TS server to track new $/progress notifications triggered + # by the didOpen calls below. Must happen BEFORE opening files to avoid + # a race where progress begins and ends before we start waiting. + self._ts_server.expect_indexing() + + for vue_file in vue_files: + try: + with self._ts_server.open_file(vue_file) as file_buffer: + file_buffer.ref_count += 1 + self._indexed_vue_file_uris.append(file_buffer.uri) + except Exception as e: + log.debug(f"Failed to open {vue_file} on TS server: {e}") + + self._vue_files_indexed = True + log.info("Vue file indexing on TypeScript server complete, waiting for TS server to finish processing") + + self._wait_for_ts_indexing_complete() + + def _wait_for_ts_indexing_complete(self) -> None: + """Wait for the companion TypeScript server to finish processing opened Vue files. + + Uses the $/progress tracking in TypeScriptLanguageServer: after Vue files are + opened, tsserver sends "Initializing JS/TS language features…" progress. + We wait for all progress tokens to complete, with a timeout fallback. + """ + assert self._ts_server is not None + timeout = TypeScriptLanguageServer.INDEXING_PROGRESS_TIMEOUT + if self._ts_server.wait_for_indexing(timeout=timeout): + log.info("TypeScript server finished indexing Vue files (signaled via $/progress)") + else: + log.warning(f"Timeout ({timeout}s) waiting for TypeScript server to finish indexing Vue files, proceeding anyway") + + def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None: + uri = PathUtils.path_to_uri(os.path.join(self.repository_root_path, relative_file_path)) + request_params = { + "textDocument": {"uri": uri}, + "position": {"line": line, "character": column}, + "context": {"includeDeclaration": False}, + } + + return self.server.send.references(request_params) # type: ignore[arg-type] + + def _send_ts_references_request(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: + assert self._ts_server is not None + uri = PathUtils.path_to_uri(os.path.join(self.repository_root_path, relative_file_path)) + request_params = { + "textDocument": {"uri": uri}, + "position": {"line": line, "character": column}, + "context": {"includeDeclaration": True}, + } + + with self._ts_server.open_file(relative_file_path): + response = self._ts_server.handler.send.references(request_params) # type: ignore[arg-type] + + result: list[ls_types.Location] = [] + if response is not None: + for item in response: + abs_path = PathUtils.uri_to_path(item["uri"]) + if not Path(abs_path).is_relative_to(self.repository_root_path): + log.debug(f"Found reference outside repository: {abs_path}, skipping") + continue + + rel_path = Path(abs_path).relative_to(self.repository_root_path) + if self.is_ignored_path(str(rel_path)): + log.debug(f"Ignoring reference in {rel_path}") + continue + + new_item: dict = {} + new_item.update(item) + new_item["absolutePath"] = str(abs_path) + new_item["relativePath"] = str(rel_path) + result.append(ls_types.Location(**new_item)) # type: ignore + + return result + + def request_file_references(self, relative_file_path: str) -> list: + self._ensure_ls_operational() + + absolute_file_path = os.path.join(self.repository_root_path, relative_file_path) + uri = PathUtils.path_to_uri(absolute_file_path) + + request_params = {"textDocument": {"uri": uri}} + + log.info(f"Sending volar/client/findFileReference request for {relative_file_path}") + log.info(f"Request URI: {uri}") + log.info(f"Request params: {request_params}") + + try: + with self.open_file(relative_file_path): + log.debug(f"Sending volar/client/findFileReference for {relative_file_path}") + log.debug(f"Request params: {request_params}") + + response = self.server.send_request("volar/client/findFileReference", request_params) + + log.debug(f"Received response type: {type(response)}") + + log.info(f"Received file references response: {response}") + log.info(f"Response type: {type(response)}") + + if response is None: + log.debug(f"No file references found for {relative_file_path}") + return [] + + # Response should be an array of Location objects + if not isinstance(response, list): + log.warning(f"Unexpected response format from volar/client/findFileReference: {type(response)}") + return [] + + ret: list[Location] = [] + for item in response: + if not isinstance(item, dict) or "uri" not in item: + log.debug(f"Skipping invalid location item: {item}") + continue + + abs_path = PathUtils.uri_to_path(item["uri"]) + if not Path(abs_path).is_relative_to(self.repository_root_path): + log.warning(f"Found file reference outside repository: {abs_path}, skipping") + continue + + rel_path = Path(abs_path).relative_to(self.repository_root_path) + if self.is_ignored_path(str(rel_path)): + log.debug(f"Ignoring file reference in {rel_path}") + continue + + new_item: dict = {} + new_item.update(item) + new_item["absolutePath"] = str(abs_path) + new_item["relativePath"] = str(rel_path) + ret.append(Location(**new_item)) # type: ignore + + log.debug(f"Found {len(ret)} file references for {relative_file_path}") + return ret + + except Exception as e: + log.warning(f"Error requesting file references for {relative_file_path}: {e}") + return [] + + @override + def request_references(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: + self._ensure_ls_operational() + symbol_refs = self._send_ts_references_request(relative_file_path, line=line, column=column) + + if relative_file_path.endswith(".vue"): + log.info(f"Attempting to find file-level references for Vue component {relative_file_path}") + file_refs = self.request_file_references(relative_file_path) + log.info(f"file_refs result: {len(file_refs)} references found") + + seen = set() + for ref in symbol_refs: + key = (ref["uri"], ref["range"]["start"]["line"], ref["range"]["start"]["character"]) + seen.add(key) + + for file_ref in file_refs: + key = (file_ref["uri"], file_ref["range"]["start"]["line"], file_ref["range"]["start"]["character"]) + if key not in seen: + symbol_refs.append(file_ref) + seen.add(key) + + log.info(f"Total references for {relative_file_path}: {len(symbol_refs)} (symbol refs + file refs, deduplicated)") + + return symbol_refs + + @override + def request_definition(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: + self._ensure_ls_operational() + assert self._ts_server is not None + with self._ts_server.open_file(relative_file_path): + return self._ts_server.request_definition(relative_file_path, line, column) + + @override + def request_rename_symbol_edit(self, relative_file_path: str, line: int, column: int, new_name: str) -> ls_types.WorkspaceEdit | None: + self._ensure_ls_operational() + assert self._ts_server is not None + with self._ts_server.open_file(relative_file_path): + return self._ts_server.request_rename_symbol_edit(relative_file_path, line, column, new_name) + + @override + def request_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + self._ensure_ls_operational() + assert self._ts_server is not None + return self._ts_server.request_text_document_diagnostics(relative_file_path, start_line, end_line, min_severity) + + def _forward_edit_to_ts_server_if_needed(self, relative_file_path: str, edit_fn: Callable[[], object]) -> None: + """ + Calls ``edit_fn`` on the TypeScript server if the file is open there. + + Only applicable to non-TypeScript files (i.e. .vue files) that have been + indexed on the TypeScript server for cross-file reference support. + + :param relative_file_path: the relative path of the file that was edited + :param edit_fn: callable that performs the corresponding edit on ``_ts_server`` + """ + if self._ts_server is None or not self._ts_server_started: + return + if self._is_typescript_file(relative_file_path): + return + + absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path)) + uri = pathlib.Path(absolute_file_path).as_uri() + if uri in self._ts_server.open_file_buffers: + edit_fn() + + @override + def insert_text_at_position(self, relative_file_path: str, line: int, column: int, text_to_be_inserted: str) -> ls_types.Position: + """ + Inserts text at the given position, forwarding the change to the TypeScript server if it has the file open. + + :param relative_file_path: the relative path of the file to edit + :param line: the line number + :param column: the column number + :param text_to_be_inserted: the text to insert + :return: updated cursor position + """ + result = super().insert_text_at_position(relative_file_path, line, column, text_to_be_inserted) + self._forward_edit_to_ts_server_if_needed( + relative_file_path, + lambda: self._ts_server.insert_text_at_position( # type: ignore[union-attr] + relative_file_path, line, column, text_to_be_inserted + ), + ) + return result + + @override + def delete_text_between_positions( + self, + relative_file_path: str, + start: ls_types.Position, + end: ls_types.Position, + ) -> str: + """ + Deletes text between the given positions, forwarding the change to the TypeScript server if it has the file open. + + :param relative_file_path: the relative path of the file to edit + :param start: start position + :param end: end position + :return: deleted text + """ + deleted_text = super().delete_text_between_positions(relative_file_path, start, end) + self._forward_edit_to_ts_server_if_needed( + relative_file_path, + lambda: self._ts_server.delete_text_between_positions( # type: ignore[union-attr] + relative_file_path, start, end + ), + ) + return deleted_text + + @classmethod + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> tuple[list[str], str, str]: + is_node_installed = shutil.which("node") is not None + assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." + is_npm_installed = shutil.which("npm") is not None + assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." + + # Get TypeScript version settings from TypeScript language server settings + typescript_config = solidlsp_settings.get_ls_specific_settings(Language.TYPESCRIPT) + typescript_version = typescript_config.get("typescript_version", "5.9.3") + typescript_language_server_version = typescript_config.get("typescript_language_server_version", "5.1.3") + vue_config = solidlsp_settings.get_ls_specific_settings(Language.VUE) + vue_language_server_version = vue_config.get("vue_language_server_version", "3.1.5") + npm_registry = vue_config.get("npm_registry", typescript_config.get("npm_registry")) + + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="vue-language-server", + description="Vue language server package (Volar)", + command=build_npm_install_command("@vue/language-server", vue_language_server_version, npm_registry), + platform_id="any", + ), + RuntimeDependency( + id="typescript", + description="TypeScript (required for tsdk)", + command=build_npm_install_command("typescript", typescript_version, npm_registry), + platform_id="any", + ), + RuntimeDependency( + id="typescript-language-server", + description="TypeScript language server (for Vue LS 3.x tsserver forwarding)", + command=build_npm_install_command("typescript-language-server", typescript_language_server_version, npm_registry), + platform_id="any", + ), + ] + ) + + vue_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "vue-lsp") + vue_executable_path = os.path.join(vue_ls_dir, "node_modules", ".bin", "vue-language-server") + ts_ls_executable_path = os.path.join(vue_ls_dir, "node_modules", ".bin", "typescript-language-server") + + if os.name == "nt": + vue_executable_path += ".cmd" + ts_ls_executable_path += ".cmd" + + tsdk_path = os.path.join(vue_ls_dir, "node_modules", "typescript", "lib") + + # Check if installation is needed based on executables AND version + version_file = os.path.join(vue_ls_dir, ".installed_version") + expected_version = f"{vue_language_server_version}_{typescript_version}_{typescript_language_server_version}" + + needs_install = False + if not os.path.exists(vue_executable_path) or not os.path.exists(ts_ls_executable_path): + log.info("Vue/TypeScript Language Server executables not found.") + needs_install = True + elif os.path.exists(version_file): + with open(version_file) as f: + installed_version = f.read().strip() + if installed_version != expected_version: + log.info( + f"Vue Language Server version mismatch: installed={installed_version}, expected={expected_version}. Reinstalling..." + ) + needs_install = True + else: + # No version file exists, assume old installation needs refresh + log.info("Vue Language Server version file not found. Reinstalling to ensure correct version...") + needs_install = True + + if needs_install: + log.info("Installing Vue/TypeScript Language Server dependencies...") + deps.install(vue_ls_dir) + # Write version marker file + with open(version_file, "w") as f: + f.write(expected_version) + log.info("Vue language server dependencies installed successfully") + + if not os.path.exists(vue_executable_path): + raise FileNotFoundError( + f"vue-language-server executable not found at {vue_executable_path}, something went wrong with the installation." + ) + + if not os.path.exists(ts_ls_executable_path): + raise FileNotFoundError( + f"typescript-language-server executable not found at {ts_ls_executable_path}, something went wrong with the installation." + ) + + return [vue_executable_path, "--stdio"], tsdk_path, ts_ls_executable_path + + def _create_base_initialize_params(self) -> dict: + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": {"dynamicRegistration": True}, + "codeAction": {"dynamicRegistration": True}, + "rename": {"dynamicRegistration": True, "prepareSupport": True}, + "publishDiagnostics": {"relatedInformation": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + "initializationOptions": { + "vue": { + "hybridMode": True, + }, + "typescript": { + "tsdk": self.tsdk_path, + }, + }, + } + return initialize_params + + def _start_typescript_server(self) -> None: + try: + vue_ts_plugin_path = os.path.join(self._vue_ls_dir, "node_modules", "@vue", "typescript-plugin") + + ts_config = LanguageServerConfig( + code_language=Language.TYPESCRIPT, + trace_lsp_communication=False, + ) + + log.info("Creating companion VueTypeScriptServer") + self._ts_server = VueTypeScriptServer( + config=ts_config, + repository_root_path=self.repository_root_path, + solidlsp_settings=self._solidlsp_settings, + vue_plugin_path=vue_ts_plugin_path, + tsdk_path=self.tsdk_path, + ts_ls_executable_path=self._ts_ls_cmd, + ) + + log.info("Starting companion TypeScript server") + self._ts_server.start() + + log.info("Waiting for companion TypeScript server to be ready...") + if not self._ts_server.server_ready.wait(timeout=self.TS_SERVER_READY_TIMEOUT): + log.warning( + f"Timeout waiting for companion TypeScript server to be ready after {self.TS_SERVER_READY_TIMEOUT} seconds, proceeding anyway" + ) + self._ts_server.server_ready.set() + + self._ts_server_started = True + log.info("Companion TypeScript server ready") + except Exception as e: + log.error(f"Error starting TypeScript server: {e}") + self._ts_server = None + self._ts_server_started = False + raise + + def _forward_tsserver_request(self, method: str, params: dict) -> Any: + if self._ts_server is None: + log.error("Cannot forward tsserver request - TypeScript server not started") + return None + + try: + execute_params: ExecuteCommandParams = { + "command": "typescript.tsserverRequest", + "arguments": [method, params, {"isAsync": True, "lowPriority": True}], + } + result = self._ts_server.handler.send.execute_command(execute_params) + log.debug(f"TypeScript server raw response for {method}: {result}") + + if isinstance(result, dict) and "body" in result: + return result["body"] + return result + except Exception as e: + log.error(f"Error forwarding tsserver request {method}: {e}") + return None + + def _cleanup_indexed_vue_files(self) -> None: + if not self._indexed_vue_file_uris or self._ts_server is None: + return + + log.debug(f"Cleaning up {len(self._indexed_vue_file_uris)} indexed Vue files") + for uri in self._indexed_vue_file_uris: + try: + if uri in self._ts_server.open_file_buffers: + file_buffer = self._ts_server.open_file_buffers[uri] + file_buffer.ref_count -= 1 + + if file_buffer.ref_count == 0: + self._ts_server.server.notify.did_close_text_document({"textDocument": {"uri": uri}}) + del self._ts_server.open_file_buffers[uri] + log.debug(f"Closed indexed Vue file: {uri}") + except Exception as e: + log.debug(f"Error closing indexed Vue file {uri}: {e}") + + self._indexed_vue_file_uris.clear() + + def _stop_typescript_server(self) -> None: + if self._ts_server is not None: + try: + log.info("Stopping companion TypeScript server") + self._ts_server.stop() + except Exception as e: + log.warning(f"Error stopping TypeScript server: {e}") + finally: + self._ts_server = None + self._ts_server_started = False + + @override + def _start_server(self) -> None: + self._start_typescript_server() + + def register_capability_handler(params: dict) -> None: + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "workspace/executeCommand": + self.initialize_searcher_command_available.set() + return + + def configuration_handler(params: dict) -> list: + items = params.get("items", []) + return [{} for _ in items] + + def do_nothing(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + message_text = msg.get("message", "") + if "initialized" in message_text.lower() or "ready" in message_text.lower(): + log.info("Vue language server ready signal detected") + self.server_ready.set() + + def tsserver_request_notification_handler(params: list) -> None: + try: + if params and len(params) > 0 and len(params[0]) >= 2: + request_id = params[0][0] + method = params[0][1] + method_params = params[0][2] if len(params[0]) > 2 else {} + log.debug(f"Received tsserver/request: id={request_id}, method={method}") + + if method == "_vue:projectInfo": + file_path = method_params.get("file", "") + tsconfig_path = self._find_tsconfig_for_file(file_path) + result = {"configFileName": tsconfig_path} if tsconfig_path else None + response = [[request_id, result]] + self.server.notify.send_notification("tsserver/response", response) + log.debug(f"Sent tsserver/response for projectInfo: {tsconfig_path}") + else: + result = self._forward_tsserver_request(method, method_params) + response = [[request_id, result]] + self.server.notify.send_notification("tsserver/response", response) + log.debug(f"Forwarded tsserver/response for {method}: {result}") + else: + log.warning(f"Unexpected tsserver/request params format: {params}") + except Exception as e: + log.error(f"Error handling tsserver/request: {e}") + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_request("workspace/configuration", configuration_handler) + self.server.on_notification("tsserver/request", tsserver_request_notification_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting Vue server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.debug(f"Received initialize response from Vue server: {init_response}") + + assert init_response["capabilities"]["textDocumentSync"] in [1, 2] + + self.server.notify.initialized({}) + + log.info("Waiting for Vue language server to be ready...") + if not self.server_ready.wait(timeout=self.VUE_SERVER_READY_TIMEOUT): + log.info("Timeout waiting for Vue server ready signal, proceeding anyway") + self.server_ready.set() + else: + log.info("Vue server initialization complete") + + # kick off asynchronous operational warm-up + self._ls_operational_ready_event.clear() + self._ls_operational_thread = threading.Thread( + target=self._warm_up_ls_operational_state, + name="vue-ls-operational-warmup", + daemon=True, + ) + self._ls_operational_thread.start() + + def _find_tsconfig_for_file(self, file_path: str) -> str | None: + if not file_path: + tsconfig_path = os.path.join(self.repository_root_path, "tsconfig.json") + return tsconfig_path if os.path.exists(tsconfig_path) else None + + current_dir = os.path.dirname(file_path) + repo_root = os.path.abspath(self.repository_root_path) + + while current_dir and current_dir.startswith(repo_root): + tsconfig_path = os.path.join(current_dir, "tsconfig.json") + if os.path.exists(tsconfig_path): + return tsconfig_path + parent = os.path.dirname(current_dir) + if parent == current_dir: + break + current_dir = parent + + tsconfig_path = os.path.join(repo_root, "tsconfig.json") + return tsconfig_path if os.path.exists(tsconfig_path) else None + + @override + def _get_wait_time_for_cross_file_referencing(self) -> float: + return 5.0 + + @override + def stop(self, shutdown_timeout: float = 5.0) -> None: + # serialize shutdown with operational warm-up + with self._ls_operational_lock: + self.server_started = False + self._ls_operational_ready_event.clear() + self._cleanup_indexed_vue_files() + self._stop_typescript_server() + self._ls_operational_thread = None + + super().stop(shutdown_timeout) + + @override + def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location: + return prefer_non_node_modules_definition(definitions) + + @override + def _request_document_symbols( + self, relative_file_path: str, file_data: LSPFileBuffer | None + ) -> list[SymbolInformation] | list[DocumentSymbol] | None: + """ + Override to filter out shorthand property references in Vue files. + + In Vue, when using shorthand syntax in defineExpose like `defineExpose({ pressCount })`, + the Vue LSP returns both: + - The Variable definition (e.g., `const pressCount = ref(0)`) + - A Property symbol for the shorthand reference (e.g., `pressCount` in defineExpose) + + This causes duplicate symbols with the same name, which breaks symbol lookup. + We filter out Property symbols that have a matching Variable with the same name + at a different location (the definition), keeping only the definition. + """ + symbols = super()._request_document_symbols(relative_file_path, file_data) + + if symbols is None or len(symbols) == 0: + return symbols + + # Only process DocumentSymbol format (hierarchical symbols with children) + # SymbolInformation format doesn't have the same issue + if not isinstance(symbols[0], dict) or "range" not in symbols[0]: + return symbols + + return self._filter_shorthand_property_duplicates(symbols) + + @staticmethod + def _filter_shorthand_property_duplicates( + symbols: list[DocumentSymbol] | list[SymbolInformation], + ) -> list[DocumentSymbol] | list[SymbolInformation]: + """ + Filter out Property symbols that have a matching Variable symbol with the same name. + + This handles Vue's shorthand property syntax in defineExpose, where the same + identifier appears as both a Variable definition and a Property reference. + """ + VARIABLE_KIND = 13 # SymbolKind.Variable + PROPERTY_KIND = 7 # SymbolKind.Property + + def filter_symbols(syms: list[dict]) -> list[dict]: + # Collect all Variable symbol names with their line numbers + variable_names: dict[str, set[int]] = {} + for sym in syms: + if sym.get("kind") == VARIABLE_KIND: + name = sym.get("name", "") + line = sym.get("range", {}).get("start", {}).get("line", -1) + if name not in variable_names: + variable_names[name] = set() + variable_names[name].add(line) + + # Filter: keep symbols that are either: + # 1. Not a Property, or + # 2. A Property without a matching Variable name at a different location + filtered = [] + for sym in syms: + name = sym.get("name", "") + kind = sym.get("kind") + line = sym.get("range", {}).get("start", {}).get("line", -1) + + # If it's a Property with a matching Variable name at a DIFFERENT line, skip it + if kind == PROPERTY_KIND and name in variable_names: + # Check if there's a Variable definition at a different line + var_lines = variable_names[name] + if any(var_line != line for var_line in var_lines): + # This is a shorthand reference, skip it + log.debug( + f"Filtering shorthand property reference '{name}' at line {line} " + f"(Variable definition exists at line(s) {var_lines})" + ) + continue + + # Recursively filter children + children = sym.get("children", []) + if children: + sym = dict(sym) # Create a copy to avoid mutating the original + sym["children"] = filter_symbols(children) + + filtered.append(sym) + + return filtered + + return filter_symbols(list(symbols)) # type: ignore diff --git a/src/solidlsp/language_servers/yaml_language_server.py b/src/solidlsp/language_servers/yaml_language_server.py new file mode 100644 index 0000000..b229f15 --- /dev/null +++ b/src/solidlsp/language_servers/yaml_language_server.py @@ -0,0 +1,189 @@ +""" +Provides YAML specific instantiation of the LanguageServer class using yaml-language-server. +Contains various configurations and settings specific to YAML files. +""" + +import logging +import os +import shutil +from typing import Any + +from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_YAML_LANGUAGE_SERVER_VERSION = "1.19.2" +DEFAULT_YAML_LANGUAGE_SERVER_VERSION = "1.19.2" + + +class YamlLanguageServer(SolidLanguageServer): + """ + Provides YAML specific instantiation of the LanguageServer class using yaml-language-server. + Contains various configurations and settings specific to YAML files. + """ + + @staticmethod + def _determine_log_level(line: str) -> int: + """Classify yaml-language-server stderr output to avoid false-positive errors.""" + line_lower = line.lower() + + # Known informational messages from yaml-language-server that aren't critical errors + if any( + [ + "cannot find module" in line_lower and "package.json" in line_lower, # Schema resolution - not critical + "no parser" in line_lower, # Parser messages - informational + ] + ): + return logging.DEBUG + + return SolidLanguageServer._determine_log_level(line) + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a YamlLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + super().__init__( + config, + repository_root_path, + None, + "yaml", + solidlsp_settings, + ) + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """ + Setup runtime dependencies for YAML Language Server and return the command to start the server. + """ + # Verify both node and npm are installed + is_node_installed = shutil.which("node") is not None + assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." + is_npm_installed = shutil.which("npm") is not None + assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." + yaml_language_server_version = self._custom_settings.get("yaml_language_server_version", DEFAULT_YAML_LANGUAGE_SERVER_VERSION) + npm_registry = self._custom_settings.get("npm_registry") + + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="yaml-language-server", + description="yaml-language-server package (Red Hat)", + command=build_npm_install_command("yaml-language-server", yaml_language_server_version, npm_registry), + platform_id="any", + ), + ] + ) + + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = ( + "yaml-lsp" + if yaml_language_server_version == INITIAL_YAML_LANGUAGE_SERVER_VERSION + else f"yaml-lsp-{yaml_language_server_version}" + ) + yaml_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname) + yaml_executable_path = os.path.join(yaml_ls_dir, "node_modules", ".bin", "yaml-language-server") + + # Handle Windows executable extension + if os.name == "nt": + yaml_executable_path += ".cmd" + + if not os.path.exists(yaml_executable_path): + log.info(f"YAML Language Server executable not found at {yaml_executable_path}. Installing...") + deps.install(yaml_ls_dir) + log.info("YAML language server dependencies installed successfully") + + if not os.path.exists(yaml_executable_path): + raise FileNotFoundError( + f"yaml-language-server executable not found at {yaml_executable_path}, something went wrong with the installation." + ) + + return yaml_executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "--stdio"] + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the YAML Language Server. + """ + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "codeAction": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + "initializationOptions": { + "yaml": { + "schemaStore": {"enable": True, "url": "https://www.schemastore.org/api/json/catalog.json"}, + "format": {"enable": True}, + "validate": True, + "hover": True, + "completion": True, + } + }, + } + return initialize_params + + def _start_server(self) -> None: + """ + Starts the YAML Language Server, waits for the server to be ready and yields the LanguageServer instance. + """ + + def register_capability_handler(params: Any) -> None: + return + + def do_nothing(params: Any) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting YAML server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.debug(f"Received initialize response from YAML server: {init_response}") + + # Verify document symbol support is available + if "documentSymbolProvider" in init_response["capabilities"]: + log.info("YAML server supports document symbols") + else: + log.warning("Warning: YAML server does not report document symbol support") + + self.server.notify.initialized({}) + + # YAML language server is ready immediately after initialization + log.info("YAML server initialization complete") diff --git a/src/solidlsp/language_servers/zls.py b/src/solidlsp/language_servers/zls.py new file mode 100644 index 0000000..c05ec37 --- /dev/null +++ b/src/solidlsp/language_servers/zls.py @@ -0,0 +1,225 @@ +""" +Provides Zig specific instantiation of the LanguageServer class using ZLS (Zig Language Server). +""" + +import logging +import os +import pathlib +import platform +import shutil +import subprocess + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class ZigLanguageServer(SolidLanguageServer): + """ + Provides Zig specific instantiation of the LanguageServer class using ZLS. + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # For Zig projects, we should ignore: + # - zig-cache: build cache directory + # - zig-out: default build output directory + # - .zig-cache: alternative cache location + # - node_modules: if the project has JavaScript components + return super().is_ignored_dirname(dirname) or dirname in ["zig-cache", "zig-out", ".zig-cache", "node_modules", "build", "dist"] + + @staticmethod + def _get_zig_version() -> str | None: + """Get the installed Zig version or None if not found.""" + try: + result = subprocess.run(["zig", "version"], capture_output=True, text=True, check=False) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + return None + return None + + @staticmethod + def _get_zls_version() -> str | None: + """Get the installed ZLS version or None if not found.""" + try: + result = subprocess.run(["zls", "--version"], capture_output=True, text=True, check=False) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + return None + return None + + @staticmethod + def _check_zls_installed() -> bool: + """Check if ZLS is installed in the system.""" + return shutil.which("zls") is not None + + @staticmethod + def _setup_runtime_dependency() -> bool: + """ + Check if required Zig runtime dependencies are available. + Raises RuntimeError with helpful message if dependencies are missing. + """ + # Check for Windows and provide error message + if platform.system() == "Windows": + raise RuntimeError( + "Windows is not supported by ZLS in this integration. Cross-file references don't work reliably on Windows. Reason unknown." + ) + + zig_version = ZigLanguageServer._get_zig_version() + if not zig_version: + raise RuntimeError( + "Zig is not installed. Please install Zig from https://ziglang.org/download/ and make sure it is added to your PATH." + ) + + if not ZigLanguageServer._check_zls_installed(): + zls_version = ZigLanguageServer._get_zls_version() + if not zls_version: + raise RuntimeError( + "Found Zig but ZLS (Zig Language Server) is not installed.\n" + "Please install ZLS from https://github.com/zigtools/zls\n" + "You can install it via:\n" + " - Package managers (brew install zls, scoop install zls, etc.)\n" + " - Download pre-built binaries from GitHub releases\n" + " - Build from source with: zig build -Doptimize=ReleaseSafe\n\n" + "After installation, make sure 'zls' is added to your PATH." + ) + + return True + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + self._setup_runtime_dependency() + + super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd="zls", cwd=repository_root_path), "zig", solidlsp_settings) + self.request_id = 0 + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Zig Language Server. + """ + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "completion": { + "dynamicRegistration": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + }, + }, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + "publishDiagnostics": {"relatedInformation": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "configuration": True, + }, + }, + "initializationOptions": { + # ZLS specific options based on schema.json + # Critical paths for ZLS to understand the project + "zig_exe_path": shutil.which("zig"), # Path to zig executable + "zig_lib_path": None, # Let ZLS auto-detect + "build_runner_path": None, # Let ZLS use its built-in runner + "global_cache_path": None, # Let ZLS use default cache + # Build configuration + "enable_build_on_save": True, # Enable to analyze project structure + "build_on_save_args": ["build"], + # Features + "enable_snippets": True, + "enable_argument_placeholders": True, + "semantic_tokens": "full", + "warn_style": False, + "highlight_global_var_declarations": False, + "skip_std_references": False, + "prefer_ast_check_as_child_process": True, + "completion_label_details": True, + # Inlay hints configuration + "inlay_hints_show_variable_type_hints": True, + "inlay_hints_show_struct_literal_field_type": True, + "inlay_hints_show_parameter_name": True, + "inlay_hints_show_builtin": True, + "inlay_hints_exclude_single_argument": True, + "inlay_hints_hide_redundant_param_names": False, + "inlay_hints_hide_redundant_param_names_last_token": False, + }, + } + return initialize_params + + def _start_server(self) -> None: + """Start ZLS server process""" + + def register_capability_handler(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting ZLS server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + assert "documentSymbolProvider" in init_response["capabilities"] + assert "referencesProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + + # ZLS server is ready after initialization + # (no need to wait for an event) + + # Open build.zig if it exists to help ZLS understand project structure + build_zig_path = os.path.join(self.repository_root_path, "build.zig") + if os.path.exists(build_zig_path): + try: + with open(build_zig_path, encoding="utf-8") as f: + content = f.read() + uri = pathlib.Path(build_zig_path).as_uri() + self.server.notify.did_open_text_document( + { + "textDocument": { + "uri": uri, + "languageId": "zig", + "version": 1, + "text": content, + } + } + ) + log.info("Opened build.zig to provide project context to ZLS") + except Exception as e: + log.warning(f"Failed to open build.zig: {e}") diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py new file mode 100644 index 0000000..f296fee --- /dev/null +++ b/src/solidlsp/ls.py @@ -0,0 +1,3367 @@ +import dataclasses +import hashlib +import json +import logging +import os +import pathlib +import shutil +import threading +from abc import ABC, abstractmethod +from collections import defaultdict +from collections.abc import Callable, Hashable, Iterator, Sequence +from contextlib import contextmanager +from copy import copy +from dataclasses import dataclass +from pathlib import Path, PurePath +from time import monotonic, perf_counter, sleep +from typing import Any, Self, Union, cast + +import pathspec +from sensai.util.pickle import getstate, load_pickle +from sensai.util.string import ToStringMixin + +from serena.util.file_system import match_path +from serena.util.text_utils import MatchedConsecutiveLines +from solidlsp import ls_types +from solidlsp.initialize_params import DefaultInitializeParamsBuilder, InitializeParamsBuilder +from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.ls_process import LanguageServerInterface, StdioLanguageServer +from solidlsp.ls_types import UnifiedSymbolInformation +from solidlsp.ls_utils import FileUtils, PathUtils, TextUtils +from solidlsp.lsp_protocol_handler import lsp_types +from solidlsp.lsp_protocol_handler import lsp_types as LSPTypes +from solidlsp.lsp_protocol_handler.lsp_constants import LSPConstants +from solidlsp.lsp_protocol_handler.lsp_types import ( + Definition, + DefinitionParams, + DocumentSymbol, + ImplementationParams, + InitializeParams, + LocationLink, + RenameParams, + SymbolInformation, +) +from solidlsp.lsp_protocol_handler.server import ( + LSPError, + ProcessLaunchInfo, + StringDict, +) +from solidlsp.settings import SolidLSPSettings +from solidlsp.util.cache import load_cache, save_cache + +RawDocumentSymbol = Union[DocumentSymbol, SymbolInformation] +""" +Type alias for the raw symbol information returned by a language server in response to a +`textDocument/documentSymbol` request. +The `DocumentSymbol` is the preferred type, but the legacy type `SymbolInformation` is also still used. +""" + +log = logging.getLogger(__name__) + +_debug_enabled = log.isEnabledFor(logging.DEBUG) +"""Serves as a flag that triggers additional computation when debug logging is enabled.""" + + +@dataclasses.dataclass(kw_only=True) +class ReferenceInSymbol: + """A symbol retrieved when requesting reference to a symbol, together with the location of the reference""" + + symbol: ls_types.UnifiedSymbolInformation + line: int + character: int + + +class LSPFileBuffer: + """ + This class is used to store the contents of an open LSP file in memory. + """ + + def __init__( + self, + abs_path: Path, + uri: str, + encoding: str, + version: int, + language_id: str, + ref_count: int, + language_server: "SolidLanguageServer", + open_in_ls: bool = True, + ) -> None: + self.abs_path = abs_path + self.language_server = language_server + self.uri = uri + self._read_file_modified_date: float | None = None + self._contents: str | None = None + self.version = version + self.language_id = language_id + self.ref_count = ref_count + self.encoding = encoding + self._content_hash: str | None = None + self._is_open_in_ls = False + if open_in_ls: + self._open_in_ls() + + def _open_in_ls(self) -> None: + """ + Open the file in the language server if it is not already open. + """ + if self._is_open_in_ls: + return + self._is_open_in_ls = True + self.language_server.server.notify.did_open_text_document( + { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict + LSPConstants.TEXT_DOCUMENT: { + LSPConstants.URI: self.uri, + LSPConstants.LANGUAGE_ID: self.language_id, + LSPConstants.VERSION: 0, + LSPConstants.TEXT: self.contents, + } + } + ) + + def close(self) -> None: + if self._is_open_in_ls: + self.language_server.server.notify.did_close_text_document( + { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict + LSPConstants.TEXT_DOCUMENT: { + LSPConstants.URI: self.uri, + } + } + ) + + def ensure_open_in_ls(self) -> None: + """Ensure that the file is opened in the language server.""" + self._open_in_ls() + + @property + def contents(self) -> str: + file_modified_date = self.abs_path.stat().st_mtime + + # if contents are cached, check if they are stale (file modification since last read) and invalidate if so + if self._contents is not None: + assert self._read_file_modified_date is not None + if file_modified_date > self._read_file_modified_date: + self._contents = None + + if self._contents is None: + self._read_file_modified_date = file_modified_date + self._contents = FileUtils.read_file(str(self.abs_path), self.encoding) + self._content_hash = None + + return self._contents + + @contents.setter + def contents(self, new_contents: str) -> None: + """ + Sets new contents for the file buffer (in-memory change only). + Persistence of the change to disk must be handled separately. + + :param new_contents: the new contents to set + """ + self._contents = new_contents + self._content_hash = None + + @property + def content_hash(self) -> str: + if self._content_hash is None: + self._content_hash = hashlib.md5(self.contents.encode(self.encoding)).hexdigest() + return self._content_hash + + def split_lines(self) -> list[str]: + """Splits the contents of the file into lines.""" + return self.contents.split("\n") + + +class SymbolBody(ToStringMixin): + """ + Representation of the body of a symbol, which allows the extraction of the symbol's text + from the lines of the file it is defined in. + + Instances that share the same lines buffer are memory-efficient, + using only 4 integers and a reference to the lines buffer from which the text can be extracted, + i.e. a core representation of only about 40 bytes per body. + """ + + def __init__(self, lines: list[str], start_line: int, start_col: int, end_line: int, end_col: int) -> None: + self._lines = lines + self._start_line = start_line + self._start_col = start_col + self._end_line = end_line + self._end_col = end_col + + def _tostring_excludes(self) -> list[str]: + return ["_lines"] + + def get_text(self) -> str: + # extract relevant lines + symbol_body = "\n".join(self._lines[self._start_line : self._end_line + 1]) + + # remove leading content from the first line + symbol_body = symbol_body[self._start_col :] + + # remove trailing content from the last line + last_line = self._lines[self._end_line] + trailing_length = len(last_line) - self._end_col + if trailing_length > 0: + symbol_body = symbol_body[: -(len(last_line) - self._end_col)] + + return symbol_body + + +class SymbolBodyFactory: + """ + A factory for the creation of SymbolBody instances from symbols dictionaries. + Instances created from the same factory instance are memory-efficient, as they share + the same lines buffer. + """ + + def __init__(self, file_buffer: LSPFileBuffer): + self._lines = file_buffer.split_lines() + + def create_symbol_body(self, symbol: UnifiedSymbolInformation) -> SymbolBody: + existing_body = symbol.get("body", None) + if existing_body and isinstance(existing_body, SymbolBody): + return existing_body + + assert "location" in symbol + start_line = symbol["location"]["range"]["start"]["line"] + end_line = symbol["location"]["range"]["end"]["line"] + start_col = symbol["location"]["range"]["start"]["character"] + end_col = symbol["location"]["range"]["end"]["character"] + return SymbolBody(self._lines, start_line, start_col, end_line, end_col) + + +class DocumentSymbols: + # IMPORTANT: Instances of this class are persisted in the high-level document symbol cache + + def __init__(self, root_symbols: list[ls_types.UnifiedSymbolInformation]): + self.root_symbols = root_symbols + self._all_symbols: list[ls_types.UnifiedSymbolInformation] | None = None + + def __getstate__(self) -> dict: + return getstate(DocumentSymbols, self, transient_properties=["_all_symbols"]) + + def iter_symbols(self) -> Iterator[ls_types.UnifiedSymbolInformation]: + """ + Iterate over all symbols in the document symbol tree. + Yields symbols in a depth-first manner. + """ + if self._all_symbols is not None: + yield from self._all_symbols + return + + def traverse(s: ls_types.UnifiedSymbolInformation) -> Iterator[ls_types.UnifiedSymbolInformation]: + yield s + for child in s.get("children", []): + yield from traverse(child) + + for root_symbol in self.root_symbols: + yield from traverse(root_symbol) + + def get_all_symbols_and_roots(self) -> tuple[list[ls_types.UnifiedSymbolInformation], list[ls_types.UnifiedSymbolInformation]]: + """ + This function returns all symbols in the document as a flat list and the root symbols. + It exists to facilitate migration from previous versions, where this was the return interface of + the LS method that obtained document symbols. + + :return: A tuple containing a list of all symbols in the document and a list of root symbols. + """ + if self._all_symbols is None: + self._all_symbols = list(self.iter_symbols()) + return self._all_symbols, self.root_symbols + + +class LanguageServerDependencyProvider(ABC): + """ + Prepares dependencies for a language server (if any), ultimately enabling the launch command to be constructed + and optionally providing environment variables that are necessary for the execution. + """ + + def __init__(self, custom_settings: SolidLSPSettings.CustomLSSettings, ls_resources_dir: str): + """ + :param custom_settings: the (user-provided) language server-specific settings + :param ls_resources_dir: the directory in which data for the language-server shall be stored + (this is already specific to the concrete language server, i.e. no further subdirectory is needed) + """ + self._custom_settings = custom_settings + self._ls_resources_dir = ls_resources_dir + + @abstractmethod + def create_launch_command(self) -> list[str]: + """ + Creates the launch command for this language server, potentially downloading and installing dependencies + beforehand. + + :return: the launch command as a list containing the executable and its arguments + """ + + def create_launch_command_env(self) -> dict[str, str]: + """ + Provides environment variables to be set when executing the launch command. + + This method is intended to be overridden by subclasses that need to set variables. + + :return: a mapping for variable names to values + """ + return {} + + +class LanguageServerDependencyProviderBaseCommand(LanguageServerDependencyProvider, ABC): + """ + Special case of a dependency provider, where the launch command is constructed from a base command. + + The user can configure aspects of the launch command in LS-specific settings: + + * ``ls_base_cmd`: overrides the base command + * ``ls_path``: overrides the path of the language server's core dependency (e.g. its executable or a JAR file), + from which the base command is formed, bypassing Serena's managed installation + * ``ls_args``: overrides the arguments that are added to the base command in order to form the launch command + * ``ls_extra_args``: additional arguments to append to the launch command + """ + + @abstractmethod + def _create_default_base_command(self) -> list[str]: + """ + Obtains the default base command for this language server, potentially downloading and installing dependencies + beforehand. + + Note: The user can override the base command, so this will only be run if no custom base command is provided. + + :return: the base command as a list containing the executable and its arguments + """ + + @abstractmethod + def _create_launch_command_from_base_command(self, base_command: list[str]) -> list[str]: + """ + Adds any additional arguments to the base command to create the final launch command. + + :param base_command: the base command + :return: the extended command + """ + + def create_launch_command(self) -> list[str]: + # obtain base command + base_command = self._custom_settings.get("ls_base_cmd", None) + if base_command is not None and not isinstance(base_command, list): + log.warning("The 'ls_base_cmd' setting should be a list of strings. Ignoring the provided value: %s", base_command) + base_command = None + if base_command is None: + ls_path = self._custom_settings.get("ls_path", None) + if ls_path is not None: + base_command = [ls_path] + else: + # default case: base command is constructed by the provider implementation + base_command = self._create_default_base_command() + + # create launch command from base command + ls_args = self._custom_settings.get("ls_args") + if ls_args is not None: + if not isinstance(ls_args, list): + log.warning("The 'ls_args' setting should be a list of strings. Ignoring the provided value: %s", ls_args) + ls_args = None + if ls_args is not None: + cmd = list(base_command) + ls_args + else: + cmd = self._create_launch_command_from_base_command(list(base_command)) + + # add user-provided extra arguments (if any) + ls_extra_args = self._custom_settings.get("ls_extra_args", []) + if ls_extra_args: + if not isinstance(ls_extra_args, list): + log.warning("The 'ls_extra_args' setting should be a list of strings. Ignoring the provided value: %s", ls_extra_args) + else: + cmd = cmd + ls_extra_args + + return cmd + + +class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProviderBaseCommand, ABC): + """ + Special case of a dependency provider, where there is a single core dependency which provides + the basis for the launch command. + + The core dependency's path can be overridden by the user in LS-specific settings (SerenaConfig) + via the key "ls_path". If the user provides the key, the specified path is used directly. + Otherwise, the provider implementation is called to get or install the core dependency. + + Note: The inheritance from the BaseCommand class serves to allow user overrides to be handled + centrally, but implementations of this class do not necessarily follow the principle that + the base command is strictly extended. + Yet incompatibility arises only if (a) the user overrides the base command with a command that + has arguments, and (b) the implementation of this class does not construct the launch command + by appending arguments to the core dependency's path. + """ + + @abstractmethod + def _get_or_install_core_dependency(self) -> str: + """ + Gets the language server's core path, potentially installing dependencies beforehand. + + :return: the core dependency's path (e.g. executable, jar, etc.) + """ + + @abstractmethod + def _create_launch_command(self, core_path: str) -> list[str]: + """ + :param core_path: path to the core dependency + :return: the launch command as a list containing the executable and its arguments + """ + + def _create_default_base_command(self) -> list[str]: + # We treat the core path as the only element of the base command, + # noting that the construction of the launch command from the base command + # is not necessarily to append arguments only. + core_path = self._get_or_install_core_dependency() + return [core_path] + + def _create_launch_command_from_base_command(self, base_command: list[str]) -> list[str]: + core_path = base_command[0] + cmd = self._create_launch_command(core_path) + if len(base_command) == 1: + # This is the regular case, where the base command consists only of the core + # dependency's path, and the launch command is constructed from it. + return cmd + else: + # In this case, the user has overridden the base command via LS-specific settings + # with a command that has arguments and therefore does not map cleanly to + # the single path assumption. However, in special cases where the full command + # was constructed by appending arguments to the core dependency's path, + # we simply assume that the provided base command can be substituted. + if cmd[0] == core_path: + return base_command + cmd[1:] + else: + raise ValueError("Language server base launch command with arguments unsupported") + + +class LanguageServerDependencyProviderUvx(LanguageServerDependencyProviderBaseCommand): + """ + Dependency provider for language servers distributed as a PyPI package, run on demand via ``uvx`` / ``uv x``. + + The pinned package version can be overridden by the user via the LS-specific setting given by + ``version_setting_key``. Alternatively, the LS-specific setting "ls_path" can be set to the path of an + already-installed language server executable, in which case it is launched directly, bypassing uv entirely. + """ + + DEFAULT_UVX_PYTHON_VERSION = "3.13" + + def __init__( + self, + custom_settings: "SolidLSPSettings.CustomLSSettings", + ls_resources_dir: str, + *, + package: str, + entrypoint: str, + default_version: str, + version_setting_key: str, + extra_args: Sequence[str] = (), + ): + """ + :param package: the PyPI package name (e.g. ``"pyright"``) + :param entrypoint: the console script provided by the package (e.g. ``"pyright-langserver"``) + :param default_version: the package version to pin unless overridden + :param version_setting_key: the LS-specific setting key through which the user can override the version + :param extra_args: arguments appended after the entrypoint (e.g. ``("--stdio",)``) + """ + super().__init__(custom_settings, ls_resources_dir) + self._package = package + self._entrypoint = entrypoint + self._default_version = default_version + self._version_setting_key = version_setting_key + self._extra_args = tuple(extra_args) + + @staticmethod + def _build_uvx_base_command( + package: str, + version: str, + entrypoint: str, + python_version: str = DEFAULT_UVX_PYTHON_VERSION, + ) -> list[str]: + """Build a command that runs a pinned PyPI package's console script on demand via ``uvx`` / ``uv x``. + + Resolution order: + 1. Prefer ``uvx`` (env var ``UVX`` or PATH lookup). + 2. Fall back to ``uv x`` if only ``uv`` is on PATH. + 3. Raise ``RuntimeError`` if neither is available. + + :param package: PyPI package name (e.g. ``"pyright"``). + :param version: Pinned package version. + :param entrypoint: Console script provided by the package (e.g. ``"pyright-langserver"``). + :param python_version: Python interpreter version passed via ``-p`` (uv will fetch it if missing). + """ + base_args = ["-p", python_version, "--from", f"{package}=={version}", entrypoint] + + uvx_path = os.environ.get("UVX") or shutil.which("uvx") + if uvx_path is not None: + return [uvx_path, *base_args] + + uv_path = shutil.which("uv") + if uv_path is not None: + return [uv_path, "x", *base_args] + + raise RuntimeError("Could not find 'uvx' or 'uv' in PATH. Install uv (https://docs.astral.sh/uv/).") + + def _create_default_base_command(self): + version = self._custom_settings.get(self._version_setting_key, self._default_version) + return self._build_uvx_base_command(self._package, version, self._entrypoint) + + def _create_launch_command_from_base_command(self, base_command: list[str]) -> list[str]: + return base_command + list(self._extra_args) + + +class SolidLanguageServer(ABC): + """ + High-level abstraction for language server interaction, which wraps the underlying low-level LSP interface + """ + + CACHE_FOLDER_NAME = "cache" + RAW_DOCUMENT_SYMBOLS_CACHE_VERSION = 1 + """ + global version identifier for raw symbol caches; an LS-specific version is defined separately and combined with this. + This should be incremented whenever there is a change in the way raw document symbols are stored. + If the result of a language server changes in a way that affects the raw document symbols, + the LS-specific version should be incremented instead. + """ + RAW_DOCUMENT_SYMBOL_CACHE_FILENAME = "raw_document_symbols.pkl" + RAW_DOCUMENT_SYMBOL_CACHE_FILENAME_LEGACY_FALLBACK = "document_symbols_cache_v23-06-25.pkl" + DOCUMENT_SYMBOL_CACHE_VERSION = 4 + DOCUMENT_SYMBOL_CACHE_FILENAME = "document_symbols.pkl" + + # Directories that should always be ignored regardless of language: + # VCS internals, virtual environments, caches, and serena's own data. + _ALWAYS_IGNORED_DIRS = frozenset( + { + ".git", + ".svn", + ".hg", + ".bzr", # VCS + ".venv", + ".env", # virtual environments + ".cache", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", # caches + ".tox", + ".nox", # test runners + ".idea", # IDE internals + ".serena", # serena's own data + ".vscode", # Doesn't contain symbols + } + ) + + # To be overridden and extended by subclasses + def is_ignored_dirname(self, dirname: str) -> bool: + """ + A language-specific condition for directories that should always be ignored. For example, venv + in Python and node_modules in JS/TS should be ignored always. + """ + return dirname in self._ALWAYS_IGNORED_DIRS + + @staticmethod + def _determine_log_level(line: str) -> int: + """ + Classify a stderr line from the language server to determine appropriate logging level. + + Language servers may emit informational messages to stderr that contain words like "error" + but are not actual errors. Subclasses can override this method to filter out known + false-positive patterns specific to their language server. + + :param line: The stderr line to classify + :return: A logging level (logging.DEBUG, logging.INFO, logging.WARNING, or logging.ERROR) + """ + line_lower = line.lower() + + # Default classification: treat lines with "error" or "exception" as ERROR level + if "error" in line_lower or "exception" in line_lower or line.startswith("E["): + return logging.ERROR + else: + return logging.INFO + + @classmethod + def get_language_enum_instance(cls) -> Language: + return Language.from_ls_class(cls) + + @classmethod + def supports_implementation_request(cls) -> bool: + """ + Return whether this language server supports ``textDocument/implementation``. + """ + return False + + @classmethod + def ls_resources_dir(cls, solidlsp_settings: SolidLSPSettings, mkdir: bool = True) -> str: + """ + Returns the directory where the language server resources are downloaded. + This is used to store language server binaries, configuration files, etc. + """ + result = os.path.join(solidlsp_settings.ls_resources_dir, cls.__name__) + + # Migration of previously downloaded LS resources that were downloaded to a subdir of solidlsp instead of to the user's home + pre_migration_ls_resources_dir = os.path.join(os.path.dirname(__file__), "language_servers", "static", cls.__name__) + if os.path.exists(pre_migration_ls_resources_dir): + if os.path.exists(result): + # if the directory already exists, we just remove the old resources + shutil.rmtree(result, ignore_errors=True) + else: + # move old resources to the new location + shutil.move(pre_migration_ls_resources_dir, result) + if mkdir: + os.makedirs(result, exist_ok=True) + return result + + @classmethod + def create( + cls, + config: LanguageServerConfig, + repository_root_path: str, + timeout: float | None = None, + solidlsp_settings: SolidLSPSettings | None = None, + ) -> "SolidLanguageServer": + """ + Creates a language specific LanguageServer instance based on the given configuration, and appropriate settings for the programming language. + + If language is Java, then ensure that jdk-17.0.6 or higher is installed, `java` is in PATH, and JAVA_HOME is set to the installation directory. + If language is JS/TS, then ensure that node (v18.16.0 or higher) is installed and in PATH. + + :param repository_root_path: The root path of the repository. + :param config: language server configuration. + :param logger: The logger to use. + :param timeout: the timeout for requests to the language server. If None, no timeout will be used. + :param solidlsp_settings: additional settings + :return LanguageServer: A language specific LanguageServer instance. + """ + ls: SolidLanguageServer + if solidlsp_settings is None: + solidlsp_settings = SolidLSPSettings() + + # Ensure repository_root_path is absolute to avoid issues with file URIs + repository_root_path = os.path.abspath(repository_root_path) + + ls_class = config.code_language.get_ls_class() + # All language server implementations are required to use the same signature of the constructor + # (which differs from the signature of the base class constructor). + ls = ls_class(config, repository_root_path, solidlsp_settings) + ls.set_request_timeout(timeout) + return ls + + def __init__( + self, + config: LanguageServerConfig, + repository_root_path: str, + process_launch_info: ProcessLaunchInfo | None, + language_id: str, + solidlsp_settings: SolidLSPSettings, + cache_version_raw_document_symbols: Hashable = 1, + ): + """ + Initializes a LanguageServer instance. + + Do not instantiate this class directly. Use `LanguageServer.create` method instead. + + :param config: the language server's configuration. + :param repository_root_path: the root path of the repository. + :param process_launch_info: (DEPRECATED: pass None and implement _create_dependency_provider instead) + the command used to start the actual language server. + The command must pass appropriate flags to the binary, so that it runs in the stdio mode, + as opposed to HTTP, TCP modes supported by some language servers. + :param language_id: The language identifier which will be passed to the language server in the `textDocument/didOpen` + notification by default. + If the language server uses multiple language identifiers, it must override the method `get_language_id_for_file` + to provide the appropriate identifier for each type of file. + :param solidlsp_settings: the global SolidLSP settings + :param cache_version_raw_document_symbols: the version, for caching, of the raw document symbols coming + from this specific language server. This should be incremented by subclasses calling this constructor + whenever the format of the raw document symbols changes (typically because the language server + improves/fixes its output). + """ + self.config = config + self._solidlsp_settings = solidlsp_settings + lang = self.get_language_enum_instance() + self._custom_settings = solidlsp_settings.get_ls_specific_settings(lang) + """ + the (user-provided) language server-specific settings + """ + self._ls_resources_dir = self.ls_resources_dir(solidlsp_settings) + log.debug(f"Custom config (LS-specific settings) for {lang}: {self._custom_settings}") + self._encoding = config.encoding + self.repository_root_path: str = repository_root_path + + log.debug( + f"Creating language server instance for {repository_root_path=} with {language_id=} and process launch info: {process_launch_info}" + ) + + self.language_id = language_id + """ + default language identifier to be passed to the language server in `textDocument/didOpen` notifications. + """ + self.open_file_buffers: dict[str, LSPFileBuffer] = {} + self.language = self.get_language_enum_instance() + """ + identifies the language server (not to be confused with the language id passed to the language server) + """ + # The source filename matcher is a @cache'd per-language singleton. A previous project may + # have extended it (e.g. Perl's file_filter adding .cgi); reset it here so every activation + # starts from the language's default extensions, then language-server subclasses re-apply + # their own settings during the rest of __init__. + self.language.get_source_fn_matcher().reset() + self._published_diagnostics: dict[str, list[ls_types.Diagnostic]] = {} + self._published_diagnostics_generation_by_uri: dict[str, int] = {} + self._published_diagnostics_generation = 0 + self._published_diagnostics_condition = threading.Condition() + + # initialise symbol caches + self.cache_dir = Path(self._solidlsp_settings.project_data_path) / self.CACHE_FOLDER_NAME / self.language_id + self.cache_dir.mkdir(parents=True, exist_ok=True) + # * raw document symbols cache + self._ls_specific_raw_document_symbols_cache_version = cache_version_raw_document_symbols + self._raw_document_symbols_cache: dict[str, tuple[str, list[DocumentSymbol] | list[SymbolInformation] | None]] = {} + """maps relative file paths to a tuple of (file_content_hash, raw_root_symbols)""" + self._raw_document_symbols_cache_is_modified: bool = False + self._load_raw_document_symbols_cache() + # * high-level document symbols cache + self._document_symbols_cache: dict[str, tuple[str, DocumentSymbols]] = {} + """maps relative file paths to a tuple of (file_content_hash, document_symbols)""" + self._document_symbols_cache_is_modified: bool = False + self._load_document_symbols_cache() + + self.server_started = False + if config.trace_lsp_communication: + + def logging_fn(source: str, target: str, msg: StringDict | str) -> None: + log.debug(f"LSP: {source} -> {target}: {msg!s}") + + else: + logging_fn = None + + # create the LanguageServerHandler, which provides the functionality to start the language server and communicate with it, + # preparing the launch command beforehand + self._dependency_provider: LanguageServerDependencyProvider | None = None + if process_launch_info is None: + self._dependency_provider = self._create_dependency_provider() + process_launch_info = self._create_process_launch_info() + log.debug(f"Creating language server instance with {language_id=} and process launch info: {process_launch_info}") + self.server = self._create_language_server_interface(process_launch_info, logging_fn) + """ + the low-level language server interface + """ + self.server.on_any_notification(self._observe_server_notification) + + # Set up the pathspec matcher for the ignored paths + # for all absolute paths in ignored_paths, convert them to relative paths + processed_patterns = [] + for pattern in set(config.ignored_paths): + # Normalize separators (pathspec expects forward slashes) + pattern = pattern.replace(os.path.sep, "/") + processed_patterns.append(pattern) + log.debug(f"Processing {len(processed_patterns)} ignored paths from the config") + + # Create a pathspec matcher from the processed patterns + self._ignore_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, processed_patterns) + + self._has_waited_for_cross_file_references = False + + self._abs_workspace_folders_indexed = self.config.get_absolute_workspace_folders(self.repository_root_path) + self._abs_workspace_folders_additional = self.config.get_absolute_additional_workspace_folders(self.repository_root_path) + """ + additional workspace folders, which are passed to the language server in the initialization request + but which are not indexed by SolidLSP, i.e. not traversed for full symbol tree + """ + self._abs_workspace_folders_all = self._abs_workspace_folders_indexed + self._abs_workspace_folders_additional + + @property + def custom_settings(self) -> SolidLSPSettings.CustomLSSettings: + """ + The (user-provided) language server-specific settings. + """ + return self._custom_settings + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + """ + Creates the dependency provider for this language server. + + Subclasses should override this method to provide their specific dependency provider. + This method is only called if process_launch_info is not passed to __init__. + """ + raise NotImplementedError( + f"{self.__class__.__name__} must implement _create_dependency_provider() or pass process_launch_info to __init__()" + ) + + def _create_process_launch_info(self) -> ProcessLaunchInfo: + assert self._dependency_provider is not None + cmd = self._dependency_provider.create_launch_command() + env = self._dependency_provider.create_launch_command_env() + return ProcessLaunchInfo(cmd=cmd, cwd=self.repository_root_path, env=env) + + def _create_language_server_interface( + self, process_launch_info: ProcessLaunchInfo, logging_fn: Callable[[str, str, StringDict | str], None] | None + ) -> LanguageServerInterface: + """ + Creates the low-level language server interface for LSP communication. + + The interface is created but not started. + + The default implementation creates a StdioLanguageServer, but subclasses can override this method to create a different type + of interface if needed. + + :param process_launch_info: process launch information + :param logging_fn: the trace logging function + :return: the interface + """ + return StdioLanguageServer( + process_launch_info, + language=self.language, + determine_log_level=self._determine_log_level, + logger=logging_fn, + start_independent_lsp_process=self.config.start_independent_lsp_process, + ) + + # --- diagnostics-related functions --- + + def _observe_server_notification(self, method: str, params: Any) -> None: + """ + Observe notifications sent by the language server. + + This is used for generic cross-language bookkeeping that must work independently of + language-specific notification handlers. + """ + if method == "textDocument/publishDiagnostics": + self._store_published_diagnostics(params) + + def _store_published_diagnostics(self, params: Any) -> None: + """ + Store diagnostics received through ``textDocument/publishDiagnostics``. + """ + if not isinstance(params, dict): + return + + uri = params.get("uri") + diagnostics = params.get("diagnostics") + if not isinstance(uri, str) or not isinstance(diagnostics, list): + return + + normalized_diagnostics: list[ls_types.Diagnostic] = [] + for diagnostic in diagnostics: + if not isinstance(diagnostic, dict): + continue + if "message" not in diagnostic or "range" not in diagnostic: + continue + + normalized_diagnostic: ls_types.Diagnostic = { + "uri": uri, + "message": diagnostic["message"], + "range": diagnostic["range"], + } + severity = diagnostic.get("severity") + if isinstance(severity, int): + normalized_diagnostic["severity"] = ls_types.DiagnosticSeverity(severity) + + code = diagnostic.get("code") + if isinstance(code, int | str): + normalized_diagnostic["code"] = code + + if "source" in diagnostic: + normalized_diagnostic["source"] = diagnostic["source"] + normalized_diagnostics.append(ls_types.Diagnostic(**normalized_diagnostic)) + + # canonicalize the key so lookups using URIs produced by pathlib.Path.as_uri() match + # what servers publish (e.g. file:///c%3A/... or file:///c:/... vs. file:///C:/...) + key = self._canonicalize_published_diagnostics_uri(uri) + + with self._published_diagnostics_condition: + self._published_diagnostics_generation += 1 + self._published_diagnostics[key] = normalized_diagnostics + self._published_diagnostics_generation_by_uri[key] = self._published_diagnostics_generation + self._published_diagnostics_condition.notify_all() + + @staticmethod + def _canonicalize_published_diagnostics_uri(uri: str) -> str: + """ + Canonicalizes a ``file://`` URI so that diagnostics published by language servers + and lookups based on ``pathlib.Path.as_uri()`` agree on the same key. + + On Windows, servers may publish under ``file:///c%3A/...`` or ``file:///c:/...`` while + ``pathlib.Path.as_uri()`` produces ``file:///C:/...``. The canonical form uses an + upper-case drive letter and a plain colon. + """ + if os.name != "nt" or not uri.startswith("file:///"): + return uri + + # extract the segment after "file:///" up to the next slash and look for a drive letter + prefix = "file:///" + rest = uri[len(prefix) :] + slash = rest.find("/") + head = rest if slash < 0 else rest[:slash] + tail = "" if slash < 0 else rest[slash:] + + if (len(head) >= 2 and head[0].isalpha() and head[1] == ":") or ( + len(head) >= 4 and head[0].isalpha() and head[1:4].lower() == "%3a" + ): + head = head[0].upper() + ":" + else: + return uri + + return prefix + head + tail + + def _get_published_diagnostics_generation(self, uri: str) -> int: + key = self._canonicalize_published_diagnostics_uri(uri) + with self._published_diagnostics_condition: + return self._published_diagnostics_generation_by_uri.get(key, -1) + + def _wait_for_published_diagnostics( + self, + uri: str, + after_generation: int, + timeout: float, + ) -> list[ls_types.Diagnostic] | None: + key = self._canonicalize_published_diagnostics_uri(uri) + deadline = perf_counter() + timeout + with self._published_diagnostics_condition: + while True: + current_generation = self._published_diagnostics_generation_by_uri.get(key, -1) + if current_generation > after_generation: + return list(self._published_diagnostics.get(key, [])) + + remaining_timeout = deadline - perf_counter() + if remaining_timeout <= 0: + return None + self._published_diagnostics_condition.wait(timeout=remaining_timeout) + + def _get_cached_published_diagnostics(self, uri: str) -> list[ls_types.Diagnostic] | None: + key = self._canonicalize_published_diagnostics_uri(uri) + with self._published_diagnostics_condition: + diagnostics = self._published_diagnostics.get(key) + if diagnostics is None: + return None + return list(diagnostics) + + @staticmethod + def _diagnostic_matches_range(diagnostic: ls_types.Diagnostic, start_line: int, end_line: int) -> bool: + diagnostic_start_line = diagnostic["range"]["start"]["line"] + diagnostic_end_line = diagnostic["range"]["end"]["line"] + + # normalize inverted ranges: some servers (e.g. Julia's JuliaSyntax.jl for unterminated + # expressions) emit diagnostics whose end position lies before the start position. + # treat such ranges as a single span covering both lines. + lo = min(diagnostic_start_line, diagnostic_end_line) + hi = max(diagnostic_start_line, diagnostic_end_line) + + # when end_line < 0 the caller imposes no upper bound; only enforce the lower bound. + if end_line < 0: + return hi >= start_line + return lo <= end_line and hi >= start_line + + @staticmethod + def _diagnostic_matches_min_severity(diagnostic: ls_types.Diagnostic, min_severity: int) -> bool: + severity = diagnostic.get("severity") + if severity is None: + return True + return int(severity) <= min_severity + + @classmethod + def _filter_diagnostics( + cls, + diagnostics: list[ls_types.Diagnostic], + start_line: int, + end_line: int, + min_severity: int, + ) -> list[ls_types.Diagnostic]: + diagnostics = [d for d in diagnostics if cls._diagnostic_matches_range(d, start_line, end_line)] + diagnostics = [d for d in diagnostics if cls._diagnostic_matches_min_severity(d, min_severity)] + return diagnostics + + def _validate_text_document_diagnostics_request( + self, + relative_file_path: str, + start_line: int, + end_line: int, + min_severity: int, + ) -> str: + if not self.server_started: + log.error("request_text_document_diagnostics called before Language Server started") + raise SolidLSPException("Language Server not started") + if start_line < 0: + raise ValueError(f"start_line must be non-negative, got {start_line}") + if end_line != -1 and end_line < start_line: + raise ValueError(f"end_line must be -1 or >= start_line, got {end_line} < {start_line}") + if min_severity not in {1, 2, 3, 4}: + raise ValueError(f"min_severity must be one of 1, 2, 3, 4, got {min_severity}") + return pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri() + + def get_published_diagnostics_generation(self, relative_file_path: str) -> int: + """ + Get the generation number for the latest published diagnostics of a file. + + :param relative_file_path: The relative path of the file. + :return: the generation number, or ``-1`` if none were published yet. + """ + uri = pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri() + return self._get_published_diagnostics_generation(uri) + + def get_cached_published_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic] | None: + """ + Get cached diagnostics received through ``textDocument/publishDiagnostics``. + + :param relative_file_path: The relative path of the file to retrieve diagnostics for. + :param start_line: the first 0-based line to include in the result. + :param end_line: the last 0-based line to include in the result. ``-1`` means no upper bound. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + Diagnostics with lower-or-equal numeric severity are returned. + :return: the cached diagnostics, or ``None`` if no diagnostics were published yet. + """ + uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + diagnostics = self._get_cached_published_diagnostics(uri) + if diagnostics is None: + return None + return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) + + def request_published_text_document_diagnostics( + self, + relative_file_path: str, + after_generation: int = -1, + timeout: float = 2.5, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + allow_cached: bool = True, + ) -> list[ls_types.Diagnostic] | None: + """ + Wait for diagnostics received through ``textDocument/publishDiagnostics`` and return them. + + :param relative_file_path: The relative path of the file to retrieve diagnostics for. + :param after_generation: only return diagnostics published after this generation. ``-1`` accepts the next publication. + :param timeout: the maximum time to wait for a newer publication. + :param start_line: the first 0-based line to include in the result. + :param end_line: the last 0-based line to include in the result. ``-1`` means no upper bound. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + Diagnostics with lower-or-equal numeric severity are returned. + :param allow_cached: whether to fall back to the current cached diagnostics if no newer publication arrives in time. + :return: the published diagnostics, or ``None`` if no diagnostics are available. + """ + uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + published_uri = self._get_published_diagnostics_uri(uri) + diagnostics: list[ls_types.Diagnostic] | None = None + + # keeping the document open + with self.open_file(relative_file_path): + diagnostics = self._wait_for_relevant_published_diagnostics( + uri=published_uri, + after_generation=after_generation, + timeout=timeout, + allow_cached=allow_cached, + ) + + if diagnostics is None: + return None + + return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) + + def _get_published_diagnostics_uri(self, request_uri: str) -> str: + """ + Gets the URI under which published diagnostics should be looked up. + """ + return request_uri + + def _supports_pull_diagnostics(self) -> bool: + """ + Whether the language server handles ``textDocument/diagnostic`` (LSP 3.17 pull + diagnostics) gracefully. Subclasses should override and return ``False`` when the + underlying server does not implement pull diagnostics, especially when sending + the request would terminate the server (e.g. LanguageServer.jl raises an error + for unknown methods that crashes the process). + """ + return True + + def _get_published_diagnostics_wait_timeout(self, pull_diagnostics_failed: bool) -> float: + """ + Gets the timeout for waiting on published diagnostics after a diagnostics request. + """ + return 2.5 + + def _accept_published_diagnostics(self, diagnostics: list[ls_types.Diagnostic]) -> bool: + """ + Determines whether a published diagnostics payload should satisfy the current wait. + """ + return bool(diagnostics) + + def _wait_for_relevant_published_diagnostics( + self, + uri: str, + after_generation: int, + timeout: float, + allow_cached: bool = True, + ) -> list[ls_types.Diagnostic] | None: + """ + Waits for a published diagnostics payload that is relevant for the current request. + """ + deadline = monotonic() + timeout + current_after_generation = after_generation + + while True: + remaining_timeout = deadline - monotonic() + if remaining_timeout <= 0: + break + + diagnostics = self._wait_for_published_diagnostics( + uri=uri, + after_generation=current_after_generation, + timeout=remaining_timeout, + ) + if diagnostics is None: + break + if self._accept_published_diagnostics(diagnostics): + return diagnostics + current_after_generation = self._get_published_diagnostics_generation(uri) + + if allow_cached: + diagnostics = self._get_cached_published_diagnostics(uri) + if diagnostics is not None and self._accept_published_diagnostics(diagnostics): + return diagnostics + + return None + + def request_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + """ + Raise a [textDocument/diagnostic](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_diagnostic) request to the Language Server + to find diagnostics for the given file. Wait for the response and return the result. + + :param relative_file_path: The relative path of the file to retrieve diagnostics for + :param start_line: the first 0-based line to include in the result. + :param end_line: the last 0-based line to include in the result. `-1` means no upper bound. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + Diagnostics with lower-or-equal numeric severity are returned. + + :return: A list of diagnostics for the file + """ + uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + published_uri = self._get_published_diagnostics_uri(uri) + diagnostics_before_request = self._get_published_diagnostics_generation(published_uri) + ret: list[ls_types.Diagnostic] | None = None + pull_diagnostics_failed = False + + with self.open_file(relative_file_path): + response: Any = None + # only send pull diagnostics when the server actually supports it; some servers + # (e.g. Julia's LanguageServer.jl) hard-error and crash the process on unknown methods + if self._supports_pull_diagnostics(): + try: + response = self.server.send.text_document_diagnostic( + { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict + LSPConstants.TEXT_DOCUMENT: { + LSPConstants.URI: uri, + } + } + ) + except SolidLSPException as ex: + log.debug("Falling back to published diagnostics for %s due to pull-diagnostics error: %s", relative_file_path, ex) + response = None + pull_diagnostics_failed = True + + if response is not None: + assert isinstance(response, dict), ( + f"Unexpected response from Language Server (expected list, got {type(response)}): {response}" + ) + ret = [] + for item in response["items"]: # type: ignore + new_item: ls_types.Diagnostic = { + "uri": uri, + "severity": item["severity"], + "message": item["message"], + "range": item["range"], + "code": item.get("code"), # type: ignore + } + if "source" in item: + new_item["source"] = item["source"] + ret.append(ls_types.Diagnostic(**new_item)) + + if not ret: + published_diagnostics = self._wait_for_relevant_published_diagnostics( + uri=published_uri, + after_generation=diagnostics_before_request, + timeout=self._get_published_diagnostics_wait_timeout(pull_diagnostics_failed), + ) + if published_diagnostics is not None: + ret = published_diagnostics + + if ret is None: + return [] + + return self._filter_diagnostics(ret, start_line, end_line, min_severity) + + def _get_wait_time_for_cross_file_referencing(self) -> float: + """Meant to be overridden by subclasses for LS that don't have a reliable "finished initializing" signal. + + LS may return incomplete results on calls to `request_references` (only references found in the same file), + if the LS is not fully initialized yet. + """ + return 2 + + # --- Cross-workspace / additional workspace folder support --- + + @staticmethod + def _path_contains_dots(relative_file_path: str) -> bool: + """Check if a relative path traverses outside the workspace root via '..' components.""" + return ".." in PurePath(relative_file_path).parts + + @dataclass + class PathWorkspaceStatus: + """ + Represents the status of a path with respect to the language server's workspace folders. + """ + + ls: "SolidLanguageServer" + resolve_abs_path: Path + is_in_workspace_folder: bool + workspace_root: str | None = None + """ + the workspace root the path is within/relative to, if any + """ + + @classmethod + def from_abs_resolved_path(cls, path: Path, ls: "SolidLanguageServer") -> "SolidLanguageServer.PathWorkspaceStatus": + """ + :param path: an absolute, resolved Path instance + :param ls: the language server instance + """ + for workspace in ls._abs_workspace_folders_all: + if path.is_relative_to(workspace): + return SolidLanguageServer.PathWorkspaceStatus( + ls=ls, is_in_workspace_folder=True, workspace_root=workspace, resolve_abs_path=path + ) + return SolidLanguageServer.PathWorkspaceStatus(ls=ls, is_in_workspace_folder=False, resolve_abs_path=path) + + @classmethod + def from_relative_path(cls, relative_path: str, ls: "SolidLanguageServer") -> "SolidLanguageServer.PathWorkspaceStatus": + """ + :param relative_path: a relative path from the repository root + :param ls: the language server instance + """ + return cls.from_abs_resolved_path(pathlib.Path(ls.repository_root_path, relative_path).resolve(), ls) + + def check_within_workspace_or_raise(self): + if not self.is_in_workspace_folder: + raise ValueError( + f"Path {self.resolve_abs_path} is outside of configured workspaces. " + f"Configured workspaces: {self.ls._abs_workspace_folders_all}." + ) + + def _resolve_file_uri(self, relative_file_path: str) -> str: + """Construct a canonical file URI from a relative path. + + For cross-workspace paths containing '..', the path is resolved to + produce a clean URI without '..' segments. + """ + p = pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)) + if self._path_contains_dots(relative_file_path): + p = p.resolve() + self.PathWorkspaceStatus.from_abs_resolved_path(p, self).check_within_workspace_or_raise() + return p.as_uri() + + def _activate_additional_workspaces(self) -> None: + """Open a representative file from each additional workspace folder to + trigger project loading in the language server. + + Many language servers only load projects on-demand when files are opened. + This method finds a source file in each additional workspace folder via + :meth:`_find_representative_source_file` and opens it, keeping it open + for the lifetime of this language server instance. + + Subclasses must override :meth:`_find_representative_source_file` to + enable this feature; the default implementation raises ``NotImplementedError``. + """ + opened_count = 0 + for additional_workspace in self._abs_workspace_folders_additional: + source_file = self._find_representative_source_file(additional_workspace) + if source_file is None: + log.warning("No source file found in additional workspace folder: %s", additional_workspace) + continue + + rel_path = os.path.relpath(source_file, self.repository_root_path) + log.info("Opening %s to trigger project loading for %s", rel_path, os.path.basename(additional_workspace)) + + abs_path = pathlib.Path(source_file).resolve() + uri = abs_path.as_uri() + language_id = self._get_language_id_for_file(rel_path) + + self._signal_expect_indexing() + fb = LSPFileBuffer( + abs_path=abs_path, + uri=uri, + encoding=self._encoding, + version=0, + language_id=language_id, + ref_count=1, + language_server=self, + open_in_ls=True, + ) + self.open_file_buffers[uri] = fb + opened_count += 1 + + if opened_count > 0: + log.info("Waiting for %d additional workspace(s) to index...", opened_count) + self._wait_for_additional_workspace_indexing() + + def _find_representative_source_file(self, directory: str) -> str | None: + """Find a source file suitable for triggering project loading in the given directory. + + Must be overridden by subclasses that support ``additional_workspace_folders``. + Return ``None`` if no suitable file is found. + + :param directory: Absolute path to the workspace folder. + :return: Absolute path to a source file, or None. + """ + raise NotImplementedError( + f"{type(self).__name__} does not yet support additional_workspace_folders. " + f"Override _find_representative_source_file() to enable this feature." + ) + + def _signal_expect_indexing(self) -> None: + """Signal that new files are about to be opened and async indexing should be awaited. + + Override in subclasses that track indexing progress (e.g. via $/progress). + Default implementation is a no-op. + """ + + def _wait_for_additional_workspace_indexing(self) -> None: + """Wait for additional workspace indexing to complete. + + Override in subclasses that track indexing progress. + Default implementation is a no-op. + """ + + def set_request_timeout(self, timeout: float | None) -> None: + """ + :param timeout: the timeout, in seconds, for requests to the language server. + """ + self.server.set_request_timeout(timeout) + + def get_ignore_spec(self) -> pathspec.PathSpec: + """ + Returns the pathspec matcher for the paths that were configured to be ignored through + the language server configuration. + + This is a subset of the full language-specific ignore spec that determines + which files are relevant for the language server. + + This matcher is useful for operations outside of the language server, + such as when searching for relevant non-language files in the project. + """ + return self._ignore_spec + + def get_source_fn_matcher(self) -> FilenameMatcher: + """ + :return: the source filename matcher for this language server, which must positively match all files that + are understood by this language server or are discovered as containing sources indirectly, e.g. via references + """ + # By default, use the matcher of the language + return self.language.get_source_fn_matcher() + + def is_ignored_path(self, relative_path: str, ignore_unsupported_files: bool = True) -> bool: + """ + Determine if a path should be ignored based on file type + and ignore patterns. + + Missing paths are classified without raising, since language servers may report + locations of generated files that are not present on disk (e.g. compiled classes + under build output). A missing path is treated as a file if its name has a suffix + and as a directory otherwise; directory-only ignore patterns (e.g. ``build/``) + match only existing directories. + + :param relative_path: Relative path to check + :param ignore_unsupported_files: whether files that are not supported source files should be ignored + + :return: True if the path should be ignored, False otherwise + """ + abs_path = os.path.join(self.repository_root_path, relative_path) + rel_path = Path(relative_path) + + # determine whether the path is a file: from the filesystem if the path exists, lexically otherwise + exists = os.path.exists(abs_path) + if not exists: + log.debug("Path %s does not exist; the ignore check is performed on the path itself", abs_path) + is_file = os.path.isfile(abs_path) if exists else bool(rel_path.suffix) + + # check each directory component against always-ignored dirnames + dir_parts = rel_path.parts + if is_file: + dir_parts = dir_parts[:-1] + for part in dir_parts: + if not part: # Skip empty parts (e.g., from leading '/') + continue + if self.is_ignored_dirname(part): + return True + + # apply the unsupported-extension rule to files + if is_file and ignore_unsupported_files: + fn_matcher = self.get_source_fn_matcher() + if not fn_matcher.is_relevant_filename(abs_path): + return True + + return match_path(relative_path, self.get_ignore_spec(), root_path=self.repository_root_path) + + @contextmanager + def start_server_context(self) -> Iterator["SolidLanguageServer"]: + self.start() + try: + yield self + finally: + self.stop() + + @abstractmethod + def _start_server(self) -> None: + """ + Starts the low-level language server interface (i.e. self.server). + + This method must ultimately call `self.server.start()` and may perform additional setup before or after starting the server, + such as waiting for initialization to complete or activating additional workspaces. + """ + + def _get_language_id_for_file(self, relative_file_path: str) -> str: + """ + Determines the language identifier to pass to the language server for the given file, + particularly `textDocument/didOpen` requests. + + Override this method in subclasses to return file-specific language identifiers. + The default implementation returns the main identifier passed at construction (self.language_id). + """ + return self.language_id + + @contextmanager + def open_file(self, relative_file_path: str, open_in_ls: bool = True) -> Iterator[LSPFileBuffer]: + """ + Open a file in the Language Server. This is required before making any requests to the Language Server. + + :param relative_file_path: The relative path of the file to open. + :param open_in_ls: whether to open the file in the language server, sending the didOpen notification. + Set this to False to read the local file buffer without notifying the LS; the file can + be opened in the LS later by calling the `ensure_open_in_ls` method on the returned LSPFileBuffer. + """ + if not self.server_started: + log.error("open_file called before Language Server started") + raise SolidLSPException("Language Server not started") + + absolute_file_path = Path(self.repository_root_path, relative_file_path) + if self._path_contains_dots(relative_file_path): + absolute_file_path = absolute_file_path.resolve() + uri = absolute_file_path.as_uri() + + if uri in self.open_file_buffers: + fb = self.open_file_buffers[uri] + assert fb.uri == uri + assert fb.ref_count >= 1 + + fb.ref_count += 1 + if open_in_ls: + fb.ensure_open_in_ls() + else: + version = 0 + language_id = self._get_language_id_for_file(relative_file_path) + fb = LSPFileBuffer( + abs_path=absolute_file_path, + uri=uri, + encoding=self._encoding, + version=version, + language_id=language_id, + ref_count=1, + language_server=self, + open_in_ls=open_in_ls, + ) + self.open_file_buffers[uri] = fb + + try: + yield fb + finally: + fb.ref_count -= 1 + if fb.ref_count == 0: + fb.close() + del self.open_file_buffers[uri] + + @contextmanager + def _open_file_context( + self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None, open_in_ls: bool = True + ) -> Iterator[LSPFileBuffer]: + """ + Internal context manager to open a file, optionally reusing an existing file buffer. + + :param relative_file_path: the relative path of the file to open. + :param file_buffer: an optional existing file buffer to reuse. + :param open_in_ls: whether to open the file in the language server, sending the didOpen notification. + Set this to False to read the local file buffer without notifying the LS; the file can + be opened in the LS later by calling the `ensure_open_in_ls` method on the returned LSPFileBuffer. + """ + if file_buffer is not None: + expected_uri = self._resolve_file_uri(relative_file_path) + assert file_buffer.uri == expected_uri, f"Inconsistency between provided {file_buffer.uri=} and {expected_uri=}" + if open_in_ls: + file_buffer.ensure_open_in_ls() + yield file_buffer + else: + with self.open_file(relative_file_path, open_in_ls=open_in_ls) as fb: + yield fb + + def insert_text_at_position(self, relative_file_path: str, line: int, column: int, text_to_be_inserted: str) -> ls_types.Position: + """ + Insert text at the given line and column in the given file and return + the updated cursor position after inserting the text. + + :param relative_file_path: The relative path of the file to open. + :param line: The line number at which text should be inserted. + :param column: The column number at which text should be inserted. + :param text_to_be_inserted: The text to insert. + """ + if not self.server_started: + log.error("insert_text_at_position called before Language Server started") + raise SolidLSPException("Language Server not started") + + uri = self._resolve_file_uri(relative_file_path) + + # Ensure the file is open + assert uri in self.open_file_buffers + + file_buffer = self.open_file_buffers[uri] + file_buffer.version += 1 + + new_contents, new_l, new_c = TextUtils.insert_text_at_position(file_buffer.contents, line, column, text_to_be_inserted) + file_buffer.contents = new_contents + self.server.notify.did_change_text_document( + { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict + LSPConstants.TEXT_DOCUMENT: { + LSPConstants.VERSION: file_buffer.version, + LSPConstants.URI: file_buffer.uri, + }, + LSPConstants.CONTENT_CHANGES: [ + { + LSPConstants.RANGE: { + "start": {"line": line, "character": column}, + "end": {"line": line, "character": column}, + }, + "text": text_to_be_inserted, + } + ], + } + ) + return ls_types.Position(line=new_l, character=new_c) + + def delete_text_between_positions( + self, + relative_file_path: str, + start: ls_types.Position, + end: ls_types.Position, + ) -> str: + """ + Delete text between the given start and end positions in the given file and return the deleted text. + """ + if not self.server_started: + log.error("delete_text_between_positions called before Language Server started") + raise SolidLSPException("Language Server not started") + + uri = self._resolve_file_uri(relative_file_path) + + # Ensure the file is open + assert uri in self.open_file_buffers + + file_buffer = self.open_file_buffers[uri] + file_buffer.version += 1 + new_contents, deleted_text = TextUtils.delete_text_between_positions( + file_buffer.contents, start_line=start["line"], start_col=start["character"], end_line=end["line"], end_col=end["character"] + ) + file_buffer.contents = new_contents + self.server.notify.did_change_text_document( + { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict + LSPConstants.TEXT_DOCUMENT: { + LSPConstants.VERSION: file_buffer.version, + LSPConstants.URI: file_buffer.uri, + }, + LSPConstants.CONTENT_CHANGES: [{LSPConstants.RANGE: {"start": start, "end": end}, "text": ""}], + } + ) + return deleted_text + + def _send_definition_request(self, definition_params: DefinitionParams) -> Definition | list[LocationLink] | None: + return self.server.send.definition(definition_params) + + class SymbolLocationRequest(ABC): + def __init__( + self, + language_server: "SolidLanguageServer", + relative_file_path: str, + line: int, + column: int, + *, + request_name: str, + ) -> None: + self.language_server = language_server + self.relative_file_path = relative_file_path + self.line = line + self.column = column + self.request_name = request_name + self.skip_ignored_paths = True + + def execute(self) -> list[ls_types.Location]: + self._ensure_server_started() + + t0 = perf_counter() if _debug_enabled else None + # arm indexing tracking before didOpen so that the subsequent wait can + # observe the indexing progress triggered by opening the file + self.language_server._pre_open_for_cross_file_references() + with self.language_server.open_file(self.relative_file_path): + self.language_server._wait_for_cross_file_references_if_needed() + try: + response = self.send_request() + except Exception as e: + mapped_exception = self.map_exception(e) + if mapped_exception is not None: + raise mapped_exception from e + raise + + result = self.normalize_response(response) + if t0 is not None: + self.log_perf_result(t0, result) + return result + + def _ensure_server_started(self) -> None: + if not self.language_server.server_started: + log.error("%s called before language server started", self.request_name) + raise SolidLSPException("Language Server not started") + + @abstractmethod + def send_request(self) -> object | None: + pass + + def map_exception(self, error: Exception) -> Exception | None: + if isinstance(error, LSPError) and getattr(error, "code", None) == -32603: + return RuntimeError( + f"LSP internal error (-32603) when requesting {self.request_name} for " + f"{self.relative_file_path}:{self.line}:{self.column}. " + "This often occurs when requesting a symbol in a way the language server cannot resolve." + ) + return None + + @abstractmethod + def normalize_response(self, response: object | None) -> list[ls_types.Location]: + pass + + def convert_location_item(self, item: dict[str, object], *, allow_location_links: bool = False) -> ls_types.Location | None: + if LSPConstants.URI in item and LSPConstants.RANGE in item: + uri = cast(str, item[LSPConstants.URI]) + range_d = cast(ls_types.Range, item[LSPConstants.RANGE]) + elif ( + allow_location_links + and LSPConstants.TARGET_URI in item + and LSPConstants.TARGET_RANGE in item + and LSPConstants.TARGET_SELECTION_RANGE in item + ): + uri = cast(str, item[LSPConstants.TARGET_URI]) + range_d = cast(ls_types.Range, item[LSPConstants.TARGET_SELECTION_RANGE]) + else: + raise AssertionError(f"Unexpected response from Language Server: {item}") + + abs_path = PathUtils.uri_to_path(uri) + rel_path_str = PathUtils.get_relative_path(abs_path, self.language_server.repository_root_path) + + if rel_path_str is None: + log.warning( + "Found a %s in a path outside the repository, probably the LS is parsing things in installed packages or in the standardlib! " + "Path: %s. This is a bug but we currently simply skip these locations.", + self.request_name, + abs_path, + ) + return None + + # skip locations of generated files that are not present on disk + # (language servers may report e.g. compiled classes under build output) + if not os.path.exists(abs_path): + log.info("%s found symbol at non-existent path: %s", self.request_name, abs_path) + return None + + if self.skip_ignored_paths and self.language_server.is_ignored_path(rel_path_str): + log.info("%s found symbol in ignored path: %s", self.request_name, rel_path_str) + return None + + return ls_types.Location(uri=uri, range=range_d, absolutePath=str(abs_path), relativePath=rel_path_str) + + def log_perf_result(self, t0: float, result: list[ls_types.Location]) -> None: + return + + class DefinitionLocationRequest(SymbolLocationRequest): + def __init__( + self, + language_server: "SolidLanguageServer", + relative_file_path: str, + line: int, + column: int, + *, + request_name: str = "request_definition", + ) -> None: + super().__init__( + language_server, + relative_file_path, + line, + column, + request_name=request_name, + ) + + def send_request(self) -> object | None: + return self.language_server._send_definition_request( + self.language_server._create_text_document_position_params(self.relative_file_path, self.line, self.column) + ) + + def normalize_response(self, response: object | None) -> list[ls_types.Location]: + if response is None: + log.warning( + "Language server returned None for %s request at %s:%s:%s", + self.request_name, + self.relative_file_path, + self.line, + self.column, + ) + return [] + + ret: list[ls_types.Location] = [] + if isinstance(response, list): + for item in response: + assert isinstance(item, dict), f"Unexpected response from Language Server (expected dict, got {type(item)}): {item}" + if location := self.convert_location_item(cast(dict[str, object], item), allow_location_links=True): + ret.append(location) + return ret + + if isinstance(response, dict): + if location := self.convert_location_item(cast(dict[str, object], response), allow_location_links=True): + ret.append(location) + return ret + + assert False, f"Unexpected response from Language Server: {response}" + + def request_definition(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: + """ + Raise a [textDocument/definition](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition) request to the Language Server + for the symbol at the given line and column in the given file. Wait for the response and return the result. + + :param relative_file_path: The relative path of the file that has the symbol for which definition should be looked up + :param line: The line number of the symbol + :param column: The column number of the symbol + + :return: the list of locations where the symbol is defined + """ + request = self.DefinitionLocationRequest(self, relative_file_path, line, column) + return request.execute() + + def _send_implementation_request(self, implementation_params: ImplementationParams) -> Definition | list[LocationLink] | None: + return self.server.send.implementation(implementation_params) + + def _create_text_document_position_params(self, relative_file_path: str, line: int, column: int) -> DefinitionParams: + return cast( + DefinitionParams, + { + LSPConstants.TEXT_DOCUMENT: {LSPConstants.URI: self._resolve_file_uri(relative_file_path)}, + LSPConstants.POSITION: { + LSPConstants.LINE: line, + LSPConstants.CHARACTER: column, + }, + }, + ) + + def _pre_open_for_cross_file_references(self) -> None: + """Called just before open_file() for requests that may need cross-file indexing. + + Override to pre-arm async indexing tracking before sending didOpen (e.g. clear + a threading.Event so a subsequent wait for indexing blocks correctly). + The base implementation is a no-op. + """ + + def _wait_for_cross_file_references_if_needed(self) -> None: + if not self._has_waited_for_cross_file_references: + # Some LS require waiting for a while before they can return accurate cross-file results. + # The waiting has to happen after at least one file was opened in the LS. + sleep(self._get_wait_time_for_cross_file_referencing()) + self._has_waited_for_cross_file_references = True + + class ImplementationLocationRequest(DefinitionLocationRequest): + def __init__(self, language_server: "SolidLanguageServer", relative_file_path: str, line: int, column: int) -> None: + super().__init__( + language_server, + relative_file_path, + line, + column, + request_name="request_implementation", + ) + + def send_request(self) -> object | None: + return self.language_server._send_implementation_request( + self.language_server._create_text_document_position_params(self.relative_file_path, self.line, self.column), + ) + + def request_implementation(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: + """ + Raise a [textDocument/implementation](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_implementation) + request to the Language Server for the symbol at the given line and column in the given file. + + :param relative_file_path: The relative path of the file that has the symbol for which implementations should be looked up + :param line: The line number of the symbol + :param column: The column number of the symbol + :return: the list of locations where the symbol is implemented + """ + request = self.ImplementationLocationRequest(self, relative_file_path, line, column) + return request.execute() + + # Some LS cause problems with this, so the call is isolated from the rest to allow overriding in subclasses + def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None: + return self.server.send.references( + { + "textDocument": {"uri": self._resolve_file_uri(relative_file_path)}, + "position": {"line": line, "character": column}, + "context": {"includeDeclaration": False}, + } + ) + + class ReferencesLocationRequest(SymbolLocationRequest): + def __init__(self, language_server: "SolidLanguageServer", relative_file_path: str, line: int, column: int) -> None: + super().__init__( + language_server, + relative_file_path, + line, + column, + request_name="request_references", + ) + + def send_request(self) -> object | None: + return self.language_server._send_references_request(self.relative_file_path, line=self.line, column=self.column) + + def normalize_response(self, response: object | None) -> list[ls_types.Location]: + if response is None: + return [] + + assert isinstance(response, list), f"Unexpected response from Language Server (expected list, got {type(response)}): {response}" + ret: list[ls_types.Location] = [] + for item in response: + assert isinstance(item, dict), f"Unexpected response from Language Server (expected dict, got {type(item)}): {item}" + if location := self.convert_location_item(cast(dict[str, object], item)): + ret.append(location) + return ret + + def log_perf_result(self, t0: float, result: list[ls_types.Location]) -> None: + elapsed_ms = (perf_counter() - t0) * 1000 + if not result: + log.debug("perf: request_references path=%s elapsed_ms=%.2f count=0", self.relative_file_path, elapsed_ms) + return + + unique_files = len({r["relativePath"] for r in result}) + log.debug( + "perf: request_references path=%s elapsed_ms=%.2f count=%d unique_files=%d", + self.relative_file_path, + elapsed_ms, + len(result), + unique_files, + ) + + def request_references(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: + """ + Raise a [textDocument/references](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references) request to the Language Server + to find references to the symbol at the given line and column in the given file. Wait for the response and return the result. + Filters out references located in ignored directories. + + :param relative_file_path: The relative path of the file that has the symbol for which references should be looked up + :param line: The line number of the symbol + :param column: The column number of the symbol + + :return: A list of locations where the symbol is referenced (excluding ignored directories) + """ + request = self.ReferencesLocationRequest(self, relative_file_path, line, column) + return request.execute() + + def retrieve_full_file_content(self, file_path: str) -> str: + """ + Retrieve the full content of the given file. + """ + if os.path.isabs(file_path): + file_path = os.path.relpath(file_path, self.repository_root_path) + with self.open_file(file_path) as file_data: + return file_data.contents + + def retrieve_content_around_line( + self, relative_file_path: str, line: int, context_lines_before: int = 0, context_lines_after: int = 0 + ) -> MatchedConsecutiveLines: + """ + Retrieve the content of the given file around the given line. + + :param relative_file_path: The relative path of the file to retrieve the content from + :param line: The line number to retrieve the content around + :param context_lines_before: The number of lines to retrieve before the given line + :param context_lines_after: The number of lines to retrieve after the given line + + :return MatchedConsecutiveLines: A container with the desired lines. + """ + with self.open_file(relative_file_path) as file_data: + file_contents = file_data.contents + return MatchedConsecutiveLines.from_file_contents( + file_contents, + line=line, + context_lines_before=context_lines_before, + context_lines_after=context_lines_after, + source_file_path=relative_file_path, + ) + + def request_completions( + self, relative_file_path: str, line: int, column: int, allow_incomplete: bool = False + ) -> list[ls_types.CompletionItem]: + """ + Raise a [textDocument/completion](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion) request to the Language Server + to find completions at the given line and column in the given file. Wait for the response and return the result. + + :param relative_file_path: The relative path of the file that has the symbol for which completions should be looked up + :param line: The line number of the symbol + :param column: The column number of the symbol + + :return: A list of completions + """ + with self.open_file(relative_file_path): + open_file_buffer = self.open_file_buffers[self._resolve_file_uri(relative_file_path)] + completion_params: LSPTypes.CompletionParams = { + "position": {"line": line, "character": column}, + "textDocument": {"uri": open_file_buffer.uri}, + "context": {"triggerKind": LSPTypes.CompletionTriggerKind.Invoked}, + } + response: list[LSPTypes.CompletionItem] | LSPTypes.CompletionList | None = None + + for _ in range(30): + response = self.server.send.completion(completion_params) + if isinstance(response, list): + response = {"items": response, "isIncomplete": False} + if response is None or not response["isIncomplete"]: + break + + # TODO: Understand how to appropriately handle `isIncomplete` + if response is None or (response["isIncomplete"] and not allow_incomplete): + return [] + + if "items" in response: + response = response["items"] + + response = cast(list[LSPTypes.CompletionItem], response) + + # TODO: Handle the case when the completion is a keyword + items = [item for item in response if item["kind"] != LSPTypes.CompletionItemKind.Keyword] + + completions_list: list[ls_types.CompletionItem] = [] + + for item in items: + assert "insertText" in item or "textEdit" in item + assert "kind" in item + completion_item = {} + if "detail" in item: + completion_item["detail"] = item["detail"] + + if "label" in item: + completion_item["completionText"] = item["label"] + completion_item["kind"] = item["kind"] + elif "insertText" in item: + completion_item["completionText"] = item["insertText"] + completion_item["kind"] = item["kind"] + elif "textEdit" in item and "newText" in item["textEdit"]: + completion_item["completionText"] = item["textEdit"]["newText"] + completion_item["kind"] = item["kind"] + elif "textEdit" in item and "range" in item["textEdit"]: + new_dot_lineno, new_dot_colno = ( + completion_params["position"]["line"], + completion_params["position"]["character"], + ) + assert all( + ( + item["textEdit"]["range"]["start"]["line"] == new_dot_lineno, + item["textEdit"]["range"]["start"]["character"] == new_dot_colno, + item["textEdit"]["range"]["start"]["line"] == item["textEdit"]["range"]["end"]["line"], + item["textEdit"]["range"]["start"]["character"] == item["textEdit"]["range"]["end"]["character"], + ) + ) + + completion_item["completionText"] = item["textEdit"]["newText"] + completion_item["kind"] = item["kind"] + elif "textEdit" in item and "insert" in item["textEdit"]: + assert False + else: + assert False + + completion_item = ls_types.CompletionItem(**completion_item) # type: ignore + completions_list.append(completion_item) + + return [json.loads(json_repr) for json_repr in set(json.dumps(item, sort_keys=True) for item in completions_list)] + + def _request_document_symbols( + self, relative_file_path: str, file_data: LSPFileBuffer | None + ) -> list[SymbolInformation] | list[DocumentSymbol] | None: + """ + Sends a [documentSymbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol) + request to the language server to find symbols in the given file - or returns a cached result if available. + The returned symbols are considered "raw document symbols" (in contrast to processed symbols returned by `request_document_symbols`). + + NOTE: This method can be overridden in subclasses to post-process the raw results. + When doing so after the initial implementation, be sure to update the init parameter `cache_version_raw_document_symbols` + to a different version (add 1) to ensure that all caches are invalidated appropriately. + IMPORTANT: Since rebuilding the raw document symbol cache from the language server results + is potentially expensive, prefer overriding the `request_document_symbols` method + if the post-processing can also be done on the processed/high-level symbols. + + :param relative_file_path: the relative path of the file that has the symbols. + :param file_data: the file data buffer, if already opened. If None, the file will be opened in this method. + :return: the list of root symbols in the file. + """ + + def get_cached_raw_document_symbols(cache_key: str, fd: LSPFileBuffer) -> list[SymbolInformation] | list[DocumentSymbol] | None: + file_hash_and_result = self._raw_document_symbols_cache.get(cache_key) + if file_hash_and_result is None: + log.debug("No cache hit for raw document symbols in %s", relative_file_path) + log.debug("perf: raw_document_symbols_cache MISS path=%s", relative_file_path) + return None + + file_hash, result = file_hash_and_result + if file_hash == fd.content_hash: + log.debug("Returning cached raw document symbols for %s", relative_file_path) + log.debug("perf: raw_document_symbols_cache HIT path=%s", relative_file_path) + return result + + log.debug("Document content for %s has changed (raw symbol cache is not up-to-date)", relative_file_path) + log.debug("perf: raw_document_symbols_cache STALE path=%s", relative_file_path) + return None + + def get_raw_document_symbols(fd: LSPFileBuffer) -> list[SymbolInformation] | list[DocumentSymbol] | None: + # check for cached result + cache_key = relative_file_path + response = get_cached_raw_document_symbols(cache_key, fd) + if response is not None: + return response + + # no cached result, query language server + log.debug(f"Requesting document symbols for {relative_file_path} from the Language Server") + response = self.server.send.document_symbol({"textDocument": {"uri": self._resolve_file_uri(relative_file_path)}}) + + # Only cache non-empty results. An empty or None response can occur when the language server + # has not yet finished indexing or building the project (e.g. Lean 4 before `lake build`), + # and caching it would permanently serve stale data even after the project is ready. + if response: + self._raw_document_symbols_cache[cache_key] = (fd.content_hash, response) + self._raw_document_symbols_cache_is_modified = True + + return response + + with self._open_file_context(relative_file_path, file_buffer=file_data) as fd: + return get_raw_document_symbols(fd) + + def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: + """ + Normalizes the name of the given symbol, e.g. by removing parameter lists from method symbols. + + Override this method in subclasses to implement language-specific normalization logic. + NOTE: When changing the override of this method after the initial LS implementation, + be sure to also override `_document_symbols_cache_fingerprint` in order to ensure that + the caches are invalidated appropriately. + + :param symbol: the symbol + :param relative_file_path: the relative path of the file the symbol is located in + :return: the normalized name of the symbol + """ + # the default implementation does not change the name + return symbol["name"] + + def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: + """ + Retrieves the collection of symbols in the given file. + + NOTE: This method can be overridden in subclasses to post-process the results. + When doing so after the initial LS implementation, be sure to also override `_document_symbols_cache_fingerprint` + to ensure that the caches are invalidated appropriately. + DO NOT override this method to modify symbol names; override `_normalize_symbol_name` instead. + + :param relative_file_path: The relative path of the file that has the symbols + :param file_buffer: an optional file buffer if the file is already opened. + :return: the collection of symbols in the file. + All contained symbols will have a location, children, and a parent attribute, + where the parent attribute is None for root symbols. + Note that this is slightly different from the call to request_full_symbol_tree, + where the parent attribute will be the file symbol which in turn may have a package symbol as parent. + If you need a symbol tree that contains file symbols as well, you should use `request_full_symbol_tree` instead. + """ + with self._open_file_context(relative_file_path, file_buffer, open_in_ls=False) as file_data: + # check if the desired result is cached + cache_key = relative_file_path + file_hash_and_result = self._document_symbols_cache.get(cache_key) + if file_hash_and_result is None: + log.debug("No cache hit for document symbols in %s", relative_file_path) + log.debug("perf: document_symbols_cache MISS path=%s", relative_file_path) + else: + file_hash, document_symbols = file_hash_and_result + if file_hash == file_data.content_hash: + log.debug("Returning cached document symbols for %s", relative_file_path) + log.debug("perf: document_symbols_cache HIT path=%s", relative_file_path) + return document_symbols + + log.debug("Cached document symbol content for %s has changed", relative_file_path) + log.debug("perf: document_symbols_cache STALE path=%s", relative_file_path) + + # no cached result: request the root symbols from the language server + root_symbols = self._request_document_symbols(relative_file_path, file_data) + + if root_symbols is None: + log.warning( + f"Received None response from the Language Server for document symbols in {relative_file_path}. " + f"This means the language server can't understand this file (possibly due to syntax errors). It may also be due to a bug or misconfiguration of the LS. " + f"Returning empty list", + ) + return DocumentSymbols([]) + + assert isinstance(root_symbols, list), f"Unexpected response from Language Server: {root_symbols}" + log.debug("Received %d root symbols for %s from the language server", len(root_symbols), relative_file_path) + + body_factory = SymbolBodyFactory(file_data) + + def convert_to_unified_symbol(original_symbol_dict: RawDocumentSymbol) -> ls_types.UnifiedSymbolInformation: + """ + Converts the given symbol dictionary to the unified representation, ensuring + that all required fields are present (except 'children' which is handled separately). + + :param original_symbol_dict: the item to augment + :return: the augmented item (new object) + """ + # noinspection PyInvalidCast + item = cast(ls_types.UnifiedSymbolInformation, dict(original_symbol_dict)) + absolute_path = os.path.join(self.repository_root_path, relative_file_path) + + # handle missing location and path entries + if "location" not in item: + uri = pathlib.Path(absolute_path).as_uri() + assert "range" in item + tree_location = ls_types.Location( + uri=uri, + range=item["range"], + absolutePath=absolute_path, + relativePath=relative_file_path, + ) + item["location"] = tree_location + location = item["location"] + if "absolutePath" not in location: + location["absolutePath"] = absolute_path + if "relativePath" not in location: + location["relativePath"] = relative_file_path + + item["body"] = self.create_symbol_body(item, factory=body_factory) + + # handle missing selectionRange + if "selectionRange" not in item: + if "range" in item: + item["selectionRange"] = item["range"] + else: + item["selectionRange"] = item["location"]["range"] + + return item + + def convert_symbols_with_common_parent( + symbols: list[DocumentSymbol] | list[SymbolInformation], + parent: ls_types.UnifiedSymbolInformation | None, + ) -> list[ls_types.UnifiedSymbolInformation]: + """ + Converts the given symbols into UnifiedSymbolInformation with proper parent-child relationships, + adding overload indices for symbols with the same name under the same parent. + """ + # apply name normalization and count occurrences of each symbol name + total_name_counts: dict[str, int] = defaultdict(lambda: 0) + for symbol in symbols: + name = self._normalize_symbol_name(symbol, relative_file_path=relative_file_path) + symbol["name"] = name + total_name_counts[name] += 1 + + # convert symbols to the unified representation and + # * add overload indices where necessary + # * ensure that the "parent" field is set correctly + name_counts: dict[str, int] = defaultdict(lambda: 0) + unified_symbols = [] + for symbol in symbols: + usymbol = convert_to_unified_symbol(symbol) + if total_name_counts[usymbol["name"]] > 1: + usymbol["overload_idx"] = name_counts[usymbol["name"]] + name_counts[usymbol["name"]] += 1 + usymbol["parent"] = parent + if "children" in usymbol: + usymbol["children"] = convert_symbols_with_common_parent(usymbol["children"], usymbol) # type: ignore + else: + usymbol["children"] = [] + unified_symbols.append(usymbol) + return unified_symbols + + unified_root_symbols = convert_symbols_with_common_parent(root_symbols, None) + document_symbols = DocumentSymbols(unified_root_symbols) + + # update cache + log.debug("Updating cached document symbols for %s", relative_file_path) + self._document_symbols_cache[cache_key] = (file_data.content_hash, document_symbols) + self._document_symbols_cache_is_modified = True + + return document_symbols + + def request_full_symbol_tree(self, within_relative_path: str | None = None) -> list[ls_types.UnifiedSymbolInformation]: + """ + Will go through all files in the project or within a relative path and build a tree of symbols. + Note: this may be slow the first time it is called, especially if `within_relative_path` is not used to restrict the search. + + For each file, a symbol of kind File (2) will be created. For directories, a symbol of kind Package (4) will be created. + All symbols will have a children attribute, thereby representing the tree structure of all symbols in the project + that are within the repository. + All symbols except the root packages will have a parent attribute. + Will ignore directories starting with '.', language-specific defaults + and user-configured directories (e.g. from .gitignore). + + :param within_relative_path: pass a relative path to only consider symbols within this path. + If a file is passed, only the symbols within this file will be considered. + If a directory is passed, all files within this directory will be considered. + :return: A list of root symbols representing the top-level packages/modules in the project. + """ + + # Helper function to recursively process directories + def process_directory(abs_dir_path: str) -> list[ls_types.UnifiedSymbolInformation]: + abs_dir_path = os.path.realpath(abs_dir_path) + + rel_dir_path: str | None + try: + rel_dir_path = str(Path(abs_dir_path).relative_to(self.repository_root_path)) + except ValueError: # not relative to repository root + rel_dir_path = None + if rel_dir_path and self.is_ignored_path(rel_dir_path): + log.debug("Skipping directory: %s (because it should be ignored)", rel_dir_path) + return [] + + result = [] + try: + contained_dir_or_file_names = os.listdir(abs_dir_path) + except OSError: + return [] + + # Create package symbol for directory + package_symbol = ls_types.UnifiedSymbolInformation( + name=os.path.basename(abs_dir_path), + kind=ls_types.SymbolKind.Package, + location=ls_types.Location( + uri=str(pathlib.Path(abs_dir_path).as_uri()), + range={"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, + absolutePath=str(abs_dir_path), + relativePath=str(Path(abs_dir_path).resolve().relative_to(self.repository_root_path)), + ), + children=[], + ) + result.append(package_symbol) + + for contained_dir_or_file_name in contained_dir_or_file_names: + contained_dir_or_file_abs_path = os.path.join(abs_dir_path, contained_dir_or_file_name) + + # obtain relative path + try: + contained_dir_or_file_rel_path = str( + Path(contained_dir_or_file_abs_path).resolve().relative_to(self.repository_root_path) + ) + except ValueError as e: + # Typically happens when the path is not under the repository root (e.g., symlink pointing outside) + log.warning( + "Skipping path %s; likely outside of the repository root %s [cause: %s]", + contained_dir_or_file_abs_path, + self.repository_root_path, + e, + ) + continue + + if self.is_ignored_path(contained_dir_or_file_rel_path): + log.debug("Skipping item: %s (because it should be ignored)", contained_dir_or_file_rel_path) + continue + + if os.path.isdir(contained_dir_or_file_abs_path): + child_symbols = process_directory(contained_dir_or_file_abs_path) + package_symbol["children"].extend(child_symbols) + for child in child_symbols: + child["parent"] = package_symbol + + elif os.path.isfile(contained_dir_or_file_abs_path): + with self._open_file_context(contained_dir_or_file_rel_path, open_in_ls=False) as file_data: + document_symbols = self.request_document_symbols(contained_dir_or_file_rel_path, file_data) + file_root_nodes = document_symbols.root_symbols + + # Create file symbol, link with children + file_range = self._get_range_from_file_content(file_data.contents) + file_symbol = ls_types.UnifiedSymbolInformation( + name=os.path.splitext(contained_dir_or_file_name)[0], + kind=ls_types.SymbolKind.File, + range=file_range, + selectionRange=file_range, + location=ls_types.Location( + uri=str(pathlib.Path(contained_dir_or_file_abs_path).as_uri()), + range=file_range, + absolutePath=str(contained_dir_or_file_abs_path), + relativePath=str(Path(contained_dir_or_file_abs_path).resolve().relative_to(self.repository_root_path)), + ), + children=file_root_nodes, + parent=package_symbol, + ) + for child in file_root_nodes: + child["parent"] = file_symbol + + # Link file symbol with package + package_symbol["children"].append(file_symbol) + + # TODO: Not sure if this is actually still needed given recent changes to relative path handling + def fix_relative_path(nodes: list[ls_types.UnifiedSymbolInformation]) -> None: + for node in nodes: + if "location" in node and "relativePath" in node["location"]: + path = Path(node["location"]["relativePath"]) # type: ignore + if path.is_absolute(): + try: + path = path.relative_to(self.repository_root_path) + node["location"]["relativePath"] = str(path) + except Exception: + pass + if "children" in node: + fix_relative_path(node["children"]) + + fix_relative_path(file_root_nodes) + + return result + + if within_relative_path: + within_abs_path = os.path.join(self.repository_root_path, within_relative_path) + if not os.path.exists(within_abs_path): + raise FileNotFoundError(f"File or directory not found: {within_abs_path}") + if self.is_ignored_path(within_relative_path): + raise ValueError(f"Explicitly requested symbols in '{within_relative_path}' while the path is ignored") + if os.path.isfile(within_abs_path): + root_nodes = self.request_document_symbols(within_relative_path).root_symbols + return root_nodes + else: + self.PathWorkspaceStatus.from_abs_resolved_path(Path(within_abs_path).resolve(), self).check_within_workspace_or_raise() + return process_directory(within_abs_path) + else: + full_result = [] + for root in self.config.get_absolute_workspace_folders(self.repository_root_path): + full_result.extend(process_directory(root)) + return full_result + + @staticmethod + def _get_range_from_file_content(file_content: str) -> ls_types.Range: + """ + Get the range for the given file. + """ + lines = file_content.split("\n") + end_line = len(lines) + end_column = len(lines[-1]) + return ls_types.Range(start=ls_types.Position(line=0, character=0), end=ls_types.Position(line=end_line, character=end_column)) + + def request_dir_overview(self, relative_dir_path: str) -> dict[str, list[UnifiedSymbolInformation]]: + """ + :return: A mapping of all relative paths analyzed to lists of top-level symbols in the corresponding file. + """ + symbol_tree = self.request_full_symbol_tree(relative_dir_path) + # Initialize result dictionary + result: dict[str, list[UnifiedSymbolInformation]] = defaultdict(list) + + # Helper function to process a symbol and its children + def process_symbol(symbol: ls_types.UnifiedSymbolInformation) -> None: + if symbol["kind"] == ls_types.SymbolKind.File: + # For file symbols, process their children (top-level symbols) + for child in symbol["children"]: + # Handle cross-platform path resolution (fixes Docker/macOS path issues) + absolute_path = Path(child["location"]["absolutePath"]).resolve() + repository_root = Path(self.repository_root_path).resolve() + + # Try pathlib first, fallback to alternative approach if paths are incompatible + try: + path = absolute_path.relative_to(repository_root) + except ValueError: + # If paths are from different roots (e.g., /workspaces vs /Users), + # use the relativePath from location if available, or extract from absolutePath + if "relativePath" in child["location"] and child["location"]["relativePath"]: + path = Path(child["location"]["relativePath"]) + else: + # Extract relative path by finding common structure + # Example: /workspaces/.../test_repo/file.py -> test_repo/file.py + path_parts = absolute_path.parts + + # Find the last common part or use a fallback + if "test_repo" in path_parts: + test_repo_idx = path_parts.index("test_repo") + path = Path(*path_parts[test_repo_idx:]) + else: + # Last resort: use filename only + path = Path(absolute_path.name) + result[str(path)].append(child) + # For package/directory symbols, process their children + for child in symbol["children"]: + process_symbol(child) + + # Process each root symbol + for root in symbol_tree: + process_symbol(root) + return result + + def request_document_overview(self, relative_file_path: str) -> list[UnifiedSymbolInformation]: + """ + :return: the top-level symbols in the given file. + """ + return self.request_document_symbols(relative_file_path).root_symbols + + def request_overview(self, within_relative_path: str) -> dict[str, list[UnifiedSymbolInformation]]: + """ + An overview of all symbols in the given file or directory. + Raises a ValueError if a path to an ignored file is passed. + + :param within_relative_path: the relative path to the file or directory to get the overview of. + :return: A mapping of all relative paths analyzed to lists of top-level symbols in the corresponding file. + """ + abs_path = (Path(self.repository_root_path) / within_relative_path).resolve() + if not abs_path.exists(): + raise FileNotFoundError(f"File or directory not found: {abs_path}") + + if abs_path.is_file(): + if self.is_ignored_path(within_relative_path): + raise ValueError(f"The explicitly passed file {within_relative_path} is ignored, not returning overview.") + symbols_overview = self.request_document_overview(within_relative_path) + return {within_relative_path: symbols_overview} + else: + return self.request_dir_overview(within_relative_path) + + def request_hover( + self, relative_file_path: str, line: int, column: int, file_buffer: LSPFileBuffer | None = None + ) -> ls_types.Hover | None: + """ + Raise a [textDocument/hover](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover) request to the Language Server + to find the hover information at the given line and column in the given file. Wait for the response and return the result. + + :param relative_file_path: The relative path of the file that has the hover information + :param line: The line number of the symbol + :param column: The column number of the symbol + :param file_buffer: The file buffer to use for the request. If not provided, the file will be read from disk. + Can be used for optimizing number of file reads in downstream code + """ + with self._open_file_context(relative_file_path, file_buffer=file_buffer) as fb: + return self._request_hover(fb, line, column) + + def _request_hover(self, file_buffer: LSPFileBuffer, line: int, column: int) -> ls_types.Hover | None: + """ + Performs the actual hover request. + """ + response = self.server.send.hover( + { + "textDocument": {"uri": file_buffer.uri}, + "position": { + "line": line, + "character": column, + }, + } + ) + + if response is None: + return None + + assert isinstance(response, dict) + contents = response.get("contents") + if not contents: + return None + if isinstance(contents, dict) and not contents.get("value"): + return None + return ls_types.Hover(**response) # type: ignore + + def request_signature_help(self, relative_file_path: str, line: int, column: int) -> ls_types.SignatureHelp | None: + """ + Raise a [textDocument/signatureHelp](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_signatureHelp) + request to the Language Server to find the signature help at the given line and column in the given file. + Note: contrary to `hover`, this only returns something on the position of a *call* and not on a symbol definition. + This means for Serena's purposes, this method is not particularly useful. The result is also fairly verbose (but well structured). + + :param relative_file_path: The relative path of the file that has the signature help + :param line: The line number of the symbol + :param column: The column number of the symbol + + :return None + """ + with self.open_file(relative_file_path): + response = self.server.send.signature_help( + { + "textDocument": {"uri": self._resolve_file_uri(relative_file_path)}, + "position": { + "line": line, + "character": column, + }, + } + ) + + if response is None: + return None + + assert isinstance(response, dict) + + return ls_types.SignatureHelp(**response) # type: ignore + + def create_symbol_body( + self, + symbol: ls_types.UnifiedSymbolInformation, + factory: SymbolBodyFactory | None = None, + ) -> SymbolBody: + if factory is None: + assert "relativePath" in symbol["location"] + with self._open_file_context(symbol["location"]["relativePath"]) as f: # type: ignore + factory = SymbolBodyFactory(f) + + return factory.create_symbol_body(symbol) + + def request_referencing_symbols( + self, + relative_file_path: str, + line: int, + column: int, + include_imports: bool = True, + include_self: bool = False, + include_body: bool = False, + include_file_symbols: bool = False, + ) -> list[ReferenceInSymbol]: + """ + Finds all symbols that reference the symbol at the given location. + This is similar to request_references but filters to only include symbols + (functions, methods, classes, etc.) that reference the target symbol. + + :param relative_file_path: The relative path to the file. + :param line: The 0-indexed line number. + :param column: The 0-indexed column number. + :param include_imports: whether to also include imports as references. + Unfortunately, the LSP does not have an import type, so the references corresponding to imports + will not be easily distinguishable from definitions. + :param include_self: whether to include the references that is the "input symbol" itself. + Only has an effect if the relative_file_path, line and column point to a symbol, for example a definition. + :param include_body: whether to include the body of the symbols in the result. + :param include_file_symbols: whether to include references that are file symbols. This + is often a fallback mechanism for when the reference cannot be resolved to a symbol. + :return: List of objects containing the symbol and the location of the reference. + """ + if not self.server_started: + log.error("request_referencing_symbols called before Language Server started") + raise SolidLSPException("Language Server not started") + + # First, get all references to the symbol + references = self.request_references(relative_file_path, line, column) + if not references: + return [] + + debug_enabled = log.isEnabledFor(logging.DEBUG) + t0_loop = perf_counter() if debug_enabled else 0.0 + # For each reference, find the containing symbol + result = [] + incoming_symbol = None + for ref in references: + ref_path = ref["relativePath"] + assert ref_path is not None + ref_line = ref["range"]["start"]["line"] + ref_col = ref["range"]["start"]["character"] + + with self.open_file(ref_path) as file_data: + body_factory = SymbolBodyFactory(file_data) + + # Get the containing symbol for this reference + containing_symbol = self.request_containing_symbol( + ref_path, ref_line, ref_col, include_body=include_body, body_factory=body_factory + ) + if containing_symbol is None: + # TODO: HORRIBLE HACK! I don't know how to do it better for now... + # THIS IS BOUND TO BREAK IN MANY CASES! IT IS ALSO SPECIFIC TO PYTHON! + # Background: + # When a variable is used to change something, like + # + # instance = MyClass() + # instance.status = "new status" + # + # we can't find the containing symbol for the reference to `status` + # since there is no container on the line of the reference + # The hack is to try to find a variable symbol in the containing module + # by using the text of the reference to find the variable name (In a very heuristic way) + # and then look for a symbol with that name and kind Variable + ref_text = file_data.contents.split("\n")[ref_line] + if "." in ref_text: + containing_symbol_name = ref_text.split(".")[0] + document_symbols = self.request_document_symbols(ref_path) + for symbol in document_symbols.iter_symbols(): + if symbol["name"] == containing_symbol_name and symbol["kind"] == ls_types.SymbolKind.Variable: + containing_symbol = copy(symbol) + containing_symbol["location"] = ref + containing_symbol["range"] = ref["range"] + break + + # We failed retrieving the symbol, falling back to creating a file symbol + if containing_symbol is None and include_file_symbols: + log.warning(f"Could not find containing symbol for {ref_path}:{ref_line}:{ref_col}. Returning file symbol instead") + fileRange = self._get_range_from_file_content(file_data.contents) + ref_abs_path = os.path.join(self.repository_root_path, ref_path) + if self._path_contains_dots(ref_path): + ref_abs_path = str(pathlib.Path(ref_abs_path).resolve()) + location = ls_types.Location( + uri=self._resolve_file_uri(ref_path), + range=fileRange, + absolutePath=ref_abs_path, + relativePath=ref_path, + ) + name = os.path.splitext(os.path.basename(ref_path))[0] + + containing_symbol = ls_types.UnifiedSymbolInformation( + kind=ls_types.SymbolKind.File, + range=fileRange, + selectionRange=fileRange, + location=location, + name=name, + children=[], + ) + + if include_body: + containing_symbol["body"] = self.create_symbol_body(containing_symbol, factory=body_factory) + + if containing_symbol is None or (not include_file_symbols and containing_symbol["kind"] == ls_types.SymbolKind.File): + continue + + assert "location" in containing_symbol + assert "selectionRange" in containing_symbol + + # Checking for self-reference + if ( + containing_symbol["location"]["relativePath"] == relative_file_path + and containing_symbol["selectionRange"]["start"]["line"] == ref_line + and containing_symbol["selectionRange"]["start"]["character"] == ref_col + ): + incoming_symbol = containing_symbol + if include_self: + result.append(ReferenceInSymbol(symbol=containing_symbol, line=ref_line, character=ref_col)) + continue + log.debug(f"Found self-reference for {incoming_symbol['name']}, skipping it since {include_self=}") + continue + + # checking whether reference is an import + # This is neither really safe nor elegant, but if we don't do it, + # there is no way to distinguish between definitions and imports as import is not a symbol-type + # and we get the type referenced symbol resulting from imports... + if ( + not include_imports + and incoming_symbol is not None + and containing_symbol["name"] == incoming_symbol["name"] + and containing_symbol["kind"] == incoming_symbol["kind"] + ): + log.debug( + f"Found import of referenced symbol {incoming_symbol['name']}" + f"in {containing_symbol['location']['relativePath']}, skipping" + ) + continue + + result.append(ReferenceInSymbol(symbol=containing_symbol, line=ref_line, character=ref_col)) + + if debug_enabled: + loop_elapsed_ms = (perf_counter() - t0_loop) * 1000 + unique_files = len({r.symbol["location"]["relativePath"] for r in result}) + log.debug( + "perf: request_referencing_symbols path=%s loop_elapsed_ms=%.2f ref_count=%d result_count=%d unique_files=%d", + relative_file_path, + loop_elapsed_ms, + len(references), + len(result), + unique_files, + ) + + return result + + def request_containing_symbol( + self, + relative_file_path: str, + line: int, + column: int | None = None, + strict: bool = False, + include_body: bool = False, + body_factory: SymbolBodyFactory | None = None, + ) -> ls_types.UnifiedSymbolInformation | None: + """ + Finds the first symbol containing the position for the given file. + For Python, container symbols are considered to be those with kinds corresponding to + functions, methods, or classes (typically: Function (12), Method (6), Class (5)). + + The method operates as follows: + - Request the document symbols for the file. + - Filter symbols to those that start at or before the given line. + - From these, first look for symbols whose range contains the (line, column). + - If one or more symbols contain the position, return the one with the greatest starting position + (i.e. the innermost container). + - If none (strictly) contain the position, return the symbol with the greatest starting position + among those above the given line. + - If no container candidates are found, return None. + + :param relative_file_path: The relative path to the Python file. + :param line: The 0-indexed line number. + :param column: The 0-indexed column (also called character). If not passed, the lookup will be based + only on the line. + :param strict: If True, the position must be strictly within the range of the symbol. + Setting to True is useful for example for finding the parent of a symbol, as with strict=False, + and the line pointing to a symbol itself, the containing symbol will be the symbol itself + (and not the parent). + :param include_body: Whether to include the body of the symbol in the result. + :return: The container symbol (if found) or None. + """ + # checking if the line is empty, unfortunately ugly and duplicating code, but I don't want to refactor + with self.open_file(relative_file_path): + absolute_file_path = os.path.join(self.repository_root_path, relative_file_path) + if self._path_contains_dots(relative_file_path): + absolute_file_path = str(pathlib.Path(absolute_file_path).resolve()) + content = FileUtils.read_file(absolute_file_path, self._encoding) + if content.split("\n")[line].strip() == "": + log.error(f"Passing empty lines to request_container_symbol is currently not supported, {relative_file_path=}, {line=}") + return None + + document_symbols = self.request_document_symbols(relative_file_path) + + # make jedi and pyright api compatible + # the former has no location, the later has no range + # we will just always add location of the desired format to all symbols + for symbol in document_symbols.iter_symbols(): + if "location" not in symbol: + range = symbol["range"] + location = ls_types.Location( + uri=f"file:/{absolute_file_path}", + range=range, + absolutePath=absolute_file_path, + relativePath=relative_file_path, + ) + symbol["location"] = location + else: + location = symbol["location"] + assert "range" in location + location["absolutePath"] = absolute_file_path + location["relativePath"] = relative_file_path + location["uri"] = Path(absolute_file_path).as_uri() + + # Allowed container kinds, currently only for Python + container_symbol_kinds = {ls_types.SymbolKind.Method, ls_types.SymbolKind.Function, ls_types.SymbolKind.Class} + + def is_position_in_range(line: int, range_d: ls_types.Range) -> bool: + start = range_d["start"] + end = range_d["end"] + + column_condition = True + if strict: + line_condition = end["line"] >= line > start["line"] + if column is not None and line == start["line"]: + column_condition = column > start["character"] + else: + line_condition = end["line"] >= line >= start["line"] + if column is not None and line == start["line"]: + column_condition = column >= start["character"] + return line_condition and column_condition + + # Only consider containers that are not one-liners (otherwise we may get imports) + candidate_containers = [ + s + for s in document_symbols.iter_symbols() + if s["kind"] in container_symbol_kinds and s["location"]["range"]["start"]["line"] != s["location"]["range"]["end"]["line"] + ] + var_containers = [s for s in document_symbols.iter_symbols() if s["kind"] == ls_types.SymbolKind.Variable] + candidate_containers.extend(var_containers) + + if not candidate_containers: + return None + + # From the candidates, find those whose range contains the given position. + containing_symbols = [] + for symbol in candidate_containers: + s_range = symbol["location"]["range"] + if not is_position_in_range(line, s_range): + continue + containing_symbols.append(symbol) + + if containing_symbols: + # Return the one with the greatest starting position (i.e. the innermost container). + containing_symbol = max(containing_symbols, key=lambda s: s["location"]["range"]["start"]["line"]) + if include_body: + containing_symbol["body"] = self.create_symbol_body(containing_symbol, factory=body_factory) + return containing_symbol + else: + return None + + def request_container_of_symbol( + self, symbol: ls_types.UnifiedSymbolInformation, include_body: bool = False + ) -> ls_types.UnifiedSymbolInformation | None: + """ + Finds the container of the given symbol if there is one. If the parent attribute is present, the parent is returned + without further searching. + + :param symbol: The symbol to find the container of. + :param include_body: whether to include the body of the symbol in the result. + :return: The container of the given symbol or None if no container is found. + """ + if "parent" in symbol: + return symbol["parent"] + assert "location" in symbol, f"Symbol {symbol} has no location and no parent attribute" + return self.request_containing_symbol( + symbol["location"]["relativePath"], # type: ignore + symbol["location"]["range"]["start"]["line"], + symbol["location"]["range"]["start"]["character"], + strict=True, + include_body=include_body, + ) + + def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location: + """ + Select the preferred definition from a list of definitions. + + When multiple definitions are returned (e.g., both source and type definitions), + this method determines which one to use. The base implementation simply returns + the first definition. + + Subclasses can override this method to implement language-specific preferences. + For example, TypeScript/Vue servers may prefer source files over .d.ts type + definition files. + + :param definitions: A non-empty list of definition locations. + :return: The preferred definition location. + """ + return definitions[0] + + def _get_document_symbols_with_locations(self, relative_file_path: str) -> list[ls_types.UnifiedSymbolInformation]: + abs_path = os.path.join(self.repository_root_path, relative_file_path) + if self._path_contains_dots(relative_file_path): + abs_path = str(pathlib.Path(abs_path).resolve()) + document_symbols = self.request_document_symbols(relative_file_path) + symbols = list(document_symbols.iter_symbols()) + + # Make SymbolInformation and DocumentSymbol shapes consistent by ensuring every + # symbol exposes a normalized location/range in the current workspace. + for symbol in symbols: + location = symbol["location"] + location["absolutePath"] = abs_path + location["relativePath"] = relative_file_path + location["uri"] = self._resolve_file_uri(relative_file_path) + return symbols + + @staticmethod + def _position_matches_range(range_d: ls_types.Range, line: int, column: int | None = None) -> bool: + start = range_d["start"] + end = range_d["end"] + if not (start["line"] <= line <= end["line"]): + return False + if column is None: + return True + if line == start["line"] and column < start["character"]: + return False + if line == end["line"] and column > end["character"]: + return False + return True + + @staticmethod + def _symbol_match_sort_key(symbol: ls_types.UnifiedSymbolInformation, match_priority: int) -> tuple[int, int, int, int, int]: + location = symbol["location"] + symbol_range = location["range"] + start = symbol_range["start"] + end = symbol_range["end"] + line_span = end["line"] - start["line"] + character_span = end["character"] - start["character"] if line_span == 0 else end["character"] + return match_priority, line_span, character_span, start["line"], start["character"] + + def _request_symbol_at_location( + self, + relative_file_path: str, + line: int, + column: int, + include_body: bool = False, + body_factory: SymbolBodyFactory | None = None, + ) -> ls_types.UnifiedSymbolInformation | None: + candidates: list[tuple[tuple[int, int, int, int, int], ls_types.UnifiedSymbolInformation]] = [] + for symbol in self._get_document_symbols_with_locations(relative_file_path): + location = symbol["location"] + symbol_range = location["range"] + selection_range = symbol.get("selectionRange") or symbol_range + + match_priority: int | None = None + if self._position_matches_range(selection_range, line, column): + match_priority = 0 + elif self._position_matches_range(symbol_range, line, column): + match_priority = 1 + else: + selection_start = selection_range["start"] + symbol_start = symbol_range["start"] + if (selection_start["line"], selection_start["character"]) == (line, column): + match_priority = 2 + elif (symbol_start["line"], symbol_start["character"]) == (line, column): + match_priority = 3 + elif selection_start["line"] == line and column <= selection_start["character"]: + match_priority = 4 + elif symbol_start["line"] == line and column <= symbol_start["character"]: + match_priority = 5 + + if match_priority is None: + continue + candidates.append((self._symbol_match_sort_key(symbol, match_priority), symbol)) + + if not candidates: + return None + + candidates.sort(key=lambda item: item[0]) + best_symbol = candidates[0][1] + if include_body: + best_symbol["body"] = self.create_symbol_body(best_symbol, factory=body_factory) + return best_symbol + + @staticmethod + def _iter_symbol_descendants(symbol: ls_types.UnifiedSymbolInformation) -> Iterator[ls_types.UnifiedSymbolInformation]: + """Yield descendant symbols in depth-first order.""" + for child in symbol.get("children", []): + yield child + yield from SolidLanguageServer._iter_symbol_descendants(child) + + def _refine_implementing_symbol( + self, + target_symbol: ls_types.UnifiedSymbolInformation | None, + implementing_symbol: ls_types.UnifiedSymbolInformation, + include_body: bool = False, + ) -> ls_types.UnifiedSymbolInformation: + """Resolve member-level implementation symbols when the LS returns a containing type.""" + if target_symbol is None: + return implementing_symbol + + target_kind = target_symbol["kind"] + if target_kind not in (ls_types.SymbolKind.Method, ls_types.SymbolKind.Function): + return implementing_symbol + + if implementing_symbol["kind"] == target_kind and implementing_symbol.get("name") == target_symbol.get("name"): + return implementing_symbol + + candidate_descendants: list[ls_types.UnifiedSymbolInformation] = [] + for descendant in self._iter_symbol_descendants(implementing_symbol): + if descendant.get("name") != target_symbol.get("name"): + continue + if descendant["kind"] != target_kind: + continue + candidate_descendants.append(descendant) + + if not candidate_descendants: + return implementing_symbol + + refined_symbol = min( + candidate_descendants, + key=lambda symbol: self._symbol_match_sort_key(symbol, match_priority=0), + ) + if include_body: + refined_symbol["body"] = self.create_symbol_body(refined_symbol) + return refined_symbol + + def request_symbol_at_location( + self, + relative_file_path: str, + line: int, + column: int, + include_body: bool = False, + ) -> ls_types.UnifiedSymbolInformation | None: + """ + Finds the symbol at the given position, preferring exact identifier matches and otherwise + falling back to the innermost symbol whose body contains the position. + + :param relative_file_path: The relative path to the file. + :param line: The 0-indexed line number. + :param column: The 0-indexed column number. + :param include_body: whether to include the body of the symbol in the result. + :return: The symbol at the given location, or None if no symbol could be resolved. + """ + if not self.server_started: + log.error("request_symbol_at_location called before language server started") + raise SolidLSPException("Language Server not started") + return self._request_symbol_at_location(relative_file_path, line, column, include_body=include_body) + + def request_defining_symbol( + self, + relative_file_path: str, + line: int, + column: int, + include_body: bool = False, + ) -> ls_types.UnifiedSymbolInformation | None: + """ + Finds the symbol that defines the symbol at the given location. + + This method first finds the definition of the symbol at the given position, + then retrieves the full symbol information for that definition. + + :param relative_file_path: The relative path to the file. + :param line: The 0-indexed line number. + :param column: The 0-indexed column number. + :param include_body: whether to include the body of the symbol in the result. + :return: The symbol information for the definition, or None if not found. + """ + if not self.server_started: + log.error("request_defining_symbol called before language server started") + raise SolidLSPException("Language Server not started") + + # Get the definition location(s) + definitions = self.request_definition(relative_file_path, line, column) + if not definitions: + return None + + # Select the preferred definition (subclasses can override _get_preferred_definition) + definition = self._get_preferred_definition(definitions) + def_path = definition["relativePath"] + if def_path is None: + return None + def_line = definition["range"]["start"]["line"] + def_col = definition["range"]["start"]["character"] + + return self._request_symbol_at_location( + def_path, + def_line, + def_col, + include_body=include_body, + ) + + def request_implementing_symbols( + self, + relative_file_path: str, + line: int, + column: int, + include_body: bool = False, + ) -> list[ls_types.UnifiedSymbolInformation]: + """ + Finds the symbols that implement the symbol at the given location. + + This method first finds implementation locations for the symbol at the given position, + then retrieves the full symbol information for each implementation and de-duplicates + results that map to the same containing symbol. + + :param relative_file_path: The relative path to the file. + :param line: The 0-indexed line number. + :param column: The 0-indexed column number. + :param include_body: whether to include the body of the symbols in the result. + :return: The symbol information for each implementation. + """ + if not self.server_started: + log.error("request_implementing_symbols called before language server started") + raise SolidLSPException("Language Server not started") + + target_symbol = self._request_symbol_at_location(relative_file_path, line, column, include_body=False) + implementation_locations = self.request_implementation(relative_file_path, line, column) + if not implementation_locations: + return [] + + result: list[ls_types.UnifiedSymbolInformation] = [] + seen_keys: set[tuple[str, int, int, int]] = set() + for implementation in implementation_locations: + implementation_path = implementation["relativePath"] + assert implementation_path is not None + implementation_line = implementation["range"]["start"]["line"] + implementation_col = implementation["range"]["start"]["character"] + implementing_symbol = self._request_symbol_at_location( + implementation_path, + implementation_line, + implementation_col, + include_body=include_body, + body_factory=None, + ) + if implementing_symbol is None: + continue + implementing_symbol = self._refine_implementing_symbol(target_symbol, implementing_symbol, include_body=include_body) + if "location" not in implementing_symbol: + continue + symbol_location = implementing_symbol["location"] + symbol_key = ( + cast(str, symbol_location["relativePath"]), + symbol_location["range"]["start"]["line"], + symbol_location["range"]["start"]["character"], + implementing_symbol["kind"], + ) + if symbol_key in seen_keys: + continue + seen_keys.add(symbol_key) + result.append(implementing_symbol) + + return result + + def _document_symbols_cache_fingerprint(self) -> Hashable | None: + """ + Returns a fingerprint of any language server-specific aspects that result in changes + to the high-level document symbol information. + + Language servers must implement this method/change the return value + * whenever they change the `request_document_symbols` implementation to modify the returned content + * are reconfigured in a way that affects the returned contents (e.g. context-specific configuration + such as build flags or environment variables); configuration options can, in such cases, be + hashed together to produce a single fingerprint value. + + Whenever the value changes, the document symbols cache will be invalidated and re-populated. + + The value must be hashable and safe for inclusion in cache version tuples. + E.g. use an integer, a string or a tuple of integers/strings. + + For example, if there is a single aspect being considered, use an integer to reflect the version + of this aspect (incrementing it whenever the implementation changes). + If multiple versioned aspects exist, use a tuple of versions, etc. + """ + return None + + def _document_symbols_cache_version(self) -> Hashable: + """ + Return the version for the document symbols cache. + + Incorporates cache context fingerprint if provided by the language server. + """ + fingerprint = self._document_symbols_cache_fingerprint() + if fingerprint is not None: + return (self.DOCUMENT_SYMBOL_CACHE_VERSION, fingerprint) + return self.DOCUMENT_SYMBOL_CACHE_VERSION + + def _save_raw_document_symbols_cache(self) -> None: + cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME + + if not self._raw_document_symbols_cache_is_modified: + log.debug("No changes to raw document symbols cache, skipping save") + return + + log.info("Saving updated raw document symbols cache to %s", cache_file) + try: + save_cache(str(cache_file), self._raw_document_symbols_cache_version(), self._raw_document_symbols_cache) + self._raw_document_symbols_cache_is_modified = False + except Exception as e: + log.error( + "Failed to save raw document symbols cache to %s: %s. Note: this may have resulted in a corrupted cache file.", + cache_file, + e, + ) + + def _raw_document_symbols_cache_version(self) -> tuple[Hashable, ...]: + base_version: tuple[Hashable, ...] = (self.RAW_DOCUMENT_SYMBOLS_CACHE_VERSION, self._ls_specific_raw_document_symbols_cache_version) + fingerprint = self._document_symbols_cache_fingerprint() + if fingerprint is not None: + return (*base_version, fingerprint) + return base_version + + def _load_raw_document_symbols_cache(self) -> None: + cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME + + if not cache_file.exists(): + # check for legacy cache to load to migrate + legacy_cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME_LEGACY_FALLBACK + if legacy_cache_file.exists(): + try: + legacy_cache: dict[ + str, tuple[str, tuple[list[ls_types.UnifiedSymbolInformation], list[ls_types.UnifiedSymbolInformation]]] + ] = load_pickle(legacy_cache_file) + log.info("Migrating legacy document symbols cache with %d entries", len(legacy_cache)) + num_symbols_migrated = 0 + migrated_cache = {} + for cache_key, (file_hash, (all_symbols, root_symbols)) in legacy_cache.items(): + if cache_key.endswith("-True"): # include_body=True + new_cache_key = cache_key[:-5] + migrated_cache[new_cache_key] = (file_hash, root_symbols) + num_symbols_migrated += len(all_symbols) + log.info("Migrated %d document symbols from legacy cache", num_symbols_migrated) + self._raw_document_symbols_cache = migrated_cache + self._raw_document_symbols_cache_is_modified = True + self._save_raw_document_symbols_cache() + legacy_cache_file.unlink() + return + except Exception as e: + log.error("Error during cache migration: %s", e) + return + + # load existing cache (if any) + if cache_file.exists(): + log.info("Loading document symbols cache from %s", cache_file) + try: + saved_cache = load_cache(str(cache_file), self._raw_document_symbols_cache_version()) + if saved_cache is not None: + self._raw_document_symbols_cache = saved_cache + log.info(f"Loaded {len(self._raw_document_symbols_cache)} entries from raw document symbols cache.") + except Exception as e: + # cache can become corrupt, so just skip loading it + log.warning( + "Failed to load raw document symbols cache from %s (%s); Ignoring cache.", + cache_file, + e, + ) + + def _save_document_symbols_cache(self) -> None: + cache_file = self.cache_dir / self.DOCUMENT_SYMBOL_CACHE_FILENAME + + if not self._document_symbols_cache_is_modified: + log.debug("No changes to document symbols cache, skipping save") + return + + log.info("Saving updated document symbols cache to %s", cache_file) + try: + save_cache(str(cache_file), self._document_symbols_cache_version(), self._document_symbols_cache) + self._document_symbols_cache_is_modified = False + except Exception as e: + log.error( + "Failed to save document symbols cache to %s: %s. Note: this may have resulted in a corrupted cache file.", + cache_file, + e, + ) + + def _load_document_symbols_cache(self) -> None: + cache_file = self.cache_dir / self.DOCUMENT_SYMBOL_CACHE_FILENAME + if cache_file.exists(): + log.info("Loading document symbols cache from %s", cache_file) + try: + saved_cache = load_cache(str(cache_file), self._document_symbols_cache_version()) + if saved_cache is not None: + self._document_symbols_cache = saved_cache + log.info(f"Loaded {len(self._document_symbols_cache)} entries from document symbols cache.") + except Exception as e: + # cache can become corrupt, so just skip loading it + log.warning( + "Failed to load document symbols cache from %s (%s); Ignoring cache.", + cache_file, + e, + ) + + def save_cache(self) -> None: + self._save_raw_document_symbols_cache() + self._save_document_symbols_cache() + + def request_workspace_symbol(self, query: str) -> list[ls_types.UnifiedSymbolInformation] | None: + """ + Raise a [workspace/symbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_symbol) request to the Language Server + to find symbols across the whole workspace. Wait for the response and return the result. + + :param query: The query string to filter symbols by + + :return: A list of matching symbols + """ + response = self.server.send.workspace_symbol({"query": query}) + if response is None: + return None + + assert isinstance(response, list) + + ret: list[ls_types.UnifiedSymbolInformation] = [] + for item in response: + assert isinstance(item, dict) + + assert LSPConstants.NAME in item + assert LSPConstants.KIND in item + assert LSPConstants.LOCATION in item + + ret.append(ls_types.UnifiedSymbolInformation(**item)) # type: ignore + + return ret + + def request_rename_symbol_edit( + self, + relative_file_path: str, + line: int, + column: int, + new_name: str, + ) -> ls_types.WorkspaceEdit | None: + """ + Retrieve a WorkspaceEdit for renaming the symbol at the given location to the new name. + Does not apply the edit, just retrieves it. In order to actually rename the symbol, call apply_workspace_edit. + + :param relative_file_path: The relative path to the file containing the symbol + :param line: The 0-indexed line number of the symbol + :param column: The 0-indexed column number of the symbol + :param new_name: The new name for the symbol + :return: A WorkspaceEdit containing the changes needed to rename the symbol, or None if rename is not supported + """ + params = RenameParams( + textDocument=ls_types.TextDocumentIdentifier(uri=self._resolve_file_uri(relative_file_path)), + position=ls_types.Position(line=line, character=column), + newName=new_name, + ) + + with self.open_file(relative_file_path): + return self.server.send.rename(params) + + def apply_text_edits_to_file(self, relative_path: str, edits: list[ls_types.TextEdit]) -> None: + """ + Apply a list of text edits to a file. + + :param relative_path: The relative path of the file to edit + :param edits: List of TextEdit dictionaries to apply + """ + with self.open_file(relative_path): + # Sort edits by position (latest first) to avoid position shifts + sorted_edits = sorted(edits, key=lambda e: (e["range"]["start"]["line"], e["range"]["start"]["character"]), reverse=True) + + for edit in sorted_edits: + start_pos = ls_types.Position(line=edit["range"]["start"]["line"], character=edit["range"]["start"]["character"]) + end_pos = ls_types.Position(line=edit["range"]["end"]["line"], character=edit["range"]["end"]["character"]) + + # Delete the old text and insert the new text + self.delete_text_between_positions(relative_path, start_pos, end_pos) + self.insert_text_at_position(relative_path, start_pos["line"], start_pos["character"], edit["newText"]) + + def start(self) -> "SolidLanguageServer": + """ + Starts the language server process and connects to it. Call shutdown when ready. + + :return: self for method chaining + """ + log.info(f"Starting language server with language {self.language_server.language} for {self.language_server.repository_root_path}") + self.server_started = True + self._start_server() + return self + + def stop(self, shutdown_timeout: float = 2.0) -> None: + """ + Stops the language server process. + This function never raises an exception (any exceptions during shutdown are logged). + + :param shutdown_timeout: time, in seconds, to wait for the server to shutdown gracefully before killing it + """ + try: + self.server.stop(timeout=shutdown_timeout) + except Exception as e: + log.warning(f"Exception while shutting down language server: {e}") + finally: + self.server_started = False + + @property + def language_server(self) -> Self: + return self + + @property + def handler(self) -> LanguageServerInterface: + """Access the underlying language server handler. + + Useful for advanced operations like sending custom commands + or registering notification handlers. + """ + return self.server + + def is_running(self) -> bool: + return self.server.is_running() + + def _create_initialize_params_builder(self) -> InitializeParamsBuilder: + return DefaultInitializeParamsBuilder(self) + + @abstractmethod + def _create_base_initialize_params(self) -> dict | InitializeParams: + """ + Subclasses should override this method to provide server-specific InitializeParams settings, + which provide the basis for the InitializeParams object sent to the language server during initialization. + + The returned dictionary is passed to the builder constructed by _create_initialize_params_builder() + in order to create the final InitializeParams object. + + The default builder implementation already sets the following keys, so implementations of this method + should not set them: + + - processId + - rootPath + - rootUri + - clientInfo + - workspaceFolders + + :return: the base InitializeParams settings + """ + + def _create_initialize_params(self) -> InitializeParams: + """ + Create the InitializeParams object to send to the language server during initialization. + """ + return self._create_initialize_params_builder().with_base_options(self._create_base_initialize_params()).build() diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py new file mode 100644 index 0000000..8d27c6e --- /dev/null +++ b/src/solidlsp/ls_config.py @@ -0,0 +1,937 @@ +""" +Configuration objects for language servers +""" + +import logging +import os +import re +from collections.abc import Iterable +from dataclasses import dataclass, field +from enum import Enum +from functools import cache +from pathlib import Path +from typing import TYPE_CHECKING, Self + +if TYPE_CHECKING: + from solidlsp import SolidLanguageServer + +log = logging.getLogger(__name__) + + +class FilenameMatcher: + def __init__(self, *file_extensions: str, case_sensitive: bool = True) -> None: + """ + :param file_extensions: file extensions, e.g., `.py, .yml` + :param case_sensitive: whether the file extensions are case-sensitive. + """ + self._file_extensions = list(set(file_extensions)) if case_sensitive else list(set(ext.lower() for ext in file_extensions)) + self._case_sensitive = case_sensitive + # Snapshot of the initial configuration, used by ``reset``. Relevant for matchers that are + # per-language singletons (``Language.get_source_fn_matcher`` is ``@cache``d): extensions added + # via ``add_extensions`` for one project must not leak into the next, so the singleton is reset + # to this snapshot at every language server initialisation. + self._initial_file_extensions = list(self._file_extensions) + + def reset(self) -> None: + """ + Restore the matcher to its initial set of extensions (as provided at construction). + + Undoes any extensions added via :meth:`add_extensions`. Intended for the per-language + singleton matchers, which are reset at the start of every language server initialisation so + that a previous project's reconfiguration does not leak into a newly activated one. + """ + self._file_extensions = list(self._initial_file_extensions) + + def add_extensions(self, *file_extensions: str) -> None: + """ + Add further file extensions to this matcher (idempotent). + + This is intended for matchers that are per-language singletons, i.e. those returned by + :meth:`Language.get_source_fn_matcher` (which is ``@cache``d): extensions that a user + configures for a language server (e.g. ``.cgi`` for Perl) can be added here so that every + consumer of the matcher — symbol index traversal, ignore checks, language composition — + treats the same set of files as sources, staying in sync with the language server. + + :param file_extensions: the additional file extensions, e.g. ``.cgi`` + """ + for ext in file_extensions: + norm = ext if self._case_sensitive else ext.lower() + if norm not in self._file_extensions: + self._file_extensions.append(norm) + + def is_relevant_filename(self, fn: str) -> bool: + if not self._case_sensitive: + fn = fn.lower() + for ext in self._file_extensions: + if fn.endswith(ext): + return True + return False + + def string_contains_relevant_filename(self, string: str) -> bool: + """:return: whether ``string`` contains an occurrence of any registered extension as + a *complete* extension — i.e. the extension must either end the string or be followed + by a non-extension-character (anything other than a letter, digit, or underscore). + """ + if not self._case_sensitive: + string = string.lower() + for ext in self._file_extensions: + if re.search(rf"{re.escape(ext)}(?:\W|$)", string): + return True + return False + + +class Language(str, Enum): + """ + Enumeration of language servers supported by SolidLSP. + """ + + CSHARP = "csharp" + PYTHON = "python" + RUST = "rust" + JAVA = "java" + KOTLIN = "kotlin" + TYPESCRIPT = "typescript" + GO = "go" + RUBY = "ruby" + DART = "dart" + CPP = "cpp" + CPP_CCLS = "cpp_ccls" + PHP = "php" + R = "r" + PERL = "perl" + CLOJURE = "clojure" + ELIXIR = "elixir" + ELM = "elm" + TERRAFORM = "terraform" + SWIFT = "swift" + BASH = "bash" + CRYSTAL = "crystal" + CUE = "cue" + ZIG = "zig" + LUA = "lua" + LUAU = "luau" + """Luau Language Server for Roblox's Luau language (typed Lua 5.1 superset). + Uses luau-lsp by JohnnyMorganz. Automatically downloads the binary if not found. + Supports .luau files. Configure via .luaurc in the project root. + """ + NIX = "nix" + ERLANG = "erlang" + OCAML = "ocaml" + AL = "al" + FSHARP = "fsharp" + REGO = "rego" + SCALA = "scala" + JULIA = "julia" + FORTRAN = "fortran" + HASKELL = "haskell" + HAXE = "haxe" + """Haxe language server using vshaxe/haxe-language-server. + Requires Haxe compiler (3.4.0+) and Node.js. + Discovered from system PATH or vshaxe VSCode extension, otherwise downloaded from Open VSX. + """ + LEAN4 = "lean4" + GROOVY = "groovy" + VUE = "vue" + SVELTE = "svelte" + """Svelte language server using svelte-language-server. + Supports .svelte Single File Components plus TypeScript and JavaScript + files in Svelte projects. Requires Node.js v18+ and npm. + """ + POWERSHELL = "powershell" + PASCAL = "pascal" + """Pascal Language Server (pasls) for Free Pascal and Lazarus projects. + Automatically downloads pasls binary. Requires FPC for full functionality. + Set PP and FPCDIR environment variables for source navigation. + """ + MATLAB = "matlab" + """MATLAB language server using the official MathWorks MATLAB Language Server. + Requires MATLAB R2021b or later and Node.js. + Set MATLAB_PATH environment variable or configure matlab_path in ls_specific_settings. + """ + MSL = "msl" + """mIRC Scripting Language (mSL) language server. + Supports .mrc files used in mIRC and AdiIRC IRC clients. + Uses a custom LSP server based on pygls. Automatically sets up + a virtual environment with pygls dependencies on first use. + """ + BSL = "bsl" + """BSL Language Server for 1C:Enterprise and OneScript languages. + Uses bsl-language-server by 1c-syntax. Automatically downloads the JAR. + Supports .bsl and .os files. Requires Java 21+ on PATH. + """ + ADA = "ada" + """Ada / SPARK language server using AdaCore's Ada Language Server (ALS). + Supports .ads (specs), .adb (bodies), and .ada files. Auto-downloads the + ALS binary from AdaCore's GitHub releases. Works best with a .gpr GNAT + project file at the repository root. SPARK files are handled transparently + by the same server, since SPARK is distinguished by pragmas/aspects in + source rather than by file extension. + """ + GDSCRIPT = "gdscript" + """GDScript language server for Godot Engine projects (Godot 3 and 4). + Connects to the Godot editor's built-in LSP server over TCP (port 6008). + The editor must already be running with its built-in LSP enabled (default). + Supports .gd and .gdscript files. + """ + # Experimental or deprecated Language Servers + TYPESCRIPT_VTS = "typescript_vts" + """Use the typescript language server through the natively bundled vscode extension via https://github.com/yioneko/vtsls""" + PYTHON_JEDI = "python_jedi" + """Jedi language server for Python (instead of pyright, which is the default)""" + PYTHON_TY = "python_ty" + """Ty language server for Python (instead of pyright, which is the default).""" + PYTHON_PYREFLY = "python_pyrefly" + """Pyrefly language server for Python (instead of pyright, which is the default).""" + CSHARP_OMNISHARP = "csharp_omnisharp" + """OmniSharp language server for C# (instead of the default csharp-ls by microsoft). + Currently has problems with finding references, and generally seems less stable and performant. + """ + RUBY_SOLARGRAPH = "ruby_solargraph" + """Solargraph language server for Ruby (legacy, experimental). + Use Language.RUBY (ruby-lsp) for better performance and modern LSP features. + """ + PHP_PHPACTOR = "php_phpactor" + """Phpactor language server for PHP (instead of Intelephense, which is the default). + Requires PHP 8.1+ on the system. Fully open-source (MIT license). + """ + PHP_PHPANTOM = "php_phpantom" + """PHPantom language server for PHP (instead of Intelephense, which is the default). + Uses the open-source Rust-based phpantom_lsp binary and can be auto-downloaded. + """ + MARKDOWN = "markdown" + """Marksman language server for Markdown (experimental). + Must be explicitly specified as the main language, not auto-detected. + This is an edge case primarily useful when working on documentation-heavy projects. + """ + LATEX = "latex" + """texlab language server for LaTeX/BibTeX (experimental). + Must be explicitly specified as the main language, not auto-detected. + Provides sectioning-hierarchy document symbols plus label/citation definitions and references. + """ + YAML = "yaml" + """YAML language server (experimental). + Must be explicitly specified as the main language, not auto-detected. + """ + JSON = "json" + """JSON language server using vscode-json-languageserver (experimental). + Provides document symbol navigation and hover for JSON files. + Must be explicitly specified as the main language, not auto-detected. + Requires Node.js and npm. + """ + TOML = "toml" + """TOML language server using Taplo. + Supports TOML validation, formatting, and schema support. + """ + HLSL = "hlsl" + """Shader language server using shader-language-server (antaalt/shader-sense). + Supports .hlsl, .hlsli, .fx, .fxh, .cginc, .compute, .shader, .glsl, .vert, .frag, .geom, .tesc, .tese, .comp, .wgsl files. + Automatically downloads shader-language-server binary. + """ + SYSTEMVERILOG = "systemverilog" + """SystemVerilog language server using verible-verilog-ls. + Supports .sv, .svh, .v, .vh files. + Automatically downloads verible binary. + """ + SOLIDITY = "solidity" + """Solidity language server using the Nomic Foundation Solidity Language Server + (@nomicfoundation/solidity-language-server). + Supports .sol files. Provides go-to-definition, find references, document symbols, + hover, and diagnostics. Requires Node.js and npm. + Works best with a foundry.toml or hardhat.config.js in the project root. + """ + ANSIBLE = "ansible" + """Ansible language server (experimental) using @ansible/ansible-language-server. + Supports .yaml and .yml files (same extensions as YAML, hence experimental). + Must be explicitly specified in project.yml. Requires Node.js and npm. + Requires ``ansible`` in PATH for full functionality. + """ + HTML = "html" + """HTML language server (experimental) using vscode-html-language-server from + Microsoft's vscode-langservers-extracted npm package. Supports *.html and *.htm files. + Must be explicitly specified in project.yml. Requires Node.js and npm. + Note: HTML LSP provides in-file element/id symbols only; cross-file references + are not meaningful for HTML. Also used as a companion server by Angular LS for + plain HTML documentSymbol support. + """ + SCSS = "scss" + """SCSS / Sass / CSS language server (experimental) using some-sass-language-server + (https://github.com/wkillerud/some-sass). Handles *.scss, *.sass, and *.css. + Must be explicitly specified in project.yml. Requires Node.js and npm. + Provides full @use/@forward workspace navigation across SCSS files; CSS support + relies on the same vscode-css-languageservice engine and is enabled at startup + via the somesass.css.* feature toggles (which default to off upstream). + """ + ANGULAR = "angular" + """Angular Language Server (experimental) using the official @angular/language-server + (ngserver). Supports *.ts and *.html files (Angular templates can be external or inline). + Understands Angular template syntax (*ngIf, [prop], (event), {{ interpolation }}, + @if/@for blocks, etc.) and provides type-aware navigation between templates and + component classes — which the plain HTML and TypeScript LSPs cannot. + Requires Node.js, npm, and a valid Angular workspace (angular.json or Nx project.json + at the repository root). When activated, do not also enable typescript or html in + project.yml — Angular LS supersedes both for Angular projects. + Must be explicitly specified in project.yml. + """ + + @classmethod + def iter_all(cls, include_experimental: bool = False, include_non_programming_languages: bool = True) -> Iterable[Self]: + for lang in cls: + if include_experimental or not lang.is_experimental(): + if include_non_programming_languages or lang.is_programming_language(): + yield lang + + def is_experimental(self) -> bool: + """ + Check if the language server is experimental or deprecated. + + Note for serena users/developers: + Experimental languages are not autodetected and must be explicitly specified + in the project.yml configuration. + """ + return self in { + self.ANSIBLE, + self.TYPESCRIPT_VTS, + self.PYTHON_JEDI, + self.PYTHON_TY, + self.PYTHON_PYREFLY, + self.CSHARP_OMNISHARP, + self.RUBY_SOLARGRAPH, + self.PHP_PHPACTOR, + self.PHP_PHPANTOM, + self.MARKDOWN, + self.LATEX, + self.YAML, + self.JSON, + self.TOML, + self.GROOVY, + self.CPP_CCLS, + self.SOLIDITY, + self.HTML, + self.SCSS, + self.ANGULAR, + } + + def is_programming_language(self) -> bool: + """Whether the supported language should be considered a programming language. + Solidlsp supports languages like markdown or json, this method returns False for them. + """ + return self not in frozenset((self.MARKDOWN, self.LATEX, self.JSON, self.TOML, self.YAML, self.ANSIBLE)) + + def __str__(self) -> str: + return self.value + + def get_priority(self) -> int: + """ + :return: priority of the language for breaking ties between languages; higher is more important. + """ + # experimental languages have the lowest priority + if self.is_experimental(): + return 0 + # We assign lower priority to languages that are supersets of others, such that + # the "larger" language is only chosen when it matches more strongly + match self: + # languages that are supersets of others (Vue/Svelte are supersets of TypeScript/JavaScript) + case self.VUE | self.SVELTE: + return 1 + # regular languages + case _: + return 2 + + def supports_implementation_request(self) -> bool: + """ + Return whether the default language server for this language supports ``textDocument/implementation``. + """ + return self.get_ls_class().supports_implementation_request() + + # NOTE: Caching results in a singleton per enum item, which is a precondition for persistent configuration of the matcher. + @cache + def get_source_fn_matcher(self) -> FilenameMatcher: + match self: + case self.PYTHON | self.PYTHON_JEDI | self.PYTHON_TY | self.PYTHON_PYREFLY: + return FilenameMatcher(".py", ".pyi") + case self.JAVA: + return FilenameMatcher(".java") + case self.TYPESCRIPT | self.TYPESCRIPT_VTS: + # see https://github.com/oraios/serena/issues/204 + path_patterns = [] + for prefix in ["c", "m", ""]: + for postfix in ["x", ""]: + for base_pattern in ["ts", "js"]: + path_patterns.append(f".{prefix}{base_pattern}{postfix}") + return FilenameMatcher(*path_patterns) + case self.CSHARP | self.CSHARP_OMNISHARP: + return FilenameMatcher(".cs") + case self.RUST: + return FilenameMatcher(".rs") + case self.GO: + return FilenameMatcher(".go") + case self.RUBY: + return FilenameMatcher(".rb", ".erb") + case self.RUBY_SOLARGRAPH: + return FilenameMatcher(".rb") + case self.CPP: + # From llvm-project/clang/lib/Driver/Types.cpp types::lookupTypeForExtension: + return FilenameMatcher( + # C + ".c", + ".h", + # C++ + ".c++", + ".cc", + ".cp", + ".cpp", + ".cxx", + ".hh", + ".hpp", + ".hxx", + # C++ include files + ".inl", + ".ipp", + ".tpp", + ".txx", + # Objective-C + ".m", + ".mm", + # C++20 module interface files + ".c++m", + ".cppm", + ".cxxm", + ".ixx", + # CUDA + ".cu", + # HIP + ".hip", + # OpenCL + ".cl", + ".clcpp", + # Arduino sketch: not in clang's extension table, but it is C++. + # Routing it here lets clangd serve symbols; the project must + # tell clangd it is C++ (a .clangd with CompileFlags Add: [-xc++], + # or a compile DB), since the clang driver can't infer a job from .ino. + ".ino", + case_sensitive=False, + ) + case self.CPP_CCLS: + # From llvm-project/clang/lib/Driver/Types.cpp types::lookupTypeForExtension: + return FilenameMatcher( + # C + ".c", + ".h", + # C++ + ".c++", + ".cc", + ".cp", + ".cpp", + ".cxx", + ".hh", + ".hpp", + ".hxx", + # C++ include files + ".inl", + ".ipp", + ".tpp", + ".txx", + # Objective-C + ".m", + ".mm", + # Arduino sketch (C++); see note in the CPP case above. + ".ino", + case_sensitive=False, + ) + case self.KOTLIN: + return FilenameMatcher(".kt", ".kts") + case self.DART: + return FilenameMatcher(".dart") + case self.PHP | self.PHP_PHPACTOR | self.PHP_PHPANTOM: + return FilenameMatcher(".php") + case self.R: + return FilenameMatcher(".R", ".r", ".Rmd", ".Rnw") + case self.PERL: + return FilenameMatcher(".pl", ".pm", ".t") + case self.CLOJURE: + return FilenameMatcher(".clj", ".cljs", ".cljc", ".edn") # codespell:ignore edn + case self.ELIXIR: + return FilenameMatcher(".ex", ".exs") + case self.ELM: + return FilenameMatcher(".elm") + case self.TERRAFORM: + return FilenameMatcher(".tf", ".tfvars", ".tfstate") + case self.SWIFT: + return FilenameMatcher(".swift") + case self.BASH: + return FilenameMatcher(".sh", ".bash") + case self.CRYSTAL: + return FilenameMatcher(".cr") + case self.CUE: + return FilenameMatcher(".cue") + case self.YAML: + return FilenameMatcher(".yaml", ".yml") + case self.JSON: + return FilenameMatcher(".json", ".jsonc") + case self.TOML: + return FilenameMatcher(".toml") + case self.ZIG: + return FilenameMatcher(".zig", ".zon") + case self.LUA: + return FilenameMatcher(".lua") + case self.LUAU: + return FilenameMatcher(".luau") + case self.NIX: + return FilenameMatcher(".nix") + case self.ERLANG: + return FilenameMatcher(".erl", ".hrl", ".escript", ".config", ".app", ".app.src") + case self.OCAML: + return FilenameMatcher(".ml", ".mli", ".re", ".rei") + case self.AL: + return FilenameMatcher(".al", ".dal") + case self.FSHARP: + return FilenameMatcher(".fs", ".fsx", ".fsi") + case self.REGO: + return FilenameMatcher(".rego") + case self.MARKDOWN: + return FilenameMatcher(".md", ".markdown") + case self.LATEX: + return FilenameMatcher(".tex", ".bib", ".sty", ".cls") + case self.SCALA: + return FilenameMatcher(".scala", ".sbt") + case self.JULIA: + return FilenameMatcher(".jl") + case self.FORTRAN: + return FilenameMatcher(".f90", ".f95", ".f03", ".f08", ".f", ".for", ".fpp", case_sensitive=False) + case self.HASKELL: + return FilenameMatcher(".hs", ".lhs") + case self.HAXE: + return FilenameMatcher(".hx") + case self.LEAN4: + return FilenameMatcher(".lean") + case self.VUE: + path_patterns = [".vue"] + for prefix in ["c", "m", ""]: + for postfix in ["x", ""]: + for base_pattern in ["ts", "js"]: + path_patterns.append(f".{prefix}{base_pattern}{postfix}") + return FilenameMatcher(*path_patterns) + case self.SVELTE: + path_patterns = [".svelte"] + for prefix in ["c", "m", ""]: + for base_pattern in ["ts", "js"]: + path_patterns.append(f".{prefix}{base_pattern}") + return FilenameMatcher(*path_patterns) + case self.POWERSHELL: + return FilenameMatcher(".ps1", ".psm1", ".psd1") + case self.PASCAL: + return FilenameMatcher(".pas", ".pp", ".lpr", ".dpr", ".dpk", ".inc") + case self.GROOVY: + return FilenameMatcher(".groovy", ".gvy") + case self.MATLAB: + return FilenameMatcher(".m", ".mlx", ".mlapp") + case self.HLSL: + return FilenameMatcher( + ".hlsl", + ".hlsli", + ".fx", + ".fxh", + ".cginc", + ".compute", + ".shader", + ".glsl", + ".vert", + ".frag", + ".geom", + ".tesc", + ".tese", + ".comp", + ".wgsl", + ) + case self.SYSTEMVERILOG: + return FilenameMatcher(".sv", ".svh", ".v", ".vh") + case self.SOLIDITY: + return FilenameMatcher(".sol") + case self.ANSIBLE: + return FilenameMatcher(".yaml", ".yml") + case self.MSL: + return FilenameMatcher(".mrc") + case self.BSL: + return FilenameMatcher(".bsl", ".os") + case self.ADA: + return FilenameMatcher(".ads", ".adb", ".ada", case_sensitive=False) + case self.GDSCRIPT: + return FilenameMatcher(".gd", ".gdscript") + case self.HTML: + return FilenameMatcher(".html", ".htm") + case self.SCSS: + # *.css is handled by the same engine (vscode-css-languageservice) that powers + # Microsoft's CSS LS, so we route plain CSS through Some Sass too. The CSS feature + # toggles default off upstream and are flipped on at initialization time. + return FilenameMatcher(".scss", ".sass", ".css") + case self.ANGULAR: + # Angular templates can be standalone .html files or inline templates + # within .ts component files; the dual-server architecture handles both. + # SCSS / styles are deliberately NOT subsumed — use Language.SCSS for those. + path_patterns = [".html", ".htm"] + for prefix in ["c", "m", ""]: + for postfix in ["x", ""]: + path_patterns.append(f".{prefix}ts{postfix}") + return FilenameMatcher(*path_patterns) + case _: + raise ValueError(f"Unhandled language: {self}") + + def get_ls_class(self) -> type["SolidLanguageServer"]: + match self: + case self.PYTHON: + from solidlsp.language_servers.pyright_server import PyrightServer + + return PyrightServer + case self.PYTHON_JEDI: + from solidlsp.language_servers.jedi_server import JediServer + + return JediServer + case self.PYTHON_TY: + from solidlsp.language_servers.ty_server import TyLanguageServer + + return TyLanguageServer + case self.PYTHON_PYREFLY: + from solidlsp.language_servers.pyrefly_server import PyreflyLanguageServer + + return PyreflyLanguageServer + case self.JAVA: + from solidlsp.language_servers.eclipse_jdtls import EclipseJDTLS + + return EclipseJDTLS + case self.KOTLIN: + from solidlsp.language_servers.kotlin_language_server import KotlinLanguageServer + + return KotlinLanguageServer + case self.RUST: + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + return RustAnalyzer + case self.CSHARP: + from solidlsp.language_servers.csharp_language_server import CSharpLanguageServer + + return CSharpLanguageServer + case self.CSHARP_OMNISHARP: + from solidlsp.language_servers.omnisharp import OmniSharp + + return OmniSharp + case self.TYPESCRIPT: + from solidlsp.language_servers.typescript_language_server import TypeScriptLanguageServer + + return TypeScriptLanguageServer + case self.TYPESCRIPT_VTS: + from solidlsp.language_servers.vts_language_server import VtsLanguageServer + + return VtsLanguageServer + case self.VUE: + from solidlsp.language_servers.vue_language_server import VueLanguageServer + + return VueLanguageServer + case self.SVELTE: + from solidlsp.language_servers.svelte_language_server import SvelteLanguageServer + + return SvelteLanguageServer + case self.GO: + from solidlsp.language_servers.gopls import Gopls + + return Gopls + case self.RUBY: + from solidlsp.language_servers.ruby_lsp import RubyLsp + + return RubyLsp + case self.RUBY_SOLARGRAPH: + from solidlsp.language_servers.solargraph import Solargraph + + return Solargraph + case self.DART: + from solidlsp.language_servers.dart_language_server import DartLanguageServer + + return DartLanguageServer + case self.CPP: + from solidlsp.language_servers.clangd_language_server import ClangdLanguageServer + + return ClangdLanguageServer + case self.CPP_CCLS: + from solidlsp.language_servers.ccls_language_server import CCLS + + return CCLS + case self.PHP: + from solidlsp.language_servers.intelephense import Intelephense + + return Intelephense + case self.PHP_PHPACTOR: + from solidlsp.language_servers.phpactor import PhpactorServer + + return PhpactorServer + case self.PHP_PHPANTOM: + from solidlsp.language_servers.phpantom import PHPantomServer + + return PHPantomServer + case self.PERL: + from solidlsp.language_servers.perl_language_server import PerlLanguageServer + + return PerlLanguageServer + case self.CLOJURE: + from solidlsp.language_servers.clojure_lsp import ClojureLSP + + return ClojureLSP + case self.ELIXIR: + from solidlsp.language_servers.elixir_tools.elixir_tools import ElixirTools + + return ElixirTools + case self.ELM: + from solidlsp.language_servers.elm_language_server import ElmLanguageServer + + return ElmLanguageServer + case self.TERRAFORM: + from solidlsp.language_servers.terraform_ls import TerraformLS + + return TerraformLS + case self.SWIFT: + from solidlsp.language_servers.sourcekit_lsp import SourceKitLSP + + return SourceKitLSP + case self.BASH: + from solidlsp.language_servers.bash_language_server import BashLanguageServer + + return BashLanguageServer + case self.CRYSTAL: + from solidlsp.language_servers.crystal_language_server import CrystalLanguageServer + + return CrystalLanguageServer + case self.CUE: + from solidlsp.language_servers.cue_language_server import CueLanguageServer + + return CueLanguageServer + case self.YAML: + from solidlsp.language_servers.yaml_language_server import YamlLanguageServer + + return YamlLanguageServer + case self.JSON: + from solidlsp.language_servers.json_language_server import JsonLanguageServer + + return JsonLanguageServer + case self.TOML: + from solidlsp.language_servers.taplo_server import TaploServer + + return TaploServer + case self.ZIG: + from solidlsp.language_servers.zls import ZigLanguageServer + + return ZigLanguageServer + case self.NIX: + from solidlsp.language_servers.nixd_ls import NixLanguageServer + + return NixLanguageServer + case self.LUA: + from solidlsp.language_servers.lua_ls import LuaLanguageServer + + return LuaLanguageServer + + case self.LUAU: + from solidlsp.language_servers.luau_lsp import LuauLanguageServer + + return LuauLanguageServer + + case self.ERLANG: + from solidlsp.language_servers.erlang_language_server import ErlangLanguageServer + + return ErlangLanguageServer + case self.OCAML: + from solidlsp.language_servers.ocaml_lsp_server import OcamlLanguageServer + + return OcamlLanguageServer + case self.AL: + from solidlsp.language_servers.al_language_server import ALLanguageServer + + return ALLanguageServer + case self.REGO: + from solidlsp.language_servers.regal_server import RegalLanguageServer + + return RegalLanguageServer + case self.MARKDOWN: + from solidlsp.language_servers.marksman import Marksman + + return Marksman + case self.LATEX: + from solidlsp.language_servers.texlab_language_server import TexlabLanguageServer + + return TexlabLanguageServer + case self.R: + from solidlsp.language_servers.r_language_server import RLanguageServer + + return RLanguageServer + case self.SCALA: + from solidlsp.language_servers.scala_language_server import ScalaLanguageServer + + return ScalaLanguageServer + case self.JULIA: + from solidlsp.language_servers.julia_server import JuliaLanguageServer + + return JuliaLanguageServer + case self.FORTRAN: + from solidlsp.language_servers.fortran_language_server import FortranLanguageServer + + return FortranLanguageServer + case self.HASKELL: + from solidlsp.language_servers.haskell_language_server import HaskellLanguageServer + + return HaskellLanguageServer + case self.HAXE: + from solidlsp.language_servers.haxe_language_server import HaxeLanguageServer + + return HaxeLanguageServer + case self.LEAN4: + from solidlsp.language_servers.lean4_language_server import Lean4LanguageServer + + return Lean4LanguageServer + case self.FSHARP: + from solidlsp.language_servers.fsharp_language_server import FSharpLanguageServer + + return FSharpLanguageServer + case self.POWERSHELL: + from solidlsp.language_servers.powershell_language_server import PowerShellLanguageServer + + return PowerShellLanguageServer + case self.PASCAL: + from solidlsp.language_servers.pascal_server import PascalLanguageServer + + return PascalLanguageServer + case self.GROOVY: + from solidlsp.language_servers.groovy_language_server import GroovyLanguageServer + + return GroovyLanguageServer + case self.MATLAB: + from solidlsp.language_servers.matlab_language_server import MatlabLanguageServer + + return MatlabLanguageServer + case self.HLSL: + from solidlsp.language_servers.hlsl_language_server import HlslLanguageServer + + return HlslLanguageServer + case self.SYSTEMVERILOG: + from solidlsp.language_servers.systemverilog_server import SystemVerilogLanguageServer + + return SystemVerilogLanguageServer + case self.SOLIDITY: + from solidlsp.language_servers.solidity_language_server import SolidityLanguageServer + + return SolidityLanguageServer + case self.ANSIBLE: + from solidlsp.language_servers.ansible_language_server import AnsibleLanguageServer + + return AnsibleLanguageServer + case self.MSL: + from solidlsp.language_servers.msl_language_server import MslLanguageServer + + return MslLanguageServer + case self.BSL: + from solidlsp.language_servers.bsl_language_server import BSLLanguageServer + + return BSLLanguageServer + case self.ADA: + from solidlsp.language_servers.ada_language_server import AdaLanguageServer + + return AdaLanguageServer + case self.GDSCRIPT: + from solidlsp.language_servers.godot_language_server import GodotLanguageServer + + return GodotLanguageServer + case self.HTML: + from solidlsp.language_servers.vscode_html_language_server import VsCodeHtmlLanguageServer + + return VsCodeHtmlLanguageServer + case self.SCSS: + from solidlsp.language_servers.some_sass_language_server import SomeSassLanguageServer + + return SomeSassLanguageServer + case self.ANGULAR: + from solidlsp.language_servers.angular_language_server import AngularLanguageServer + + return AngularLanguageServer + case _: + raise ValueError(f"Unhandled language: {self}") + + @classmethod + def from_ls_class(cls, ls_class: type["SolidLanguageServer"]) -> Self: + """ + Get the Language enum value from a SolidLanguageServer class. + + :param ls_class: The SolidLanguageServer class to find the corresponding Language for + :return: The Language enum value + :raises ValueError: If the language server class is not supported + """ + for enum_instance in cls: + if enum_instance.get_ls_class() == ls_class: + return enum_instance + raise ValueError(f"Unhandled language server class: {ls_class}") + + +@dataclass(frozen=True) +class LanguageServerConfig: + """ + Configuration parameters for a language server instance + """ + + code_language: Language + """ + defines the language server to use + """ + workspace_folders: list[str] = field(default_factory=lambda: ["."]) + """ + list of workspace folders to be used by the language server and to be fully indexed by SolidLSP. + Paths can either be absolute or relative to the project root. + These folders must be descendants of the project root. + """ + additional_workspace_folders: list[str] = field(default_factory=list) + """ + list of additional workspace folders to be passed to the language server, but which are not to be indexed by SolidLSP. + Paths can either be absolute or relative to the project root. + These folders can potentially be outside of the project root, e.g. for cross-package reference support. + """ + trace_lsp_communication: bool = False + start_independent_lsp_process: bool = True + ignored_paths: list[str] = field(default_factory=list) + """Paths, dirs or glob-like patterns. The matching will follow the same logic as for .gitignore entries""" + encoding: str = "utf-8" + """File encoding to use when reading source files""" + + @classmethod + def from_dict(cls, env: dict) -> Self: + import inspect + + return cls(**{k: v for k, v in env.items() if k in inspect.signature(cls).parameters}) + + @staticmethod + def _absolute_workspace_folders(folders: list[str], project_root: str) -> list[str]: + abs_workspace_folders = [] + for path in folders: + if os.path.isabs(path): + abs_path = str(Path(path).resolve()) + else: + abs_path = os.path.realpath(os.path.join(project_root, path)) + if not os.path.exists(abs_path): + log.error("Workspace folder does not exist: %s; skipping", abs_path) + continue + if abs_path in abs_workspace_folders: + log.warning("Duplicate workspace folder: %s; skipping", abs_path) + continue + abs_workspace_folders.append(abs_path) + return abs_workspace_folders + + def get_absolute_workspace_folders(self, project_root: str) -> list[str]: + """ + Get the absolute paths of the workspace folders, resolving relative paths against the project root. + + :param project_root: The root path of the project + :return: List of absolute workspace folder paths + """ + return self._absolute_workspace_folders(self.workspace_folders, project_root) + + def get_absolute_additional_workspace_folders(self, project_root: str) -> list[str]: + """ + Get the absolute paths of the additional workspace folders, resolving relative paths against the project root. + + :param project_root: The root path of the project + :return: List of absolute additional workspace folder paths + """ + return self._absolute_workspace_folders(self.additional_workspace_folders, project_root) diff --git a/src/solidlsp/ls_exceptions.py b/src/solidlsp/ls_exceptions.py new file mode 100644 index 0000000..1a82f6b --- /dev/null +++ b/src/solidlsp/ls_exceptions.py @@ -0,0 +1,74 @@ +""" +This module contains the exceptions raised by the framework. +""" + +from solidlsp.ls_config import Language + + +class SolidLSPException(Exception): + def __init__(self, message: str, cause: Exception | None = None) -> None: + """ + Initializes the exception with the given message. + + :param message: the message describing the exception + :param cause: the original exception that caused this exception, if any. + For exceptions raised during request handling, this is typically + * an LSPError for errors returned by the LSP server + * LanguageServerTerminatedException for errors due to the language server having terminated. + """ + self.cause = cause + super().__init__(message) + + def is_language_server_terminated(self) -> bool: + """ + :return: True if the exception is caused by the language server having terminated as indicated + by the causing exception being an instance of LanguageServerTerminatedException. + """ + from .ls_process import LanguageServerTerminatedException + + return isinstance(self.cause, LanguageServerTerminatedException) + + def get_affected_language(self) -> Language | None: + """ + :return: the affected language for the case where the exception is caused by the language server having terminated + """ + from .ls_process import LanguageServerTerminatedException + + if isinstance(self.cause, LanguageServerTerminatedException): + return self.cause.language + return None + + def __str__(self) -> str: + """ + Returns a string representation of the exception. + """ + s = super().__str__() + if self.cause: + if "\n" in s: + s += "\n" + else: + s += " " + s += f"(caused by {self.cause})" + return s + + +class MetalsStaleLockError(SolidLSPException): + """ + Raised when a stale Metals H2 database lock is detected and the user + has configured fail-on-stale-lock behavior. + + A stale lock occurs when a previous Metals process crashed without + cleaning up its lock file, which can prevent proper AUTO_SERVER + coordination with new instances. + """ + + def __init__(self, lock_path: str, message: str | None = None) -> None: + self.lock_path = lock_path + if message is None: + message = ( + f"Stale Metals lock file detected at {lock_path}. " + "A previous Metals process may have crashed. " + "To resolve: remove the lock file manually, or set " + "on_stale_lock='auto-clean' in ls_specific_settings.scala." + ) + super().__init__(message) diff --git a/src/solidlsp/ls_process.py b/src/solidlsp/ls_process.py new file mode 100644 index 0000000..9fc84a8 --- /dev/null +++ b/src/solidlsp/ls_process.py @@ -0,0 +1,786 @@ +import asyncio +import json +import logging +import os +import socket +import subprocess +import threading +import time +from abc import ABC, abstractmethod +from collections.abc import Callable +from dataclasses import dataclass +from queue import Empty, Queue +from typing import IO, Any, AnyStr, cast + +from sensai.util.string import ToStringMixin + +from solidlsp.ls_config import Language +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.ls_request import LanguageServerRequest +from solidlsp.lsp_protocol_handler.lsp_requests import LspNotification +from solidlsp.lsp_protocol_handler.lsp_types import ErrorCodes +from solidlsp.lsp_protocol_handler.server import ( + ENCODING, + LSPError, + PayloadLike, + ProcessLaunchInfo, + StringDict, + content_length, + create_message, + make_error_response, + make_notification, + make_request, + make_response, +) +from solidlsp.util import subprocess_util + +log = logging.getLogger(__name__) + + +class LanguageServerTerminatedException(Exception): + """ + Exception raised when the language server process has terminated unexpectedly. + """ + + def __init__(self, message: str, language: Language, cause: Exception | None = None) -> None: + super().__init__(message) + self.message = message + self.language = language + self.cause = cause + + def __str__(self) -> str: + return f"LanguageServerTerminatedException: {self.message}" + (f"; Cause: {self.cause}" if self.cause else "") + + +class Request(ToStringMixin): + @dataclass + class Result: + payload: PayloadLike | None = None + error: Exception | None = None + + def is_error(self) -> bool: + return self.error is not None + + def __init__(self, request_id: int, method: str) -> None: + self._request_id = request_id + self._method = method + self._status = "pending" + self._result_queue: Queue[Request.Result] = Queue() + + def _tostring_includes(self) -> list[str]: + return ["_request_id", "_status", "_method"] + + def on_result(self, params: PayloadLike) -> None: + self._status = "completed" + self._result_queue.put(Request.Result(payload=params)) + + def on_error(self, err: Exception) -> None: + """ + :param err: the error that occurred while processing the request (typically an LSPError + for errors returned by the LS or LanguageServerTerminatedException if the error + is due to the language server process terminating unexpectedly). + """ + self._status = "error" + self._result_queue.put(Request.Result(error=err)) + + def get_result(self, timeout: float | None = None) -> Result: + try: + return self._result_queue.get(timeout=timeout) + except Empty as e: + if timeout is not None: + raise TimeoutError(f"Request timed out ({timeout=})") from e + raise e + + +class LanguageServerInterface(ABC): + """ + Represents an interface to a language server, providing methods for communicating with it using the + Language Server Protocol (LSP). + + It provides methods for sending requests, responses, and notifications to the server + and for registering handlers for requests and notifications from the server. + + Uses JSON-RPC 2.0 for communication with the server over stdin/stdout. + """ + + def __init__( + self, + language: Language, + determine_log_level: Callable[[str], int], + logger: Callable[[str, str, StringDict | str], None] | None = None, + request_timeout: float | None = None, + ) -> None: + """ + :param language: the language + :param determine_log_level: a function for log lines read from stderr, which determines the log level + :param logger: the trace logger function + :param request_timeout: the timeout, in seconds, for all requests sent to the language server. If None, no timeout will be applied. + """ + self.language = language + self._determine_log_level = determine_log_level + self.send = LanguageServerRequest(self) + """ + an object that can be used to send requests to the server + """ + self.notify = LspNotification(self.send_notification) + """ + an object that can be used to send notifications to the server + """ + self.request_id = 1 + """ + the next request id to use for requests + """ + self._pending_requests: dict[Any, Request] = {} + """ + maps request ids to Request objects that store the results or errors of the requests + """ + self.on_request_handlers: dict[str, Callable[[Any], Any]] = {} + self.on_notification_handlers: dict[str, Callable[[Any], None]] = {} + """ + maps method names to callback functions that handle notifications from the server + """ + self._notification_observers: list[Callable[[str, Any], None]] = [] + self._trace_log_fn = logger + self.task_counter = 0 + self._is_stopping = False + """ + Flag indicating whether the interface is in the process of stopping (or already stopped). + Exception handlers should check this flag to avoid logging errors that are caused by the interface being stopped. + """ + self._incoming_messages_queue: Queue[bytes] = Queue() + self._request_timeout = request_timeout + + # Add thread locks for shared resources to prevent race conditions + self._request_id_lock = threading.Lock() + self._response_handlers_lock = threading.Lock() + self._tasks_lock = threading.Lock() + + def set_request_timeout(self, timeout: float | None) -> None: + """ + :param timeout: the timeout, in seconds, for all requests sent to the language server. + """ + self._request_timeout = timeout + + @abstractmethod + def is_running(self) -> bool: + """ + Checks whether the language server interface is running + """ + + def start(self) -> None: + """ + Starts communication with the language server + """ + self._is_stopping = False + self._start() + + @abstractmethod + def _start(self) -> None: + """ + Starts the actual communication mechanism with the language server, calling `_handle_body` for each message received from the + language server + """ + + def stop(self, timeout: float = 5.0) -> None: + """ + Terminates communication with the language server, freeing resources + + :param timeout: the maximum time, in seconds, to wait for the language server to terminate before forcefully killing it + (if applicable) + """ + if not self.is_running(): + log.debug("Server process not running, skipping shutdown.") + return + + self._is_stopping = True + + log.info(f"Initiating shutdown with a {timeout}s timeout...") + self._stop(timeout) + + @abstractmethod + def _stop(self, timeout: float) -> None: + pass + + def _send_shutdown(self) -> None: + """ + Signals shutdown to the server + """ + log.info("Sending shutdown request to server") + self.send.shutdown() + log.info("Received shutdown response from server") + log.info("Sending exit notification to server") + self.notify.exit() + log.info("Sent exit notification to server") + + def _send_shutdown_in_thread(self) -> None: + """ + Signals shutdown to the server in a separate thread (requests can hang), + and waits for the thread to complete with a timeout. + + :param timeout: timeout, in seconds, to wait for the requests to be handled + """ + log.debug("Sending LSP shutdown request...") + shutdown_thread = threading.Thread(target=self._send_shutdown) + shutdown_thread.daemon = True + shutdown_thread.start() + shutdown_thread.join(timeout=2.0) + if shutdown_thread.is_alive(): + log.debug("LSP shutdown request timed out, proceeding to terminate...") + else: + log.debug("LSP shutdown request completed.") + + def _trace(self, src: str, dest: str, message: str | StringDict) -> None: + """ + Traces LS communication by logging the message with the source and destination of the message + """ + if self._trace_log_fn is not None: + self._trace_log_fn(src, dest, message) + + def _handle_body(self, body: bytes) -> None: + """ + Parses the body text received from the language server process and invokes the appropriate handler + """ + try: + self._receive_payload(json.loads(body)) + except OSError as ex: + log.error(f"Error processing payload: {ex}", exc_info=ex) + except UnicodeDecodeError as ex: + log.error(f"Decoding error for encoding={ENCODING}: {ex}") + except json.JSONDecodeError as ex: + log.error(f"JSON decoding error: {ex}") + + def _receive_payload(self, payload: StringDict) -> None: + """ + Determine if the payload received from server is for a request, response, or notification and invoke the appropriate handler + """ + self._trace("ls", "solidlsp", payload) + try: + if "method" in payload: + if "id" in payload: + self._request_handler(payload) + else: + self._notification_handler(payload) + elif "id" in payload: + self._response_handler(payload) + else: + log.error(f"Unknown payload type: {payload}") + except Exception as err: + log.error(f"Error handling server payload: {err}") + + def send_notification(self, method: str, params: dict | None = None) -> None: + """ + Send notification pertaining to the given method to the server with the given parameters + """ + self._send_payload(make_notification(method, params)) + + def send_response(self, request_id: Any, params: PayloadLike) -> None: + """ + Send response to the given request id to the server with the given parameters + """ + self._send_payload(make_response(request_id, params)) + + def send_error_response(self, request_id: Any, err: LSPError) -> None: + """ + Send error response to the given request id to the server with the given error + """ + self._send_payload(make_error_response(request_id, err)) + + def _cancel_pending_requests(self, exception: Exception) -> None: + """ + Cancel all pending requests by setting their results to an error + """ + with self._response_handlers_lock: + log.info("Cancelling %d pending language server requests", len(self._pending_requests)) + for request in self._pending_requests.values(): + log.info("Cancelling %s", request) + request.on_error(exception) + self._pending_requests.clear() + + def send_request(self, method: str, params: dict | None = None) -> PayloadLike: + """ + Send request to the server, register the request id, and wait for the response + """ + with self._request_id_lock: + request_id = self.request_id + self.request_id += 1 + + request = Request(request_id=request_id, method=method) + log.debug("Starting: %s", request) + + with self._response_handlers_lock: + self._pending_requests[request_id] = request + + self._send_payload(make_request(method, request_id, params)) + + log.debug("Waiting for response to request %s with params:\n%s", method, params) + result = request.get_result(timeout=self._request_timeout) + log.debug("Completed: %s", request) + + if result.is_error(): + raise SolidLSPException(f"Error processing request {method} with params:\n{params}", cause=result.error) from result.error + + log.debug("Returning result:\n%s", result.payload) + return result.payload + + @abstractmethod + def _send_payload(self, payload: StringDict) -> None: + """ + Send the given payload to the server + """ + + def on_request(self, method: str, cb: Callable[[Any], Any]) -> None: + """ + Register the callback function to handle requests from the server to the client for the given method + """ + self.on_request_handlers[method] = cb + + def on_notification(self, method: str, cb: Callable[[Any], None]) -> None: + """ + Register the callback function to handle notifications from the server to the client for the given method + """ + self.on_notification_handlers[method] = cb + + def on_any_notification(self, cb: Callable[[str, Any], None]) -> None: + """ + Register an observer that is invoked for every notification received from the server. + """ + self._notification_observers.append(cb) + + def _response_handler(self, response: StringDict) -> None: + """ + Handle the response received from the server for a request, using the id to determine the request + """ + response_id = response["id"] + with self._response_handlers_lock: + request = self._pending_requests.pop(response_id, None) + if request is None and isinstance(response_id, str) and response_id.isdigit(): + request = self._pending_requests.pop(int(response_id), None) + + if request is None: # need to convert response_id to the right type + log.debug("Request interrupted by user or not found for ID %s", response_id) + return + + if "result" in response and "error" not in response: + request.on_result(response["result"]) + elif "result" not in response and "error" in response: + request.on_error(LSPError.from_lsp(response["error"])) + else: + request.on_error(LSPError(ErrorCodes.InvalidRequest, "")) + + def _request_handler(self, response: StringDict) -> None: + """ + Handle the request received from the server: call the appropriate callback function and return the result + """ + method = response.get("method", "") + params = response.get("params") + request_id = response.get("id") + handler = self.on_request_handlers.get(method) + if not handler: + self.send_error_response( + request_id, + LSPError( + ErrorCodes.MethodNotFound, + f"method '{method}' not handled on client.", + ), + ) + return + try: + self.send_response(request_id, handler(params)) + except LSPError as ex: + self.send_error_response(request_id, ex) + except Exception as ex: + self.send_error_response(request_id, LSPError(ErrorCodes.InternalError, str(ex))) + + def _notification_handler(self, response: StringDict) -> None: + """ + Handle the notification received from the server: call the appropriate callback function + """ + method = response.get("method", "") + params = response.get("params") + + for observer in self._notification_observers: + try: + observer(method, params) + except asyncio.CancelledError: + return + except Exception as ex: + if not self._is_stopping: + log.error("Error handling notification observer for method '%s': %s", method, ex, exc_info=ex) + + handler = self.on_notification_handlers.get(method) + if not handler: + log.warning("Unhandled method '%s'", method) + return + try: + handler(params) + except asyncio.CancelledError: + return + except Exception as ex: + if not self._is_stopping: + log.error("Error handling notification for method '%s': %s", method, ex, exc_info=ex) + + +class StdioLanguageServer(LanguageServerInterface): + """ + Represents a language server interface where the language server is launched as a subprocess + and communication takes place over the process' stdin/stdout streams. + """ + + def __init__( + self, + process_launch_info: ProcessLaunchInfo, + language: Language, + determine_log_level: Callable[[str], int], + logger: Callable[[str, str, StringDict | str], None] | None = None, + start_independent_lsp_process: bool = True, + request_timeout: float | None = None, + ) -> None: + """ + :param process_launch_info: the information required to launch the language server process + :param language: the language + :param determine_log_level: a function for log lines read from stderr, which determines the log level + :param logger: the trace logger function + :param start_independent_lsp_process: whether to start the language server process in an independent process group + :param request_timeout: the timeout, in seconds, for all requests sent to the language server. If None, no timeout will be applied. + """ + super().__init__(language, determine_log_level, logger, request_timeout) + + self.process_launch_info = process_launch_info + self.process: subprocess.Popen[bytes] | None = None + self.start_independent_lsp_process = start_independent_lsp_process + + self._stdin_lock = threading.Lock() + + def is_running(self) -> bool: + return self.process is not None and self.process.returncode is None + + def _start(self) -> None: + child_proc_env = os.environ.copy() + child_proc_env.update(self.process_launch_info.env) + + cmd = subprocess_util.convert_shell_cmd(self.process_launch_info.cmd) + log.info("Starting language server process via command: %s", self.process_launch_info.cmd) + kwargs = subprocess_util.subprocess_kwargs() + kwargs["start_new_session"] = self.start_independent_lsp_process + # the language server is launched with binary (bytes) pipes; the cast is needed because the + # presence of platform-specific **kwargs prevents ty from selecting the bytes Popen overload + process = cast( + "subprocess.Popen[bytes]", + subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stdin=subprocess.PIPE, + stderr=subprocess.PIPE, + env=child_proc_env, + cwd=self.process_launch_info.cwd, + shell=True, + **kwargs, + ), + ) + self.process = process + + # Check if process terminated immediately + if process.returncode is not None: + log.error("Language server has already terminated/could not be started") + # Process has already terminated + stderr_data = process.stderr.read() if process.stderr else b"" + error_message = stderr_data.decode("utf-8", errors="replace") + raise RuntimeError(f"Process terminated immediately with code {process.returncode}. Error: {error_message}") + + # start threads to read stdout and stderr of the process + threading.Thread( + target=self._read_ls_process_stdout, + name=f"LSP-stdout-reader:{self.language.value}", + daemon=True, + ).start() + threading.Thread( + target=self._read_ls_process_stderr, + name=f"LSP-stderr-reader:{self.language.value}", + daemon=True, + ).start() + + def _stop(self, timeout: float) -> None: + if self.process is None: + log.debug("Server process is None, cannot shutdown.") + return + + try: + # send LSP shutdown and close stdin to signal no more input + try: + self._send_shutdown_in_thread() + self._safely_close_pipe(self.process.stdin) + except Exception as e: + log.debug(f"Exception during graceful shutdown: {e}") + # Ignore errors here, we are proceeding to terminate anyway. + # terminate the process + subprocess_util.terminate_process_tree_with_kill_fallback( + self.process, terminate_timeout=timeout, process_name=f"LS[{self.language.value}]" + ) + finally: + self.process = None + + @staticmethod + def _safely_close_pipe(pipe: IO[AnyStr] | None) -> None: + """Safely close a pipe, ignoring any exceptions.""" + if pipe and not pipe.closed: + try: + pipe.close() + except Exception: + pass + + def _read_bytes_from_process(self, process, stream, num_bytes) -> bytes: + """Read exactly num_bytes from process stdout""" + data = b"" + while len(data) < num_bytes: + chunk = stream.read(num_bytes - len(data)) + if not chunk: + if process.poll() is not None: + raise LanguageServerTerminatedException( + f"Process terminated while trying to read response (read {len(data)} of {num_bytes} bytes before termination)", + language=self.language, + ) + # Process still running but no data available yet, retry after a short delay + time.sleep(0.01) + continue + data += chunk + return data + + def _read_ls_process_stdout(self) -> None: + """ + Continuously read from the language server process stdout and handle the messages + invoking the registered response and notification handlers + """ + exception: Exception | None = None + try: + while self.process and self.process.stdout: + if self.process.poll() is not None: # process has terminated + break + line = self.process.stdout.readline() + if not line: + continue + try: + num_bytes = content_length(line) + except ValueError: + continue + if num_bytes is None: + continue + while line and line.strip(): + line = self.process.stdout.readline() + if not line: + continue + body = self._read_bytes_from_process(self.process, self.process.stdout, num_bytes) + + self._handle_body(body) + except LanguageServerTerminatedException as e: + exception = e + except (BrokenPipeError, ConnectionResetError) as e: + exception = LanguageServerTerminatedException("Language server process terminated while reading stdout", self.language, cause=e) + except Exception as e: + exception = LanguageServerTerminatedException( + "Unexpected error while reading stdout from language server process", self.language, cause=e + ) + log.info("Language server stdout reader thread has terminated") + if not self._is_stopping: + if exception is None: + exception = LanguageServerTerminatedException("Language server stdout read process terminated unexpectedly", self.language) + log.error(str(exception)) + self._cancel_pending_requests(exception) + + def _read_ls_process_stderr(self) -> None: + """ + Continuously read from the language server process stderr and log the messages + """ + try: + while self.process and self.process.stderr: + if self.process.poll() is not None: + # process has terminated + break + line = self.process.stderr.readline() + if not line: + continue + line_str = line.decode(ENCODING, errors="replace") + level = self._determine_log_level(line_str) + log.log(level, line_str) + except Exception as e: + log.error("Error while reading stderr from language server process: %s", e, exc_info=e) + if not self._is_stopping: + log.error("Language server stderr reader thread terminated unexpectedly") + else: + log.info("Language server stderr reader thread has terminated") + + def _send_payload(self, payload: StringDict) -> None: + if not self.process or not self.process.stdin: + return + self._trace("solidlsp", "ls", payload) + msg = create_message(payload) + + # Use lock to prevent concurrent writes to stdin that cause buffer corruption + with self._stdin_lock: + try: + self.process.stdin.writelines(msg) + self.process.stdin.flush() + except (BrokenPipeError, ConnectionResetError, OSError) as e: + # Log the error but don't raise to prevent cascading failures + log.error(f"Failed to write to stdin: {e}") + return + + +@dataclass +class TCPConnectionInfo: + """Connection parameters for an LSP server reachable over a TCP socket.""" + + host: str = "127.0.0.1" + port: int = 6008 + connection_timeout: float = 30.0 + retry_interval: float = 1.0 + + +class TCPLanguageServer(LanguageServerInterface): + """LanguageServerInterface that connects to an already-running LSP server over TCP. + + Unlike :class:`StdioLanguageServer`, this class does not own the server process — it + connects to an externally-managed server (e.g. the Godot editor) and simply closes + the socket on stop. No LSP ``shutdown``/``exit`` sequence is sent, because the remote + server is expected to keep running after Serena disconnects. + """ + + def __init__( + self, + connection_info: TCPConnectionInfo, + language: Language, + determine_log_level: Callable[[str], int], + logger: Callable[[str, str, StringDict | str], None] | None = None, + request_timeout: float | None = None, + ) -> None: + super().__init__(language, determine_log_level, logger, request_timeout) + self._connection_info = connection_info + self._sock: socket.socket | None = None + self._file: Any = None # socket.makefile("rb") - buffered reader + self._write_lock = threading.Lock() + + def is_running(self) -> bool: + return self._sock is not None + + def _start(self) -> None: + deadline = time.monotonic() + self._connection_info.connection_timeout + last_exc: Exception | None = None + while True: + try: + sock = socket.create_connection((self._connection_info.host, self._connection_info.port), timeout=5.0) + sock.settimeout(None) + self._sock = sock + self._file = sock.makefile("rb") + log.info("TCPLanguageServer connected to %s:%d", self._connection_info.host, self._connection_info.port) + break + except OSError as exc: + last_exc = exc + remaining = deadline - time.monotonic() + if remaining <= 0: + raise RuntimeError( + f"Could not connect to {self._connection_info.host}:{self._connection_info.port} " + f"within {self._connection_info.connection_timeout}s" + ) from last_exc + log.debug( + "TCPLanguageServer: connection failed (%s), retrying in %.1fs (%.0fs left)", + exc, + self._connection_info.retry_interval, + remaining, + ) + time.sleep(min(self._connection_info.retry_interval, remaining)) + + threading.Thread( + target=self._read_loop, + name=f"LSP-tcp-reader:{self.language.value}", + daemon=True, + ).start() + + def _stop(self, timeout: float) -> None: + # Close the socket first — this unblocks any readline() in the reader thread. + # BufferedReader.close() acquires an internal lock that readline() holds while + # blocked waiting for data; closing the socket first causes readline() to return + # with an error, releasing the lock before we call f.close(). + sock = self._sock + self._sock = None + f = self._file + self._file = None + if sock: + try: + sock.shutdown(socket.SHUT_RDWR) + except Exception: + pass + try: + sock.close() + except Exception: + pass + if f: + try: + f.close() + except Exception: + pass + + def _send_payload(self, payload: StringDict) -> None: + sock = self._sock + if sock is None: + return + self._trace("solidlsp", "ls", payload) + data = b"".join(create_message(payload)) + with self._write_lock: + try: + sock.sendall(data) + except OSError as e: + log.error("Failed to write to TCP language server: %s", e) + self._sock = None + self._file = None + self._cancel_pending_requests(LanguageServerTerminatedException("TCP send error", self.language, cause=e)) + + def _read_loop(self) -> None: + """Read Content-Length-framed LSP messages from the TCP socket and dispatch them.""" + exception: Exception | None = None + try: + while self._sock is not None: + f = self._file + if f is None: + break + try: + line = f.readline() + except OSError as exc: + if not self._is_stopping: + exception = LanguageServerTerminatedException("TCP read error", self.language, cause=exc) + break + if not line: + break + try: + num_bytes = content_length(line) + except ValueError: + continue + if num_bytes is None: + continue + while line and line.strip(): + try: + line = f.readline() + except OSError: + line = b"" + break + if not line: + continue + try: + body = f.read(num_bytes) + except OSError as exc: + if not self._is_stopping: + exception = LanguageServerTerminatedException("TCP read error", self.language, cause=exc) + break + if len(body) < num_bytes: + break + self._handle_body(body) + except Exception as exc: + exception = LanguageServerTerminatedException("Unexpected error in TCP language server read loop", self.language, cause=exc) + log.info("TCP language server read loop has terminated") + if not self._is_stopping: + if exception is None: + exception = LanguageServerTerminatedException("TCP language server read loop terminated unexpectedly", self.language) + log.error(str(exception)) + self._cancel_pending_requests(exception) + # Clear the socket so is_running() returns False, allowing _ensure_functional_ls + # to detect the broken connection and restart the language server. + self._sock = None + self._file = None diff --git a/src/solidlsp/ls_request.py b/src/solidlsp/ls_request.py new file mode 100644 index 0000000..e2a42ce --- /dev/null +++ b/src/solidlsp/ls_request.py @@ -0,0 +1,383 @@ +from typing import TYPE_CHECKING, Any, Union + +from solidlsp.lsp_protocol_handler import lsp_types + +if TYPE_CHECKING: + from .ls_process import LanguageServerInterface + + +class LanguageServerRequest: + def __init__(self, handler: "LanguageServerInterface"): + self.handler = handler + + def _send_request(self, method: str, params: Any | None = None) -> Any: + return self.handler.send_request(method, params) + + def implementation(self, params: lsp_types.ImplementationParams) -> Union["lsp_types.Definition", list["lsp_types.LocationLink"], None]: + """A request to resolve the implementation locations of a symbol at a given text + document position. The request's parameter is of type [TextDocumentPositionParams] + (#TextDocumentPositionParams) the response is of type {@link Definition} or a + Thenable that resolves to such. + """ + return self._send_request("textDocument/implementation", params) + + def type_definition( + self, params: lsp_types.TypeDefinitionParams + ) -> Union["lsp_types.Definition", list["lsp_types.LocationLink"], None]: + """A request to resolve the type definition locations of a symbol at a given text + document position. The request's parameter is of type [TextDocumentPositionParams] + (#TextDocumentPositionParams) the response is of type {@link Definition} or a + Thenable that resolves to such. + """ + return self._send_request("textDocument/typeDefinition", params) + + def document_color(self, params: lsp_types.DocumentColorParams) -> list["lsp_types.ColorInformation"]: + """A request to list all color symbols found in a given text document. The request's + parameter is of type {@link DocumentColorParams} the + response is of type {@link ColorInformation ColorInformation[]} or a Thenable + that resolves to such. + """ + return self._send_request("textDocument/documentColor", params) + + def color_presentation(self, params: lsp_types.ColorPresentationParams) -> list["lsp_types.ColorPresentation"]: + """A request to list all presentation for a color. The request's + parameter is of type {@link ColorPresentationParams} the + response is of type {@link ColorInformation ColorInformation[]} or a Thenable + that resolves to such. + """ + return self._send_request("textDocument/colorPresentation", params) + + def folding_range(self, params: lsp_types.FoldingRangeParams) -> list["lsp_types.FoldingRange"] | None: + """A request to provide folding ranges in a document. The request's + parameter is of type {@link FoldingRangeParams}, the + response is of type {@link FoldingRangeList} or a Thenable + that resolves to such. + """ + return self._send_request("textDocument/foldingRange", params) + + def declaration(self, params: lsp_types.DeclarationParams) -> Union["lsp_types.Declaration", list["lsp_types.LocationLink"], None]: + """A request to resolve the type definition locations of a symbol at a given text + document position. The request's parameter is of type [TextDocumentPositionParams] + (#TextDocumentPositionParams) the response is of type {@link Declaration} + or a typed array of {@link DeclarationLink} or a Thenable that resolves + to such. + """ + return self._send_request("textDocument/declaration", params) + + def selection_range(self, params: lsp_types.SelectionRangeParams) -> list["lsp_types.SelectionRange"] | None: + """A request to provide selection ranges in a document. The request's + parameter is of type {@link SelectionRangeParams}, the + response is of type {@link SelectionRange SelectionRange[]} or a Thenable + that resolves to such. + """ + return self._send_request("textDocument/selectionRange", params) + + def prepare_call_hierarchy(self, params: lsp_types.CallHierarchyPrepareParams) -> list["lsp_types.CallHierarchyItem"] | None: + """A request to result a `CallHierarchyItem` in a document at a given position. + Can be used as an input to an incoming or outgoing call hierarchy. + + @since 3.16.0 + """ + return self._send_request("textDocument/prepareCallHierarchy", params) + + def incoming_calls(self, params: lsp_types.CallHierarchyIncomingCallsParams) -> list["lsp_types.CallHierarchyIncomingCall"] | None: + """A request to resolve the incoming calls for a given `CallHierarchyItem`. + + @since 3.16.0 + """ + return self._send_request("callHierarchy/incomingCalls", params) + + def outgoing_calls(self, params: lsp_types.CallHierarchyOutgoingCallsParams) -> list["lsp_types.CallHierarchyOutgoingCall"] | None: + """A request to resolve the outgoing calls for a given `CallHierarchyItem`. + + @since 3.16.0 + """ + return self._send_request("callHierarchy/outgoingCalls", params) + + def semantic_tokens_full(self, params: lsp_types.SemanticTokensParams) -> Union["lsp_types.SemanticTokens", None]: + """@since 3.16.0""" + return self._send_request("textDocument/semanticTokens/full", params) + + def semantic_tokens_delta( + self, params: lsp_types.SemanticTokensDeltaParams + ) -> Union["lsp_types.SemanticTokens", "lsp_types.SemanticTokensDelta", None]: + """@since 3.16.0""" + return self._send_request("textDocument/semanticTokens/full/delta", params) + + def semantic_tokens_range(self, params: lsp_types.SemanticTokensRangeParams) -> Union["lsp_types.SemanticTokens", None]: + """@since 3.16.0""" + return self._send_request("textDocument/semanticTokens/range", params) + + def linked_editing_range(self, params: lsp_types.LinkedEditingRangeParams) -> Union["lsp_types.LinkedEditingRanges", None]: + """A request to provide ranges that can be edited together. + + @since 3.16.0 + """ + return self._send_request("textDocument/linkedEditingRange", params) + + def will_create_files(self, params: lsp_types.CreateFilesParams) -> Union["lsp_types.WorkspaceEdit", None]: + """The will create files request is sent from the client to the server before files are actually + created as long as the creation is triggered from within the client. + + @since 3.16.0 + """ + return self._send_request("workspace/willCreateFiles", params) + + def will_rename_files(self, params: lsp_types.RenameFilesParams) -> Union["lsp_types.WorkspaceEdit", None]: + """The will rename files request is sent from the client to the server before files are actually + renamed as long as the rename is triggered from within the client. + + @since 3.16.0 + """ + return self._send_request("workspace/willRenameFiles", params) + + def will_delete_files(self, params: lsp_types.DeleteFilesParams) -> Union["lsp_types.WorkspaceEdit", None]: + """The did delete files notification is sent from the client to the server when + files were deleted from within the client. + + @since 3.16.0 + """ + return self._send_request("workspace/willDeleteFiles", params) + + def moniker(self, params: lsp_types.MonikerParams) -> list["lsp_types.Moniker"] | None: + """A request to get the moniker of a symbol at a given text document position. + The request parameter is of type {@link TextDocumentPositionParams}. + The response is of type {@link Moniker Moniker[]} or `null`. + """ + return self._send_request("textDocument/moniker", params) + + def prepare_type_hierarchy(self, params: lsp_types.TypeHierarchyPrepareParams) -> list["lsp_types.TypeHierarchyItem"] | None: + """A request to result a `TypeHierarchyItem` in a document at a given position. + Can be used as an input to a subtypes or supertypes type hierarchy. + + @since 3.17.0 + """ + return self._send_request("textDocument/prepareTypeHierarchy", params) + + def type_hierarchy_supertypes(self, params: lsp_types.TypeHierarchySupertypesParams) -> list["lsp_types.TypeHierarchyItem"] | None: + """A request to resolve the supertypes for a given `TypeHierarchyItem`. + + @since 3.17.0 + """ + return self._send_request("typeHierarchy/supertypes", params) + + def type_hierarchy_subtypes(self, params: lsp_types.TypeHierarchySubtypesParams) -> list["lsp_types.TypeHierarchyItem"] | None: + """A request to resolve the subtypes for a given `TypeHierarchyItem`. + + @since 3.17.0 + """ + return self._send_request("typeHierarchy/subtypes", params) + + def inline_value(self, params: lsp_types.InlineValueParams) -> list["lsp_types.InlineValue"] | None: + """A request to provide inline values in a document. The request's parameter is of + type {@link InlineValueParams}, the response is of type + {@link InlineValue InlineValue[]} or a Thenable that resolves to such. + + @since 3.17.0 + """ + return self._send_request("textDocument/inlineValue", params) + + def inlay_hint(self, params: lsp_types.InlayHintParams) -> list["lsp_types.InlayHint"] | None: + """A request to provide inlay hints in a document. The request's parameter is of + type {@link InlayHintsParams}, the response is of type + {@link InlayHint InlayHint[]} or a Thenable that resolves to such. + + @since 3.17.0 + """ + return self._send_request("textDocument/inlayHint", params) + + def resolve_inlay_hint(self, params: lsp_types.InlayHint) -> "lsp_types.InlayHint": + """A request to resolve additional properties for an inlay hint. + The request's parameter is of type {@link InlayHint}, the response is + of type {@link InlayHint} or a Thenable that resolves to such. + + @since 3.17.0 + """ + return self._send_request("inlayHint/resolve", params) + + def text_document_diagnostic(self, params: lsp_types.DocumentDiagnosticParams) -> "lsp_types.DocumentDiagnosticReport": + """The document diagnostic request definition. + + @since 3.17.0 + """ + return self._send_request("textDocument/diagnostic", params) + + def workspace_diagnostic(self, params: lsp_types.WorkspaceDiagnosticParams) -> "lsp_types.WorkspaceDiagnosticReport": + """The workspace diagnostic request definition. + + @since 3.17.0 + """ + return self._send_request("workspace/diagnostic", params) + + def initialize(self, params: lsp_types.InitializeParams) -> "lsp_types.InitializeResult": + """The initialize request is sent from the client to the server. + It is sent once as the request after starting up the server. + The requests parameter is of type {@link InitializeParams} + the response if of type {@link InitializeResult} of a Thenable that + resolves to such. + """ + return self._send_request("initialize", params) + + def shutdown(self) -> None: + """A shutdown request is sent from the client to the server. + It is sent once when the client decides to shutdown the + server. The only notification that is sent after a shutdown request + is the exit event. + """ + return self._send_request("shutdown") + + def will_save_wait_until(self, params: lsp_types.WillSaveTextDocumentParams) -> list["lsp_types.TextEdit"] | None: + """A document will save request is sent from the client to the server before + the document is actually saved. The request can return an array of TextEdits + which will be applied to the text document before it is saved. Please note that + clients might drop results if computing the text edits took too long or if a + server constantly fails on this request. This is done to keep the save fast and + reliable. + """ + return self._send_request("textDocument/willSaveWaitUntil", params) + + def completion(self, params: lsp_types.CompletionParams) -> Union[list["lsp_types.CompletionItem"], "lsp_types.CompletionList", None]: + """Request to request completion at a given text document position. The request's + parameter is of type {@link TextDocumentPosition} the response + is of type {@link CompletionItem CompletionItem[]} or {@link CompletionList} + or a Thenable that resolves to such. + + The request can delay the computation of the {@link CompletionItem.detail `detail`} + and {@link CompletionItem.documentation `documentation`} properties to the `completionItem/resolve` + request. However, properties that are needed for the initial sorting and filtering, like `sortText`, + `filterText`, `insertText`, and `textEdit`, must not be changed during resolve. + """ + return self._send_request("textDocument/completion", params) + + def resolve_completion_item(self, params: lsp_types.CompletionItem) -> "lsp_types.CompletionItem": + """Request to resolve additional information for a given completion item.The request's + parameter is of type {@link CompletionItem} the response + is of type {@link CompletionItem} or a Thenable that resolves to such. + """ + return self._send_request("completionItem/resolve", params) + + def hover(self, params: lsp_types.HoverParams) -> Union["lsp_types.Hover", None]: + """Request to request hover information at a given text document position. The request's + parameter is of type {@link TextDocumentPosition} the response is of + type {@link Hover} or a Thenable that resolves to such. + """ + return self._send_request("textDocument/hover", params) + + def signature_help(self, params: lsp_types.SignatureHelpParams) -> Union["lsp_types.SignatureHelp", None]: + return self._send_request("textDocument/signatureHelp", params) + + def definition(self, params: lsp_types.DefinitionParams) -> Union["lsp_types.Definition", list["lsp_types.LocationLink"], None]: + """A request to resolve the definition location of a symbol at a given text + document position. The request's parameter is of type [TextDocumentPosition] + (#TextDocumentPosition) the response is of either type {@link Definition} + or a typed array of {@link DefinitionLink} or a Thenable that resolves + to such. + """ + return self._send_request("textDocument/definition", params) + + def references(self, params: lsp_types.ReferenceParams) -> list["lsp_types.Location"] | None: + """A request to resolve project-wide references for the symbol denoted + by the given text document position. The request's parameter is of + type {@link ReferenceParams} the response is of type + {@link Location Location[]} or a Thenable that resolves to such. + """ + return self._send_request("textDocument/references", params) + + def document_highlight(self, params: lsp_types.DocumentHighlightParams) -> list["lsp_types.DocumentHighlight"] | None: + """Request to resolve a {@link DocumentHighlight} for a given + text document position. The request's parameter is of type [TextDocumentPosition] + (#TextDocumentPosition) the request response is of type [DocumentHighlight[]] + (#DocumentHighlight) or a Thenable that resolves to such. + """ + return self._send_request("textDocument/documentHighlight", params) + + def document_symbol( + self, params: lsp_types.DocumentSymbolParams + ) -> list["lsp_types.SymbolInformation"] | list["lsp_types.DocumentSymbol"] | None: + """A request to list all symbols found in a given text document. The request's + parameter is of type {@link TextDocumentIdentifier} the + response is of type {@link SymbolInformation SymbolInformation[]} or a Thenable + that resolves to such. + """ + return self._send_request("textDocument/documentSymbol", params) + + def code_action(self, params: lsp_types.CodeActionParams) -> list[Union["lsp_types.Command", "lsp_types.CodeAction"]] | None: + """A request to provide commands for the given text document and range.""" + return self._send_request("textDocument/codeAction", params) + + def resolve_code_action(self, params: lsp_types.CodeAction) -> "lsp_types.CodeAction": + """Request to resolve additional information for a given code action.The request's + parameter is of type {@link CodeAction} the response + is of type {@link CodeAction} or a Thenable that resolves to such. + """ + return self._send_request("codeAction/resolve", params) + + def workspace_symbol( + self, params: lsp_types.WorkspaceSymbolParams + ) -> list["lsp_types.SymbolInformation"] | list["lsp_types.WorkspaceSymbol"] | None: + """A request to list project-wide symbols matching the query string given + by the {@link WorkspaceSymbolParams}. The response is + of type {@link SymbolInformation SymbolInformation[]} or a Thenable that + resolves to such. + + @since 3.17.0 - support for WorkspaceSymbol in the returned data. Clients + need to advertise support for WorkspaceSymbols via the client capability + `workspace.symbol.resolveSupport`. + """ + return self._send_request("workspace/symbol", params) + + def resolve_workspace_symbol(self, params: lsp_types.WorkspaceSymbol) -> "lsp_types.WorkspaceSymbol": + """A request to resolve the range inside the workspace + symbol's location. + + @since 3.17.0 + """ + return self._send_request("workspaceSymbol/resolve", params) + + def code_lens(self, params: lsp_types.CodeLensParams) -> list["lsp_types.CodeLens"] | None: + """A request to provide code lens for the given text document.""" + return self._send_request("textDocument/codeLens", params) + + def resolve_code_lens(self, params: lsp_types.CodeLens) -> "lsp_types.CodeLens": + """A request to resolve a command for a given code lens.""" + return self._send_request("codeLens/resolve", params) + + def document_link(self, params: lsp_types.DocumentLinkParams) -> list["lsp_types.DocumentLink"] | None: + """A request to provide document links""" + return self._send_request("textDocument/documentLink", params) + + def resolve_document_link(self, params: lsp_types.DocumentLink) -> "lsp_types.DocumentLink": + """Request to resolve additional information for a given document link. The request's + parameter is of type {@link DocumentLink} the response + is of type {@link DocumentLink} or a Thenable that resolves to such. + """ + return self._send_request("documentLink/resolve", params) + + def formatting(self, params: lsp_types.DocumentFormattingParams) -> list["lsp_types.TextEdit"] | None: + """A request to to format a whole document.""" + return self._send_request("textDocument/formatting", params) + + def range_formatting(self, params: lsp_types.DocumentRangeFormattingParams) -> list["lsp_types.TextEdit"] | None: + """A request to to format a range in a document.""" + return self._send_request("textDocument/rangeFormatting", params) + + def on_type_formatting(self, params: lsp_types.DocumentOnTypeFormattingParams) -> list["lsp_types.TextEdit"] | None: + """A request to format a document on type.""" + return self._send_request("textDocument/onTypeFormatting", params) + + def rename(self, params: lsp_types.RenameParams) -> Union["lsp_types.WorkspaceEdit", None]: + """A request to rename a symbol.""" + return self._send_request("textDocument/rename", params) + + def prepare_rename(self, params: lsp_types.PrepareRenameParams) -> Union["lsp_types.PrepareRenameResult", None]: + """A request to test and perform the setup necessary for a rename. + + @since 3.16 - support for default behavior + """ + return self._send_request("textDocument/prepareRename", params) + + def execute_command(self, params: lsp_types.ExecuteCommandParams) -> Union["lsp_types.LSPAny", None]: + """A request send from the client to the server to execute a command. The request might return + a workspace edit which the client will apply to the workspace. + """ + return self._send_request("workspace/executeCommand", params) diff --git a/src/solidlsp/ls_types.py b/src/solidlsp/ls_types.py new file mode 100644 index 0000000..24d5126 --- /dev/null +++ b/src/solidlsp/ls_types.py @@ -0,0 +1,425 @@ +""" +Defines wrapper objects around the types returned by LSP to ensure decoupling between LSP versions and SolidLSP +""" + +from __future__ import annotations + +from enum import Enum, IntEnum +from typing import TYPE_CHECKING, NotRequired, Union + +from sensai.util.helper import mark_used +from typing_extensions import TypedDict + +from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity, SymbolKind + +if TYPE_CHECKING: + from .ls import SymbolBody + +# a lot of code relied on a previously duplicated SymbolKind definition here. +# This line is kept to avoid breaking downstream imports +mark_used(SymbolKind) + + +URI = str +DocumentUri = str +Uint = int +RegExp = str + + +class Position(TypedDict): + r"""Position in a text document expressed as zero-based line and character + offset. Prior to 3.17 the offsets were always based on a UTF-16 string + representation. So a string of the form `a𐐀b` the character offset of the + character `a` is 0, the character offset of `𐐀` is 1 and the character + offset of b is 3 since `𐐀` is represented using two code units in UTF-16. + Since 3.17 clients and servers can agree on a different string encoding + representation (e.g. UTF-8). The client announces it's supported encoding + via the client capability [`general.positionEncodings`](#clientCapabilities). + The value is an array of position encodings the client supports, with + decreasing preference (e.g. the encoding at index `0` is the most preferred + one). To stay backwards compatible the only mandatory encoding is UTF-16 + represented via the string `utf-16`. The server can pick one of the + encodings offered by the client and signals that encoding back to the + client via the initialize result's property + [`capabilities.positionEncoding`](#serverCapabilities). If the string value + `utf-16` is missing from the client's capability `general.positionEncodings` + servers can safely assume that the client supports UTF-16. If the server + omits the position encoding in its initialize result the encoding defaults + to the string value `utf-16`. Implementation considerations: since the + conversion from one encoding into another requires the content of the + file / line the conversion is best done where the file is read which is + usually on the server side. + + Positions are line end character agnostic. So you can not specify a position + that denotes `\r|\n` or `\n|` where `|` represents the character offset. + + @since 3.17.0 - support for negotiated position encoding. + """ + + line: Uint + """ Line position in a document (zero-based). + + If a line number is greater than the number of lines in a document, it defaults back to the number of lines in the document. + If a line number is negative, it defaults to 0. """ + character: Uint + """ Character offset on a line in a document (zero-based). + + The meaning of this offset is determined by the negotiated + `PositionEncodingKind`. + + If the character value is greater than the line length it defaults back to the + line length. """ + + +class Range(TypedDict): + """A range in a text document expressed as (zero-based) start and end positions. + + If you want to specify a range that contains a line including the line ending + character(s) then use an end position denoting the start of the next line. + For example: + ```ts + { + start: { line: 5, character: 23 } + end : { line 6, character : 0 } + } + ``` + """ + + start: Position + """ The range's start position. """ + end: Position + """ The range's end position. """ + + +class Location(TypedDict): + """Represents a location inside a resource, such as a line + inside a text file. + """ + + uri: DocumentUri + range: Range + absolutePath: str + relativePath: str | None + + +class CompletionItemKind(IntEnum): + """The kind of a completion entry.""" + + Text = 1 + Method = 2 + Function = 3 + Constructor = 4 + Field = 5 + Variable = 6 + Class = 7 + Interface = 8 + Module = 9 + Property = 10 + Unit = 11 + Value = 12 + Enum = 13 + Keyword = 14 + Snippet = 15 + Color = 16 + File = 17 + Reference = 18 + Folder = 19 + EnumMember = 20 + Constant = 21 + Struct = 22 + Event = 23 + Operator = 24 + TypeParameter = 25 + + +class CompletionItem(TypedDict): + """A completion item represents a text snippet that is + proposed to complete text that is being typed. + """ + + completionText: str + """ The completionText of this completion item. + + The completionText property is also by default the text that + is inserted when selecting this completion.""" + + kind: CompletionItemKind + """ The kind of this completion item. Based of the kind + an icon is chosen by the editor. """ + + detail: NotRequired[str] + """ A human-readable string with additional information + about this item, like type or symbol information. """ + + +class SymbolTag(IntEnum): + """Symbol tags are extra annotations that tweak the rendering of a symbol. + + @since 3.16 + """ + + Deprecated = 1 + """ Render a symbol as obsolete, usually using a strike-out. """ + + +class UnifiedSymbolInformation(TypedDict): + """ + Represents information about programming constructs like variables, classes, + interfaces etc. + + This is a unifying extension of `lsp_types.SymbolInformation` and `lsp_types.DocumentSymbol`, + with added fields for SolidLSP/Serena use. + """ + + deprecated: NotRequired[bool] + """ Indicates if this symbol is deprecated. + + @deprecated Use tags instead """ + location: NotRequired[Location] + """ The location of this symbol. The location's range is used by a tool + to reveal the location in the editor. If the symbol is selected in the + tool the range's start information is used to position the cursor. So + the range usually spans more than the actual symbol's name and does + normally include things like visibility modifiers. + + The range doesn't have to denote a node range in the sense of an abstract + syntax tree. It can therefore not be used to re-construct a hierarchy of + the symbols. """ + name: str + """ The name of this symbol. """ + kind: SymbolKind + """ The kind of this symbol. """ + tags: NotRequired[list[SymbolTag]] + """ Tags for this symbol. + + @since 3.16.0 """ + containerName: NotRequired[str] + """ The name of the symbol containing this symbol. This information is for + user interface purposes (e.g. to render a qualifier in the user interface + if necessary). It can't be used to re-infer a hierarchy for the document + symbols. + + Note: within Serena, the parent attribute was added and should be used instead. + Most LS don't provide containerName. + """ + + detail: NotRequired[str] + """ More detail for this symbol, e.g the signature of a function. """ + + range: NotRequired[Range] + """ The range enclosing this symbol not including leading/trailing whitespace but everything else + like comments. This information is typically used to determine if the clients cursor is + inside the symbol to reveal in the symbol in the UI. """ + selectionRange: NotRequired[Range] + """ The range that should be selected and revealed when this symbol is being picked, e.g the name of a function. + Must be contained by the `range`. """ + + body: NotRequired["SymbolBody"] + """ The body of the symbol. """ + + children: list[UnifiedSymbolInformation] + """ The children of the symbol. + Added to be compatible with `lsp_types.DocumentSymbol`, + since it is sometimes useful to have the children of the symbol as a user-facing feature.""" + + parent: NotRequired[UnifiedSymbolInformation | None] + """The parent of the symbol, if there is any. Added with Serena, not part of the LSP. + All symbols except the root packages will have a parent. + """ + + overload_idx: NotRequired[int] + """ + The overload index of the symbol, if applicable. If a symbol does not have overloads, this field is omitted. + If the symbol is an overloaded function or method (same symbol name with the same parent), + this index indicates which overload it is. The index is 0-based. + Added for Serena, not part of the LSP. + """ + + +class MarkupKind(Enum): + """Describes the content type that a client supports in various + result literals like `Hover`, `ParameterInfo` or `CompletionItem`. + + Please note that `MarkupKinds` must not start with a `$`. This kinds + are reserved for internal usage. + """ + + PlainText = "plaintext" + """ Plain text is supported as a content format """ + Markdown = "markdown" + """ Markdown is supported as a content format """ + + +class __MarkedString_Type_1(TypedDict): + language: str + value: str + + +MarkedString = Union[str, "__MarkedString_Type_1"] +""" MarkedString can be used to render human readable text. It is either a markdown string +or a code-block that provides a language and a code snippet. The language identifier +is semantically equal to the optional language identifier in fenced code blocks in GitHub +issues. See https://help.github.com/articles/creating-and-highlighting-code-blocks/#syntax-highlighting + +The pair of a language and a value is an equivalent to markdown: +```${language} +${value} +``` + +Note that markdown strings will be sanitized - that means html will be escaped. +@deprecated use MarkupContent instead. """ + + +class MarkupContent(TypedDict): + r"""A `MarkupContent` literal represents a string value which content is interpreted base on its + kind flag. Currently the protocol supports `plaintext` and `markdown` as markup kinds. + + If the kind is `markdown` then the value can contain fenced code blocks like in GitHub issues. + See https://help.github.com/articles/creating-and-highlighting-code-blocks/#syntax-highlighting + + Here is an example how such a string can be constructed using JavaScript / TypeScript: + ```ts + let markdown: MarkdownContent = { + kind: MarkupKind.Markdown, + value: [ + '# Header', + 'Some text', + '```typescript', + 'someCode();', + '```' + ].join('\n') + }; + ``` + + *Please Note* that clients might sanitize the return markdown. A client could decide to + remove HTML from the markdown to avoid script execution. + """ + + kind: MarkupKind + """ The type of the Markup """ + value: str + """ The content itself """ + + +class Hover(TypedDict): + """The result of a hover request.""" + + contents: MarkupContent | MarkedString | list[MarkedString] + """ The hover's content """ + range: NotRequired[Range] + """ An optional range inside the text document that is used to + visualize the hover, e.g. by changing the background color. """ + + +class TextDocumentIdentifier(TypedDict): + """A literal to identify a text document in the client.""" + + uri: DocumentUri + """ The text document's uri. """ + + +class TextEdit(TypedDict): + """A textual edit applicable to a text document.""" + + range: Range + """ The range of the text document to be manipulated. """ + newText: str + """ The string to be inserted. For delete operations use an empty string. """ + + +class WorkspaceEdit(TypedDict): + """A workspace edit represents changes to many resources managed in the workspace.""" + + changes: NotRequired[dict[DocumentUri, list[TextEdit]]] + """ Holds changes to existing resources. """ + documentChanges: NotRequired[list] + """ Document changes array for versioned edits. """ + + +class Diagnostic(TypedDict): + """Diagnostic information for a text document.""" + + uri: DocumentUri + """ The URI of the text document to which the diagnostics apply. """ + range: Range + """ The range of the text document to which the diagnostics apply. """ + severity: NotRequired[DiagnosticSeverity] + """ The severity of the diagnostic. """ + message: str + """ The diagnostic message. """ + code: NotRequired[str | int] + """ The code of the diagnostic. """ + source: NotRequired[str] + """ The source of the diagnostic, e.g. the name of the tool that produced it. """ + + +class SignatureHelp(TypedDict): + """ + Signature help represents the signature of something + callable. There can be multiple signature but only one + active and only one active parameter. + + See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#signatureHelp + """ + + signatures: list[SignatureInformation] + """ One or more signatures. """ + activeSignature: NotRequired[int] + """ The active signature. If omitted or the value lies outside the + range of `signatures` the value defaults to zero or is ignored if + the `SignatureHelp` has no signatures. + + Whenever possible implementers should make an active decision about + the active signature and shouldn't rely on a default value. + + In future version of the protocol this property might become + mandatory to better express this. """ + activeParameter: NotRequired[int] + """ The active parameter of the active signature. If omitted or the value + lies outside the range of `signatures[activeSignature].parameters` + defaults to 0 if the active signature has parameters. If + the active signature has no parameters it is ignored. + In future version of the protocol this property might become + mandatory to better express the active parameter if the + active signature does have any. """ + + +class SignatureInformation(TypedDict): + """Represents the signature of something callable. A signature + can have a label, like a function-name, a doc-comment, and + a set of parameters. + """ + + label: str + """ The label of this signature. Will be shown in + the UI. """ + documentation: NotRequired[MarkupContent | str] + """ The human-readable doc-comment of this signature. Will be shown + in the UI but can be omitted. """ + parameters: NotRequired[list[ParameterInformation]] + """ The parameters of this signature. """ + activeParameter: NotRequired[int] + """ The index of the active parameter. + + If provided, this is used in place of `SignatureHelp.activeParameter`. + + @since 3.16.0 """ + + +class ParameterInformation(TypedDict): + """Represents a parameter of a callable-signature. A parameter can + have a label and a doc-comment. + """ + + label: str | list[int] + """ The label of this parameter information. + + Either a string or an inclusive start and exclusive end offsets within its containing + signature label. (see SignatureInformation.label). The offsets are based on a UTF-16 + string representation as `Position` and `Range` does. + + *Note*: a label of type string should be a substring of its containing signature label. + Its intended use case is to highlight the parameter label part in the `SignatureInformation.label`. """ + documentation: NotRequired[MarkupContent | str] + """ The human-readable doc-comment of this parameter. Will be shown + in the UI but can be omitted. """ diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py new file mode 100644 index 0000000..0f47a3e --- /dev/null +++ b/src/solidlsp/ls_utils.py @@ -0,0 +1,594 @@ +""" +This file contains various utility functions like I/O operations, handling paths, etc. +""" + +import gzip +import hashlib +import logging +import os +import platform +import shutil +import subprocess +import tarfile +import tempfile +import uuid +import zipfile +from enum import Enum +from pathlib import Path, PurePath +from typing import Literal, cast +from urllib.parse import urlparse + +import charset_normalizer +import requests + +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.ls_types import UnifiedSymbolInformation + +log = logging.getLogger(__name__) + + +class InvalidTextLocationError(Exception): + pass + + +class TextUtils: + """ + Utilities for text operations. + """ + + @staticmethod + def get_line_col_from_index(text: str, index: int) -> tuple[int, int]: + """ + Returns the zero-indexed line and column number of the given index in the given text + """ + l = 0 + c = 0 + idx = 0 + while idx < index: + if text[idx] == "\n": + l += 1 + c = 0 + else: + c += 1 + idx += 1 + + return l, c + + @staticmethod + def get_index_from_line_col(text: str, line: int, col: int) -> int: + """ + Returns the index of the given zero-indexed line and column number in the given text + """ + idx = 0 + while line > 0: + if idx >= len(text): + raise InvalidTextLocationError + if text[idx] == "\n": + line -= 1 + idx += 1 + idx += col + return idx + + @staticmethod + def _get_updated_position_from_line_and_column_and_edit(l: int, c: int, text_to_be_inserted: str) -> tuple[int, int]: + """ + Utility function to get the position of the cursor after inserting text at a given line and column. + """ + num_newlines_in_gen_text = text_to_be_inserted.count("\n") + if num_newlines_in_gen_text > 0: + l += num_newlines_in_gen_text + c = len(text_to_be_inserted.split("\n")[-1]) + else: + c += len(text_to_be_inserted) + return (l, c) + + @staticmethod + def delete_text_between_positions(text: str, start_line: int, start_col: int, end_line: int, end_col: int) -> tuple[str, str]: + """ + Deletes the text between the given start and end positions. + Returns the modified text and the deleted text. + """ + del_start_idx = TextUtils.get_index_from_line_col(text, start_line, start_col) + del_end_idx = TextUtils.get_index_from_line_col(text, end_line, end_col) + + deleted_text = text[del_start_idx:del_end_idx] + new_text = text[:del_start_idx] + text[del_end_idx:] + return new_text, deleted_text + + @staticmethod + def insert_text_at_position(text: str, line: int, col: int, text_to_be_inserted: str) -> tuple[str, int, int]: + """ + Inserts the given text at the given line and column. + Returns the modified text and the new line and column. + """ + try: + change_index = TextUtils.get_index_from_line_col(text, line, col) + except InvalidTextLocationError: + num_lines_in_text = text.count("\n") + 1 + max_line = num_lines_in_text - 1 + if line == max_line + 1 and col == 0: # trying to insert at new line after full text + # insert at end, adding missing newline + change_index = len(text) + text_to_be_inserted = "\n" + text_to_be_inserted + else: + raise + new_text = text[:change_index] + text_to_be_inserted + text[change_index:] + new_l, new_c = TextUtils._get_updated_position_from_line_and_column_and_edit(line, col, text_to_be_inserted) + return new_text, new_l, new_c + + @staticmethod + def get_text_in_range(text: str, start_line: int, start_col: int, end_line: int, end_col: int) -> str: + """ + Returns the text between the given start and end positions. + """ + start_idx = TextUtils.get_index_from_line_col(text, start_line, start_col) + end_idx = TextUtils.get_index_from_line_col(text, end_line, end_col) + return text[start_idx:end_idx] + + @staticmethod + def get_text_in_lines_range(text: str, start_line: int, end_line: int) -> str: + """ + Returns the text encompassed by the given start and end lines (inclusive). + """ + lines = text.splitlines(keepends=True) + return "".join(lines[start_line : end_line + 1]) + + +class PathUtils: + """ + Utilities for platform-agnostic path operations. + """ + + @staticmethod + def uri_to_path(uri: str) -> str: + """ + Converts a URI to a file path. Works on both Linux and Windows. + + This method was obtained from https://stackoverflow.com/a/61922504 + """ + try: + from urllib.parse import unquote, urlparse + from urllib.request import url2pathname + except ImportError: + # backwards compatibility (Python 2) + from urllib.parse import unquote as unquote_py2 + from urllib.request import url2pathname as url2pathname_py2 + + from urlparse import urlparse as urlparse_py2 + + unquote = unquote_py2 + url2pathname = url2pathname_py2 + urlparse = urlparse_py2 + parsed = urlparse(uri) + host = f"{os.path.sep}{os.path.sep}{parsed.netloc}{os.path.sep}" + path = os.path.abspath(os.path.join(host, url2pathname(unquote(parsed.path)))) + return path + + @staticmethod + def path_to_uri(path: str) -> str: + """ + Converts a file path to a file URI (file:///...). + """ + return str(Path(path).absolute().as_uri()) + + @staticmethod + def is_glob_pattern(pattern: str) -> bool: + """Check if a pattern contains glob-specific characters.""" + return any(c in pattern for c in "*?[]!") + + @staticmethod + def get_relative_path(path: str, base_path: str) -> str | None: + """ + Gets relative path if it's possible (paths should be on the same drive), + returns `None` otherwise. + """ + if os.path.normcase(PurePath(path).drive) == os.path.normcase(PurePath(base_path).drive): + rel_path = str(PurePath(os.path.relpath(path, base_path))) + return rel_path + return None + + +class FileUtils: + """ + Utility functions for file operations. + """ + + @staticmethod + def read_file(file_path: str, encoding: str) -> str: + """ + Reads the file at the given path using the given encoding and returns the contents as a string. + If decoding fails, tries to detect the encoding using charset_normalizer. + + Raises FileNotFoundError if the file does not exist. + """ + if not os.path.exists(file_path): + log.error(f"Failed to read '{file_path}': File does not exist.") + raise FileNotFoundError(f"File read '{file_path}' failed: File does not exist.") + try: + try: + with open(file_path, encoding=encoding) as inp_file: + return inp_file.read() + except UnicodeDecodeError as ude: + results = charset_normalizer.from_path(file_path) + match = results.best() + if match: + log.warning( + f"Could not decode {file_path} with encoding='{encoding}'; using best match '{match.encoding}' instead", + ) + return match.raw.decode(match.encoding) + raise ude + except Exception as exc: + log.error(f"Failed to read '{file_path}' with encoding '{encoding}': {exc}") + raise exc + + @staticmethod + def download_file(url: str, target_path: str) -> None: + """ + Downloads the file from the given URL to the given {target_path} + """ + FileUtils.download_file_verified(url, target_path) + + @staticmethod + def download_file_verified( + url: str, + target_path: str, + expected_sha256: str | None = None, + allowed_hosts: tuple[str, ...] | list[str] | None = None, + ) -> None: + """ + Downloads a file from ``url`` to ``target_path`` with optional integrity and host validation. + """ + # validating the requested host + FileUtils._validate_download_host(url, allowed_hosts) + + # streaming the download into a temporary file + target_directory = os.path.dirname(target_path) or "." + os.makedirs(target_directory, exist_ok=True) + temp_file_path = str(PurePath(target_directory, f".{Path(target_path).name}.{uuid.uuid4().hex}.download")) + response: requests.Response | None = None + try: + response = requests.get(url, stream=True, timeout=60) + if response.status_code != 200: + log.error(f"Error downloading file '{url}': {response.status_code} {response.text}") + raise SolidLSPException("Error downloading file.") + + FileUtils._validate_download_host(response.url, allowed_hosts) + + with open(temp_file_path, "wb") as output_file: + for chunk in response.iter_content(chunk_size=1024 * 1024): + if chunk: + output_file.write(chunk) + + FileUtils._verify_sha256_if_configured(temp_file_path, expected_sha256) + + os.replace(temp_file_path, target_path) + except Exception as exc: + log.error(f"Error downloading file '{url}': {exc}") + raise SolidLSPException("Error downloading file.") from None + finally: + if response is not None: + response.close() + if os.path.exists(temp_file_path): + Path.unlink(Path(temp_file_path)) + + @staticmethod + def download_and_extract_archive(url: str, target_path: str, archive_type: str) -> None: + """ + Downloads the archive from the given URL having format {archive_type} and extracts it to the given {target_path} + """ + FileUtils.download_and_extract_archive_verified(url, target_path, archive_type) + + @staticmethod + def download_and_extract_archive_verified( + url: str, + target_path: str, + archive_type: str, + expected_sha256: str | None = None, + allowed_hosts: tuple[str, ...] | list[str] | None = None, + ) -> None: + """ + Downloads an archive from ``url`` and extracts it safely into ``target_path``. + """ + tmp_dir: str | None = None + try: + # preparing the temporary download location + external_tmp_files: list[str] = [] + tmp_dir = tempfile.mkdtemp(prefix="solidlsp_") + tmp_file_name = os.path.join(tmp_dir, uuid.uuid4().hex) + + # downloading the archive with optional verification + FileUtils.download_file_verified(url, tmp_file_name, expected_sha256=expected_sha256, allowed_hosts=allowed_hosts) + + # extracting the archive according to its format + if archive_type in ["tar", "gztar", "bztar", "xztar"]: + os.makedirs(target_path, exist_ok=True) + FileUtils._extract_tar_archive(tmp_file_name, target_path, archive_type) + elif archive_type == "zip": + os.makedirs(target_path, exist_ok=True) + FileUtils._extract_zip_archive(tmp_file_name, target_path) + elif archive_type == "zip.gz": + os.makedirs(target_path, exist_ok=True) + tmp_file_name_ungzipped = tmp_file_name + ".zip" + with gzip.open(tmp_file_name, "rb") as f_in, open(tmp_file_name_ungzipped, "wb") as f_out: + shutil.copyfileobj(f_in, f_out) + FileUtils._extract_zip_archive(tmp_file_name_ungzipped, target_path) + elif archive_type == "gz": + target_directory = os.path.dirname(target_path) or "." + os.makedirs(target_directory, exist_ok=True) + temp_output_path = str(PurePath(target_directory, f".{Path(target_path).name}.{uuid.uuid4().hex}.extract")) + external_tmp_files.append(temp_output_path) + with gzip.open(tmp_file_name, "rb") as f_in, open(temp_output_path, "wb") as f_out: + shutil.copyfileobj(f_in, f_out) + os.replace(temp_output_path, target_path) + elif archive_type == "binary": + target_directory = os.path.dirname(target_path) or "." + os.makedirs(target_directory, exist_ok=True) + shutil.move(tmp_file_name, target_path) + else: + log.error(f"Unknown archive type '{archive_type}' for extraction") + raise SolidLSPException(f"Unknown archive type '{archive_type}'") + except Exception as exc: + log.error(f"Error extracting archive obtained from '{url}': {exc}") + raise SolidLSPException("Error extracting archive.") from exc + finally: + # cleaning up any temporary files outside the temporary directory + for tmp_file in external_tmp_files: + if os.path.exists(tmp_file): + Path.unlink(Path(tmp_file)) + + # removing the temporary directory + if tmp_dir is not None: + shutil.rmtree(tmp_dir, ignore_errors=True) + + @staticmethod + def calculate_sha256(file_path: str) -> str: + """ + Calculates the SHA256 checksum of a file. + """ + sha256_hash = hashlib.sha256() + with open(file_path, "rb") as input_file: + for chunk in iter(lambda: input_file.read(8192), b""): + sha256_hash.update(chunk) + return sha256_hash.hexdigest() + + @staticmethod + def _verify_sha256_if_configured(file_path: str, expected_sha256: str | None) -> None: + """ + Verifies the SHA256 checksum of a file when an expected value is provided. + """ + if expected_sha256 is None: + return + + actual_sha256 = FileUtils.calculate_sha256(file_path) + if actual_sha256.lower() != expected_sha256.lower(): + raise SolidLSPException(f"Checksum verification failed for '{file_path}': expected {expected_sha256}, got {actual_sha256}") + + @staticmethod + def _validate_download_host(url: str, allowed_hosts: tuple[str, ...] | list[str] | None) -> None: + """ + Validates that a download URL resolves to one of the configured hosts. + """ + if not allowed_hosts: + return + + hostname = urlparse(url).hostname + normalized_allowed_hosts = {host.lower() for host in allowed_hosts} + if hostname is None or hostname.lower() not in normalized_allowed_hosts: + raise SolidLSPException( + f"Refusing to download from host '{hostname or ''}'; allowed hosts: {sorted(normalized_allowed_hosts)}" + ) + + @staticmethod + def _validate_extraction_path(member_name: str, target_path: str) -> str: + """ + Validates that an archive member stays within the extraction root and returns its destination path. + """ + normalized_parts = Path(member_name).parts + if any(part == ".." for part in normalized_parts): + raise SolidLSPException(f"Unsafe archive member '{member_name}': path traversal is not allowed") + + absolute_target_path = os.path.abspath(target_path) + absolute_member_path = os.path.abspath(os.path.join(target_path, member_name)) + if not (absolute_member_path.startswith(absolute_target_path + os.sep) or absolute_member_path == absolute_target_path): + raise SolidLSPException(f"Unsafe archive member '{member_name}': path escapes extraction directory") + + return absolute_member_path + + @staticmethod + def _extract_zip_archive(archive_path: str, target_path: str) -> None: + """ + Extracts a ZIP archive safely while preserving Unix permissions when available. + """ + with zipfile.ZipFile(archive_path, "r") as zip_ref: + for zip_info in zip_ref.infolist(): + extracted_path = FileUtils._validate_extraction_path(zip_info.filename, target_path) + + if zip_info.is_dir(): + os.makedirs(extracted_path, exist_ok=True) + continue + + os.makedirs(os.path.dirname(extracted_path), exist_ok=True) + with zip_ref.open(zip_info, "r") as source_file, open(extracted_path, "wb") as output_file: + shutil.copyfileobj(source_file, output_file) + + ZIP_SYSTEM_UNIX = 3 + if zip_info.create_system == ZIP_SYSTEM_UNIX: + attrs = (zip_info.external_attr >> 16) & 0o777 + if attrs: + os.chmod(extracted_path, attrs) + + @staticmethod + def _extract_tar_archive(archive_path: str, target_path: str, archive_type: str) -> None: + """ + Extracts a tar archive safely into the target directory. + """ + archive_mode_by_type = { + "tar": "r:", + "gztar": "r:gz", + "bztar": "r:bz2", + "xztar": "r:xz", + } + tar_mode = cast(Literal["r:", "r:gz", "r:bz2", "r:xz"], archive_mode_by_type[archive_type]) + + with tarfile.open(archive_path, tar_mode) as tar_ref: + for tar_member in tar_ref.getmembers(): + FileUtils._validate_extraction_path(tar_member.name, target_path) + + tar_ref.extractall(target_path) + + +class PlatformId(str, Enum): + WIN_x86 = "win-x86" + WIN_x64 = "win-x64" + WIN_arm64 = "win-arm64" + OSX = "osx" + OSX_x64 = "osx-x64" + OSX_arm64 = "osx-arm64" + LINUX_x86 = "linux-x86" + LINUX_x64 = "linux-x64" + LINUX_arm64 = "linux-arm64" + LINUX_MUSL_x64 = "linux-musl-x64" + LINUX_MUSL_arm64 = "linux-musl-arm64" + + def is_windows(self) -> bool: + return self.value.startswith("win") + + +class DotnetVersion(str, Enum): + V4 = "4" + V6 = "6" + V7 = "7" + V8 = "8" + V9 = "9" + VMONO = "mono" + + +class PlatformUtils: + """ + This class provides utilities for platform detection and identification. + """ + + @classmethod + def get_platform_id(cls) -> PlatformId: + """ + Returns the platform id for the current system + """ + system = platform.system() + machine = platform.machine() + bitness = platform.architecture()[0] + if system == "Windows" and machine == "": + machine = cls._determine_windows_machine_type() + system_map = {"Windows": "win", "Darwin": "osx", "Linux": "linux"} + machine_map = { + "AMD64": "x64", + "x86_64": "x64", + "i386": "x86", + "i686": "x86", + "aarch64": "arm64", + "arm64": "arm64", + "ARM64": "arm64", + } + if system in system_map and machine in machine_map: + platform_id = system_map[system] + "-" + machine_map[machine] + if system == "Linux" and bitness == "64bit": + libc = platform.libc_ver()[0] + if libc != "glibc": + # Format: linux-musl-arch (e.g., linux-musl-arm64) + platform_id = f"{system_map[system]}-{libc}-{machine_map[machine]}" + return PlatformId(platform_id) + else: + raise SolidLSPException(f"Unknown platform: {system=}, {machine=}, {bitness=}") + + @staticmethod + def _determine_windows_machine_type() -> str: + import ctypes + from ctypes import wintypes + + class SYSTEM_INFO(ctypes.Structure): + class _U(ctypes.Union): + class _S(ctypes.Structure): + _fields_ = [("wProcessorArchitecture", wintypes.WORD), ("wReserved", wintypes.WORD)] + + _fields_ = [("dwOemId", wintypes.DWORD), ("s", _S)] + _anonymous_ = ("s",) + + _fields_ = [ + ("u", _U), + ("dwPageSize", wintypes.DWORD), + ("lpMinimumApplicationAddress", wintypes.LPVOID), + ("lpMaximumApplicationAddress", wintypes.LPVOID), + ("dwActiveProcessorMask", wintypes.LPVOID), + ("dwNumberOfProcessors", wintypes.DWORD), + ("dwProcessorType", wintypes.DWORD), + ("dwAllocationGranularity", wintypes.DWORD), + ("wProcessorLevel", wintypes.WORD), + ("wProcessorRevision", wintypes.WORD), + ] + _anonymous_ = ("u",) + + sys_info = SYSTEM_INFO() + ctypes.windll.kernel32.GetNativeSystemInfo(ctypes.byref(sys_info)) + + arch_map = { + 9: "AMD64", + 5: "ARM", + 12: "arm64", + 6: "Intel Itanium-based", + 0: "i386", + } + + return arch_map.get(sys_info.wProcessorArchitecture, f"Unknown ({sys_info.wProcessorArchitecture})") + + @staticmethod + def get_dotnet_version() -> DotnetVersion: + """ + Returns the dotnet version for the current system + """ + try: + result = subprocess.run(["dotnet", "--list-runtimes"], capture_output=True, check=True) + available_version_cmd_output = [] + for line in result.stdout.decode("utf-8").split("\n"): + if line.startswith("Microsoft.NETCore.App"): + version_cmd_output = line.split(" ")[1] + available_version_cmd_output.append(version_cmd_output) + + if not available_version_cmd_output: + raise SolidLSPException("dotnet not found on the system") + + # Check for supported versions in order of preference (latest first) + for version_cmd_output in available_version_cmd_output: + if version_cmd_output.startswith("9"): + return DotnetVersion.V9 + if version_cmd_output.startswith("8"): + return DotnetVersion.V8 + if version_cmd_output.startswith("7"): + return DotnetVersion.V7 + if version_cmd_output.startswith("6"): + return DotnetVersion.V6 + if version_cmd_output.startswith("4"): + return DotnetVersion.V4 + + # If no supported version found, raise exception with all available versions + raise SolidLSPException( + f"No supported dotnet version found. Available versions: {', '.join(available_version_cmd_output)}. Supported versions: 4, 6, 7, 8, 9" + ) + except (FileNotFoundError, subprocess.CalledProcessError): + try: + result = subprocess.run(["mono", "--version"], capture_output=True, check=True) + return DotnetVersion.VMONO + except (FileNotFoundError, subprocess.CalledProcessError): + raise SolidLSPException("dotnet or mono not found on the system") + + +class SymbolUtils: + @staticmethod + def symbol_tree_contains_name(roots: list[UnifiedSymbolInformation], name: str) -> bool: + """ + Check if any symbol in the tree has a name matching the given name. + """ + for symbol in roots: + if symbol["name"] == name: + return True + if SymbolUtils.symbol_tree_contains_name(symbol["children"], name): + return True + return False diff --git a/src/solidlsp/lsp_protocol_handler/lsp_constants.py b/src/solidlsp/lsp_protocol_handler/lsp_constants.py new file mode 100644 index 0000000..467237d --- /dev/null +++ b/src/solidlsp/lsp_protocol_handler/lsp_constants.py @@ -0,0 +1,69 @@ +""" +This module contains constants used in the LSP protocol. +""" + + +class LSPConstants: + """ + This class contains constants used in the LSP protocol. + """ + + # the key for uri used to represent paths + URI = "uri" + + # the key for range, which is a from and to position within a text document + RANGE = "range" + + # A key used in LocationLink type, used as the span of the origin link + ORIGIN_SELECTION_RANGE = "originSelectionRange" + + # A key used in LocationLink type, used as the target uri of the link + TARGET_URI = "targetUri" + + # A key used in LocationLink type, used as the target range of the link + TARGET_RANGE = "targetRange" + + # A key used in LocationLink type, used as the target selection range of the link + TARGET_SELECTION_RANGE = "targetSelectionRange" + + # key for the textDocument field in the request + TEXT_DOCUMENT = "textDocument" + + # key used to represent the language a document is in - "java", "csharp", etc. + LANGUAGE_ID = "languageId" + + # key used to represent the version of a document (a shared value between the client and server) + VERSION = "version" + + # key used to represent the text of a document being sent from the client to the server on open + TEXT = "text" + + # key used to represent a position (line and colnum) within a text document + POSITION = "position" + + # key used to represent the line number of a position + LINE = "line" + + # key used to represent the column number of a position + CHARACTER = "character" + + # key used to represent the changes made to a document + CONTENT_CHANGES = "contentChanges" + + # key used to represent name of symbols + NAME = "name" + + # key used to represent the kind of symbols + KIND = "kind" + + # key used to represent children in document symbols + CHILDREN = "children" + + # key used to represent the location in symbols + LOCATION = "location" + + # Severity level of the diagnostic + SEVERITY = "severity" + + # The message of the diagnostic + MESSAGE = "message" diff --git a/src/solidlsp/lsp_protocol_handler/lsp_requests.py b/src/solidlsp/lsp_protocol_handler/lsp_requests.py new file mode 100644 index 0000000..78f308b --- /dev/null +++ b/src/solidlsp/lsp_protocol_handler/lsp_requests.py @@ -0,0 +1,561 @@ +# Code generated. DO NOT EDIT. +# LSP v3.17.0 +# TODO: Look into use of https://pypi.org/project/ts2python/ to generate the types for https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/ + +""" +This file provides the python interface corresponding to the requests and notifications defined in Typescript in the language server protocol. +This file is obtained from https://github.com/predragnikolic/OLSP under the MIT License with the following terms: + +MIT License + +Copyright (c) 2023 Предраг Николић + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +from typing import Any, Union + +from solidlsp.lsp_protocol_handler import lsp_types + + +class LspRequest: + def __init__(self, send_request: Any) -> None: + self.send_request = send_request + + async def implementation( + self, params: lsp_types.ImplementationParams + ) -> Union["lsp_types.Definition", list["lsp_types.LocationLink"], None]: + """A request to resolve the implementation locations of a symbol at a given text + document position. The request's parameter is of type [TextDocumentPositionParams] + (#TextDocumentPositionParams) the response is of type {@link Definition} or a + Thenable that resolves to such. + """ + return await self.send_request("textDocument/implementation", params) + + async def type_definition( + self, params: lsp_types.TypeDefinitionParams + ) -> Union["lsp_types.Definition", list["lsp_types.LocationLink"], None]: + """A request to resolve the type definition locations of a symbol at a given text + document position. The request's parameter is of type [TextDocumentPositionParams] + (#TextDocumentPositionParams) the response is of type {@link Definition} or a + Thenable that resolves to such. + """ + return await self.send_request("textDocument/typeDefinition", params) + + async def document_color(self, params: lsp_types.DocumentColorParams) -> list["lsp_types.ColorInformation"]: + """A request to list all color symbols found in a given text document. The request's + parameter is of type {@link DocumentColorParams} the + response is of type {@link ColorInformation ColorInformation[]} or a Thenable + that resolves to such. + """ + return await self.send_request("textDocument/documentColor", params) + + async def color_presentation(self, params: lsp_types.ColorPresentationParams) -> list["lsp_types.ColorPresentation"]: + """A request to list all presentation for a color. The request's + parameter is of type {@link ColorPresentationParams} the + response is of type {@link ColorInformation ColorInformation[]} or a Thenable + that resolves to such. + """ + return await self.send_request("textDocument/colorPresentation", params) + + async def folding_range(self, params: lsp_types.FoldingRangeParams) -> list["lsp_types.FoldingRange"] | None: + """A request to provide folding ranges in a document. The request's + parameter is of type {@link FoldingRangeParams}, the + response is of type {@link FoldingRangeList} or a Thenable + that resolves to such. + """ + return await self.send_request("textDocument/foldingRange", params) + + async def declaration( + self, params: lsp_types.DeclarationParams + ) -> Union["lsp_types.Declaration", list["lsp_types.LocationLink"], None]: + """A request to resolve the type definition locations of a symbol at a given text + document position. The request's parameter is of type [TextDocumentPositionParams] + (#TextDocumentPositionParams) the response is of type {@link Declaration} + or a typed array of {@link DeclarationLink} or a Thenable that resolves + to such. + """ + return await self.send_request("textDocument/declaration", params) + + async def selection_range(self, params: lsp_types.SelectionRangeParams) -> list["lsp_types.SelectionRange"] | None: + """A request to provide selection ranges in a document. The request's + parameter is of type {@link SelectionRangeParams}, the + response is of type {@link SelectionRange SelectionRange[]} or a Thenable + that resolves to such. + """ + return await self.send_request("textDocument/selectionRange", params) + + async def prepare_call_hierarchy(self, params: lsp_types.CallHierarchyPrepareParams) -> list["lsp_types.CallHierarchyItem"] | None: + """A request to result a `CallHierarchyItem` in a document at a given position. + Can be used as an input to an incoming or outgoing call hierarchy. + + @since 3.16.0 + """ + return await self.send_request("textDocument/prepareCallHierarchy", params) + + async def incoming_calls( + self, params: lsp_types.CallHierarchyIncomingCallsParams + ) -> list["lsp_types.CallHierarchyIncomingCall"] | None: + """A request to resolve the incoming calls for a given `CallHierarchyItem`. + + @since 3.16.0 + """ + return await self.send_request("callHierarchy/incomingCalls", params) + + async def outgoing_calls( + self, params: lsp_types.CallHierarchyOutgoingCallsParams + ) -> list["lsp_types.CallHierarchyOutgoingCall"] | None: + """A request to resolve the outgoing calls for a given `CallHierarchyItem`. + + @since 3.16.0 + """ + return await self.send_request("callHierarchy/outgoingCalls", params) + + async def semantic_tokens_full(self, params: lsp_types.SemanticTokensParams) -> Union["lsp_types.SemanticTokens", None]: + """@since 3.16.0""" + return await self.send_request("textDocument/semanticTokens/full", params) + + async def semantic_tokens_delta( + self, params: lsp_types.SemanticTokensDeltaParams + ) -> Union["lsp_types.SemanticTokens", "lsp_types.SemanticTokensDelta", None]: + """@since 3.16.0""" + return await self.send_request("textDocument/semanticTokens/full/delta", params) + + async def semantic_tokens_range(self, params: lsp_types.SemanticTokensRangeParams) -> Union["lsp_types.SemanticTokens", None]: + """@since 3.16.0""" + return await self.send_request("textDocument/semanticTokens/range", params) + + async def linked_editing_range(self, params: lsp_types.LinkedEditingRangeParams) -> Union["lsp_types.LinkedEditingRanges", None]: + """A request to provide ranges that can be edited together. + + @since 3.16.0 + """ + return await self.send_request("textDocument/linkedEditingRange", params) + + async def will_create_files(self, params: lsp_types.CreateFilesParams) -> Union["lsp_types.WorkspaceEdit", None]: + """The will create files request is sent from the client to the server before files are actually + created as long as the creation is triggered from within the client. + + @since 3.16.0 + """ + return await self.send_request("workspace/willCreateFiles", params) + + async def will_rename_files(self, params: lsp_types.RenameFilesParams) -> Union["lsp_types.WorkspaceEdit", None]: + """The will rename files request is sent from the client to the server before files are actually + renamed as long as the rename is triggered from within the client. + + @since 3.16.0 + """ + return await self.send_request("workspace/willRenameFiles", params) + + async def will_delete_files(self, params: lsp_types.DeleteFilesParams) -> Union["lsp_types.WorkspaceEdit", None]: + """The did delete files notification is sent from the client to the server when + files were deleted from within the client. + + @since 3.16.0 + """ + return await self.send_request("workspace/willDeleteFiles", params) + + async def moniker(self, params: lsp_types.MonikerParams) -> list["lsp_types.Moniker"] | None: + """A request to get the moniker of a symbol at a given text document position. + The request parameter is of type {@link TextDocumentPositionParams}. + The response is of type {@link Moniker Moniker[]} or `null`. + """ + return await self.send_request("textDocument/moniker", params) + + async def prepare_type_hierarchy(self, params: lsp_types.TypeHierarchyPrepareParams) -> list["lsp_types.TypeHierarchyItem"] | None: + """A request to result a `TypeHierarchyItem` in a document at a given position. + Can be used as an input to a subtypes or supertypes type hierarchy. + + @since 3.17.0 + """ + return await self.send_request("textDocument/prepareTypeHierarchy", params) + + async def type_hierarchy_supertypes( + self, params: lsp_types.TypeHierarchySupertypesParams + ) -> list["lsp_types.TypeHierarchyItem"] | None: + """A request to resolve the supertypes for a given `TypeHierarchyItem`. + + @since 3.17.0 + """ + return await self.send_request("typeHierarchy/supertypes", params) + + async def type_hierarchy_subtypes(self, params: lsp_types.TypeHierarchySubtypesParams) -> list["lsp_types.TypeHierarchyItem"] | None: + """A request to resolve the subtypes for a given `TypeHierarchyItem`. + + @since 3.17.0 + """ + return await self.send_request("typeHierarchy/subtypes", params) + + async def inline_value(self, params: lsp_types.InlineValueParams) -> list["lsp_types.InlineValue"] | None: + """A request to provide inline values in a document. The request's parameter is of + type {@link InlineValueParams}, the response is of type + {@link InlineValue InlineValue[]} or a Thenable that resolves to such. + + @since 3.17.0 + """ + return await self.send_request("textDocument/inlineValue", params) + + async def inlay_hint(self, params: lsp_types.InlayHintParams) -> list["lsp_types.InlayHint"] | None: + """A request to provide inlay hints in a document. The request's parameter is of + type {@link InlayHintsParams}, the response is of type + {@link InlayHint InlayHint[]} or a Thenable that resolves to such. + + @since 3.17.0 + """ + return await self.send_request("textDocument/inlayHint", params) + + async def resolve_inlay_hint(self, params: lsp_types.InlayHint) -> "lsp_types.InlayHint": + """A request to resolve additional properties for an inlay hint. + The request's parameter is of type {@link InlayHint}, the response is + of type {@link InlayHint} or a Thenable that resolves to such. + + @since 3.17.0 + """ + return await self.send_request("inlayHint/resolve", params) + + async def text_document_diagnostic(self, params: lsp_types.DocumentDiagnosticParams) -> "lsp_types.DocumentDiagnosticReport": + """The document diagnostic request definition. + + @since 3.17.0 + """ + return await self.send_request("textDocument/diagnostic", params) + + async def workspace_diagnostic(self, params: lsp_types.WorkspaceDiagnosticParams) -> "lsp_types.WorkspaceDiagnosticReport": + """The workspace diagnostic request definition. + + @since 3.17.0 + """ + return await self.send_request("workspace/diagnostic", params) + + async def initialize(self, params: lsp_types.InitializeParams) -> "lsp_types.InitializeResult": + """The initialize request is sent from the client to the server. + It is sent once as the request after starting up the server. + The requests parameter is of type {@link InitializeParams} + the response if of type {@link InitializeResult} of a Thenable that + resolves to such. + """ + return await self.send_request("initialize", params) + + async def shutdown(self) -> None: + """A shutdown request is sent from the client to the server. + It is sent once when the client decides to shutdown the + server. The only notification that is sent after a shutdown request + is the exit event. + """ + return await self.send_request("shutdown") + + async def will_save_wait_until(self, params: lsp_types.WillSaveTextDocumentParams) -> list["lsp_types.TextEdit"] | None: + """A document will save request is sent from the client to the server before + the document is actually saved. The request can return an array of TextEdits + which will be applied to the text document before it is saved. Please note that + clients might drop results if computing the text edits took too long or if a + server constantly fails on this request. This is done to keep the save fast and + reliable. + """ + return await self.send_request("textDocument/willSaveWaitUntil", params) + + async def completion( + self, params: lsp_types.CompletionParams + ) -> Union[list["lsp_types.CompletionItem"], "lsp_types.CompletionList", None]: + """Request to request completion at a given text document position. The request's + parameter is of type {@link TextDocumentPosition} the response + is of type {@link CompletionItem CompletionItem[]} or {@link CompletionList} + or a Thenable that resolves to such. + + The request can delay the computation of the {@link CompletionItem.detail `detail`} + and {@link CompletionItem.documentation `documentation`} properties to the `completionItem/resolve` + request. However, properties that are needed for the initial sorting and filtering, like `sortText`, + `filterText`, `insertText`, and `textEdit`, must not be changed during resolve. + """ + return await self.send_request("textDocument/completion", params) + + async def resolve_completion_item(self, params: lsp_types.CompletionItem) -> "lsp_types.CompletionItem": + """Request to resolve additional information for a given completion item.The request's + parameter is of type {@link CompletionItem} the response + is of type {@link CompletionItem} or a Thenable that resolves to such. + """ + return await self.send_request("completionItem/resolve", params) + + async def hover(self, params: lsp_types.HoverParams) -> Union["lsp_types.Hover", None]: + """Request to request hover information at a given text document position. The request's + parameter is of type {@link TextDocumentPosition} the response is of + type {@link Hover} or a Thenable that resolves to such. + """ + return await self.send_request("textDocument/hover", params) + + async def signature_help(self, params: lsp_types.SignatureHelpParams) -> Union["lsp_types.SignatureHelp", None]: + return await self.send_request("textDocument/signatureHelp", params) + + async def definition(self, params: lsp_types.DefinitionParams) -> Union["lsp_types.Definition", list["lsp_types.LocationLink"], None]: + """A request to resolve the definition location of a symbol at a given text + document position. The request's parameter is of type [TextDocumentPosition] + (#TextDocumentPosition) the response is of either type {@link Definition} + or a typed array of {@link DefinitionLink} or a Thenable that resolves + to such. + """ + return await self.send_request("textDocument/definition", params) + + async def references(self, params: lsp_types.ReferenceParams) -> list["lsp_types.Location"] | None: + """A request to resolve project-wide references for the symbol denoted + by the given text document position. The request's parameter is of + type {@link ReferenceParams} the response is of type + {@link Location Location[]} or a Thenable that resolves to such. + """ + return await self.send_request("textDocument/references", params) + + async def document_highlight(self, params: lsp_types.DocumentHighlightParams) -> list["lsp_types.DocumentHighlight"] | None: + """Request to resolve a {@link DocumentHighlight} for a given + text document position. The request's parameter is of type [TextDocumentPosition] + (#TextDocumentPosition) the request response is of type [DocumentHighlight[]] + (#DocumentHighlight) or a Thenable that resolves to such. + """ + return await self.send_request("textDocument/documentHighlight", params) + + async def document_symbol( + self, params: lsp_types.DocumentSymbolParams + ) -> list["lsp_types.SymbolInformation"] | list["lsp_types.DocumentSymbol"] | None: + """A request to list all symbols found in a given text document. The request's + parameter is of type {@link TextDocumentIdentifier} the + response is of type {@link SymbolInformation SymbolInformation[]} or a Thenable + that resolves to such. + """ + return await self.send_request("textDocument/documentSymbol", params) + + async def code_action(self, params: lsp_types.CodeActionParams) -> list[Union["lsp_types.Command", "lsp_types.CodeAction"]] | None: + """A request to provide commands for the given text document and range.""" + return await self.send_request("textDocument/codeAction", params) + + async def resolve_code_action(self, params: lsp_types.CodeAction) -> "lsp_types.CodeAction": + """Request to resolve additional information for a given code action.The request's + parameter is of type {@link CodeAction} the response + is of type {@link CodeAction} or a Thenable that resolves to such. + """ + return await self.send_request("codeAction/resolve", params) + + async def workspace_symbol( + self, params: lsp_types.WorkspaceSymbolParams + ) -> list["lsp_types.SymbolInformation"] | list["lsp_types.WorkspaceSymbol"] | None: + """A request to list project-wide symbols matching the query string given + by the {@link WorkspaceSymbolParams}. The response is + of type {@link SymbolInformation SymbolInformation[]} or a Thenable that + resolves to such. + + @since 3.17.0 - support for WorkspaceSymbol in the returned data. Clients + need to advertise support for WorkspaceSymbols via the client capability + `workspace.symbol.resolveSupport`. + """ + return await self.send_request("workspace/symbol", params) + + async def resolve_workspace_symbol(self, params: lsp_types.WorkspaceSymbol) -> "lsp_types.WorkspaceSymbol": + """A request to resolve the range inside the workspace + symbol's location. + + @since 3.17.0 + """ + return await self.send_request("workspaceSymbol/resolve", params) + + async def code_lens(self, params: lsp_types.CodeLensParams) -> list["lsp_types.CodeLens"] | None: + """A request to provide code lens for the given text document.""" + return await self.send_request("textDocument/codeLens", params) + + async def resolve_code_lens(self, params: lsp_types.CodeLens) -> "lsp_types.CodeLens": + """A request to resolve a command for a given code lens.""" + return await self.send_request("codeLens/resolve", params) + + async def document_link(self, params: lsp_types.DocumentLinkParams) -> list["lsp_types.DocumentLink"] | None: + """A request to provide document links""" + return await self.send_request("textDocument/documentLink", params) + + async def resolve_document_link(self, params: lsp_types.DocumentLink) -> "lsp_types.DocumentLink": + """Request to resolve additional information for a given document link. The request's + parameter is of type {@link DocumentLink} the response + is of type {@link DocumentLink} or a Thenable that resolves to such. + """ + return await self.send_request("documentLink/resolve", params) + + async def formatting(self, params: lsp_types.DocumentFormattingParams) -> list["lsp_types.TextEdit"] | None: + """A request to to format a whole document.""" + return await self.send_request("textDocument/formatting", params) + + async def range_formatting(self, params: lsp_types.DocumentRangeFormattingParams) -> list["lsp_types.TextEdit"] | None: + """A request to to format a range in a document.""" + return await self.send_request("textDocument/rangeFormatting", params) + + async def on_type_formatting(self, params: lsp_types.DocumentOnTypeFormattingParams) -> list["lsp_types.TextEdit"] | None: + """A request to format a document on type.""" + return await self.send_request("textDocument/onTypeFormatting", params) + + async def rename(self, params: lsp_types.RenameParams) -> Union["lsp_types.WorkspaceEdit", None]: + """A request to rename a symbol.""" + return await self.send_request("textDocument/rename", params) + + async def prepare_rename(self, params: lsp_types.PrepareRenameParams) -> Union["lsp_types.PrepareRenameResult", None]: + """A request to test and perform the setup necessary for a rename. + + @since 3.16 - support for default behavior + """ + return await self.send_request("textDocument/prepareRename", params) + + async def execute_command(self, params: lsp_types.ExecuteCommandParams) -> Union["lsp_types.LSPAny", None]: + """A request send from the client to the server to execute a command. The request might return + a workspace edit which the client will apply to the workspace. + """ + return await self.send_request("workspace/executeCommand", params) + + +class LspNotification: + def __init__(self, send_notification: Any) -> None: + self.send_notification = send_notification + + def did_change_workspace_folders(self, params: lsp_types.DidChangeWorkspaceFoldersParams) -> None: + """The `workspace/didChangeWorkspaceFolders` notification is sent from the client to the server when the workspace + folder configuration changes. + """ + return self.send_notification("workspace/didChangeWorkspaceFolders", params) + + def cancel_work_done_progress(self, params: lsp_types.WorkDoneProgressCancelParams) -> None: + """The `window/workDoneProgress/cancel` notification is sent from the client to the server to cancel a progress + initiated on the server side. + """ + return self.send_notification("window/workDoneProgress/cancel", params) + + def did_create_files(self, params: lsp_types.CreateFilesParams) -> None: + """The did create files notification is sent from the client to the server when + files were created from within the client. + + @since 3.16.0 + """ + return self.send_notification("workspace/didCreateFiles", params) + + def did_rename_files(self, params: lsp_types.RenameFilesParams) -> None: + """The did rename files notification is sent from the client to the server when + files were renamed from within the client. + + @since 3.16.0 + """ + return self.send_notification("workspace/didRenameFiles", params) + + def did_delete_files(self, params: lsp_types.DeleteFilesParams) -> None: + """The will delete files request is sent from the client to the server before files are actually + deleted as long as the deletion is triggered from within the client. + + @since 3.16.0 + """ + return self.send_notification("workspace/didDeleteFiles", params) + + def did_open_notebook_document(self, params: lsp_types.DidOpenNotebookDocumentParams) -> None: + """A notification sent when a notebook opens. + + @since 3.17.0 + """ + return self.send_notification("notebookDocument/didOpen", params) + + def did_change_notebook_document(self, params: lsp_types.DidChangeNotebookDocumentParams) -> None: + return self.send_notification("notebookDocument/didChange", params) + + def did_save_notebook_document(self, params: lsp_types.DidSaveNotebookDocumentParams) -> None: + """A notification sent when a notebook document is saved. + + @since 3.17.0 + """ + return self.send_notification("notebookDocument/didSave", params) + + def did_close_notebook_document(self, params: lsp_types.DidCloseNotebookDocumentParams) -> None: + """A notification sent when a notebook closes. + + @since 3.17.0 + """ + return self.send_notification("notebookDocument/didClose", params) + + def initialized(self, params: lsp_types.InitializedParams) -> None: + """The initialized notification is sent from the client to the + server after the client is fully initialized and the server + is allowed to send requests from the server to the client. + """ + return self.send_notification("initialized", params) + + def exit(self) -> None: + """The exit event is sent from the client to the server to + ask the server to exit its process. + """ + return self.send_notification("exit") + + def workspace_did_change_configuration(self, params: lsp_types.DidChangeConfigurationParams) -> None: + """The configuration change notification is sent from the client to the server + when the client's configuration has changed. The notification contains + the changed configuration as defined by the language client. + """ + return self.send_notification("workspace/didChangeConfiguration", params) + + def did_open_text_document(self, params: lsp_types.DidOpenTextDocumentParams) -> None: + """The document open notification is sent from the client to the server to signal + newly opened text documents. The document's truth is now managed by the client + and the server must not try to read the document's truth using the document's + uri. Open in this sense means it is managed by the client. It doesn't necessarily + mean that its content is presented in an editor. An open notification must not + be sent more than once without a corresponding close notification send before. + This means open and close notification must be balanced and the max open count + is one. + """ + return self.send_notification("textDocument/didOpen", params) + + def did_change_text_document(self, params: lsp_types.DidChangeTextDocumentParams) -> None: + """The document change notification is sent from the client to the server to signal + changes to a text document. + """ + return self.send_notification("textDocument/didChange", params) + + def did_close_text_document(self, params: lsp_types.DidCloseTextDocumentParams) -> None: + """The document close notification is sent from the client to the server when + the document got closed in the client. The document's truth now exists where + the document's uri points to (e.g. if the document's uri is a file uri the + truth now exists on disk). As with the open notification the close notification + is about managing the document's content. Receiving a close notification + doesn't mean that the document was open in an editor before. A close + notification requires a previous open notification to be sent. + """ + return self.send_notification("textDocument/didClose", params) + + def did_save_text_document(self, params: lsp_types.DidSaveTextDocumentParams) -> None: + """The document save notification is sent from the client to the server when + the document got saved in the client. + """ + return self.send_notification("textDocument/didSave", params) + + def will_save_text_document(self, params: lsp_types.WillSaveTextDocumentParams) -> None: + """A document will save notification is sent from the client to the server before + the document is actually saved. + """ + return self.send_notification("textDocument/willSave", params) + + def did_change_watched_files(self, params: lsp_types.DidChangeWatchedFilesParams) -> None: + """The watched files notification is sent from the client to the server when + the client detects changes to file watched by the language client. + """ + return self.send_notification("workspace/didChangeWatchedFiles", params) + + def set_trace(self, params: lsp_types.SetTraceParams) -> None: + return self.send_notification("$/setTrace", params) + + def cancel_request(self, params: lsp_types.CancelParams) -> None: + return self.send_notification("$/cancelRequest", params) + + def progress(self, params: lsp_types.ProgressParams) -> None: + return self.send_notification("$/progress", params) diff --git a/src/solidlsp/lsp_protocol_handler/lsp_types.py b/src/solidlsp/lsp_protocol_handler/lsp_types.py new file mode 100644 index 0000000..c96f5fc --- /dev/null +++ b/src/solidlsp/lsp_protocol_handler/lsp_types.py @@ -0,0 +1,5943 @@ +# The code in this module is mostly autogenerated using https://github.com/predragnikolic/OLSP +# To update it for higher LSP versions, autogenerate a new version in a different file and merge manually. + +# LSP v3.17.0 +# Note by author of the autogenerator: Look into use of https://pypi.org/project/ts2python/ to generate the types f +# or https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/ + +""" +This file provides the Python types corresponding to the Typescript types defined in the language server protocol. +""" + +from enum import Enum, IntEnum, IntFlag +from typing import Any, Literal, NotRequired, Union + +from typing_extensions import TypedDict + +URI = str +DocumentUri = str +Uint = int +RegExp = str + + +class SemanticTokenTypes(Enum): + """A set of predefined token types. This set is not fixed + an clients can specify additional token types via the + corresponding client capabilities. + + @since 3.16.0 + """ + + Namespace = "namespace" + Type = "type" + """ Represents a generic type. Acts as a fallback for types which can't be mapped to + a specific type like class or enum. """ + Class = "class" + Enum = "enum" + Interface = "interface" + Struct = "struct" + TypeParameter = "typeParameter" + Parameter = "parameter" + Variable = "variable" + Property = "property" + EnumMember = "enumMember" + Event = "event" + Function = "function" + Method = "method" + Macro = "macro" + Keyword = "keyword" + Modifier = "modifier" + Comment = "comment" + String = "string" + Number = "number" + Regexp = "regexp" + Operator = "operator" + Decorator = "decorator" + """ @since 3.17.0 """ + + +class SemanticTokenModifiers(Enum): + """A set of predefined token modifiers. This set is not fixed + an clients can specify additional token types via the + corresponding client capabilities. + + @since 3.16.0 + """ + + Declaration = "declaration" + Definition = "definition" + Readonly = "readonly" + Static = "static" + Deprecated = "deprecated" + Abstract = "abstract" + Async = "async" + Modification = "modification" + Documentation = "documentation" + DefaultLibrary = "defaultLibrary" + + +class DocumentDiagnosticReportKind(Enum): + """The document diagnostic report kinds. + + @since 3.17.0 + """ + + Full = "full" + """ A diagnostic report with a full + set of problems. """ + Unchanged = "unchanged" + """ A report indicating that the last + returned report is still accurate. """ + + +class ErrorCodes(IntEnum): + """Predefined error codes.""" + + ParseError = -32700 + InvalidRequest = -32600 + MethodNotFound = -32601 + InvalidParams = -32602 + InternalError = -32603 + ServerNotInitialized = -32002 + """ Error code indicating that a server received a notification or + request before the server has received the `initialize` request. """ + UnknownErrorCode = -32001 + + +class LSPErrorCodes(IntEnum): + RequestFailed = -32803 + """ A request failed but it was syntactically correct, e.g the + method name was known and the parameters were valid. The error + message should contain human readable information about why + the request failed. + + @since 3.17.0 """ + ServerCancelled = -32802 + """ The server cancelled the request. This error code should + only be used for requests that explicitly support being + server cancellable. + + @since 3.17.0 """ + ContentModified = -32801 + """ The server detected that the content of a document got + modified outside normal conditions. A server should + NOT send this error code if it detects a content change + in it unprocessed messages. The result even computed + on an older state might still be useful for the client. + + If a client decides that a result is not of any use anymore + the client should cancel the request. """ + RequestCancelled = -32800 + """ The client has canceled a request and a server as detected + the cancel. """ + + +class FoldingRangeKind(Enum): + """A set of predefined range kinds.""" + + Comment = "comment" + """ Folding range for a comment """ + Imports = "imports" + """ Folding range for an import or include """ + Region = "region" + """ Folding range for a region (e.g. `#region`) """ + + +class SymbolKind(IntEnum): + """A symbol kind.""" + + Unknown = 0 + """Not an LSP type, we use it to represent symbols that don't have a known kind.""" + File = 1 + Module = 2 + Namespace = 3 + Package = 4 + """ + Represents a package or simply a directory in the filesystem + """ + Class = 5 + Method = 6 + Property = 7 + Field = 8 + Constructor = 9 + Enum = 10 + Interface = 11 + Function = 12 + Variable = 13 + Constant = 14 + String = 15 + Number = 16 + Boolean = 17 + Array = 18 + Object = 19 + Key = 20 + Null = 21 + EnumMember = 22 + Struct = 23 + Event = 24 + Operator = 25 + TypeParameter = 26 + + @classmethod + def _missing_(cls, value: Any) -> "SymbolKind": + return cls.Unknown + + +class SymbolTag(IntEnum): + """Symbol tags are extra annotations that tweak the rendering of a symbol. + + @since 3.16 + """ + + Deprecated = 1 + """ Render a symbol as obsolete, usually using a strike-out. """ + + +class UniquenessLevel(Enum): + """Moniker uniqueness level to define scope of the moniker. + + @since 3.16.0 + """ + + Document = "document" + """ The moniker is only unique inside a document """ + Project = "project" + """ The moniker is unique inside a project for which a dump got created """ + Group = "group" + """ The moniker is unique inside the group to which a project belongs """ + Scheme = "scheme" + """ The moniker is unique inside the moniker scheme. """ + Global = "global" + """ The moniker is globally unique """ + + +class MonikerKind(Enum): + """The moniker kind. + + @since 3.16.0 + """ + + Import = "import" + """ The moniker represent a symbol that is imported into a project """ + Export = "export" + """ The moniker represents a symbol that is exported from a project """ + Local = "local" + """ The moniker represents a symbol that is local to a project (e.g. a local + variable of a function, a class not visible outside the project, ...) """ + + +class InlayHintKind(IntEnum): + """Inlay hint kinds. + + @since 3.17.0 + """ + + Type = 1 + """ An inlay hint that for a type annotation. """ + Parameter = 2 + """ An inlay hint that is for a parameter. """ + + +class MessageType(IntEnum): + """The message type""" + + Error = 1 + """ An error message. """ + Warning = 2 + """ A warning message. """ + Info = 3 + """ An information message. """ + Log = 4 + """ A log message. """ + + +class TextDocumentSyncKind(IntEnum): + """Defines how the host (editor) should sync + document changes to the language server. + """ + + None_ = 0 + """ Documents should not be synced at all. """ + Full = 1 + """ Documents are synced by always sending the full content + of the document. """ + Incremental = 2 + """ Documents are synced by sending the full content on open. + After that only incremental updates to the document are + send. """ + + +class TextDocumentSaveReason(IntEnum): + """Represents reasons why a text document is saved.""" + + Manual = 1 + """ Manually triggered, e.g. by the user pressing save, by starting debugging, + or by an API call. """ + AfterDelay = 2 + """ Automatic after a delay. """ + FocusOut = 3 + """ When the editor lost focus. """ + + +class CompletionItemKind(IntEnum): + """The kind of a completion entry.""" + + Text = 1 + Method = 2 + Function = 3 + Constructor = 4 + Field = 5 + Variable = 6 + Class = 7 + Interface = 8 + Module = 9 + Property = 10 + Unit = 11 + Value = 12 + Enum = 13 + Keyword = 14 + Snippet = 15 + Color = 16 + File = 17 + Reference = 18 + Folder = 19 + EnumMember = 20 + Constant = 21 + Struct = 22 + Event = 23 + Operator = 24 + TypeParameter = 25 + + +class CompletionItemTag(IntEnum): + """Completion item tags are extra annotations that tweak the rendering of a completion + item. + + @since 3.15.0 + """ + + Deprecated = 1 + """ Render a completion as obsolete, usually using a strike-out. """ + + +class InsertTextFormat(IntEnum): + """Defines whether the insert text in a completion item should be interpreted as + plain text or a snippet. + """ + + PlainText = 1 + """ The primary text to be inserted is treated as a plain string. """ + Snippet = 2 + """ The primary text to be inserted is treated as a snippet. + + A snippet can define tab stops and placeholders with `$1`, `$2` + and `${3:foo}`. `$0` defines the final tab stop, it defaults to + the end of the snippet. Placeholders with equal identifiers are linked, + that is typing in one will update others too. + + See also: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#snippet_syntax """ + + +class InsertTextMode(IntEnum): + """How whitespace and indentation is handled during completion + item insertion. + + @since 3.16.0 + """ + + AsIs = 1 + """ The insertion or replace strings is taken as it is. If the + value is multi line the lines below the cursor will be + inserted using the indentation defined in the string value. + The client will not apply any kind of adjustments to the + string. """ + AdjustIndentation = 2 + """ The editor adjusts leading whitespace of new lines so that + they match the indentation up to the cursor of the line for + which the item is accepted. + + Consider a line like this: <2tabs><3tabs>foo. Accepting a + multi line completion item is indented using 2 tabs and all + following lines inserted will be indented using 2 tabs as well. """ + + +class DocumentHighlightKind(IntEnum): + """A document highlight kind.""" + + Text = 1 + """ A textual occurrence. """ + Read = 2 + """ Read-access of a symbol, like reading a variable. """ + Write = 3 + """ Write-access of a symbol, like writing to a variable. """ + + +class CodeActionKind(Enum): + """A set of predefined code action kinds""" + + Empty = "" + """ Empty kind. """ + QuickFix = "quickfix" + """ Base kind for quickfix actions: 'quickfix' """ + Refactor = "refactor" + """ Base kind for refactoring actions: 'refactor' """ + RefactorExtract = "refactor.extract" + """ Base kind for refactoring extraction actions: 'refactor.extract' + + Example extract actions: + + - Extract method + - Extract function + - Extract variable + - Extract interface from class + - ... """ + RefactorInline = "refactor.inline" + """ Base kind for refactoring inline actions: 'refactor.inline' + + Example inline actions: + + - Inline function + - Inline variable + - Inline constant + - ... """ + RefactorRewrite = "refactor.rewrite" + """ Base kind for refactoring rewrite actions: 'refactor.rewrite' + + Example rewrite actions: + + - Convert JavaScript function to class + - Add or remove parameter + - Encapsulate field + - Make method static + - Move method to base class + - ... """ + Source = "source" + """ Base kind for source actions: `source` + + Source code actions apply to the entire file. """ + SourceOrganizeImports = "source.organizeImports" + """ Base kind for an organize imports source action: `source.organizeImports` """ + SourceFixAll = "source.fixAll" + """ Base kind for auto-fix source actions: `source.fixAll`. + + Fix all actions automatically fix errors that have a clear fix that do not require user input. + They should not suppress errors or perform unsafe fixes such as generating new types or classes. + + @since 3.15.0 """ + + +class TraceValues(Enum): + Off = "off" + """ Turn tracing off. """ + Messages = "messages" + """ Trace messages only. """ + Verbose = "verbose" + """ Verbose message tracing. """ + + +class MarkupKind(Enum): + """Describes the content type that a client supports in various + result literals like `Hover`, `ParameterInfo` or `CompletionItem`. + + Please note that `MarkupKinds` must not start with a `$`. This kinds + are reserved for internal usage. + """ + + PlainText = "plaintext" + """ Plain text is supported as a content format """ + Markdown = "markdown" + """ Markdown is supported as a content format """ + + +class PositionEncodingKind(Enum): + """A set of predefined position encoding kinds. + + @since 3.17.0 + """ + + UTF8 = "utf-8" + """ Character offsets count UTF-8 code units. """ + UTF16 = "utf-16" + """ Character offsets count UTF-16 code units. + + This is the default and must always be supported + by servers """ + UTF32 = "utf-32" + """ Character offsets count UTF-32 code units. + + Implementation note: these are the same as Unicode code points, + so this `PositionEncodingKind` may also be used for an + encoding-agnostic representation of character offsets. """ + + +class FileChangeType(IntEnum): + """The file event type""" + + Created = 1 + """ The file got created. """ + Changed = 2 + """ The file got changed. """ + Deleted = 3 + """ The file got deleted. """ + + +class WatchKind(IntFlag): + Create = 1 + """ Interested in create events. """ + Change = 2 + """ Interested in change events """ + Delete = 4 + """ Interested in delete events """ + + +class DiagnosticSeverity(IntEnum): + """The diagnostic's severity.""" + + Error = 1 + """ Reports an error. """ + Warning = 2 + """ Reports a warning. """ + Information = 3 + """ Reports an information. """ + Hint = 4 + """ Reports a hint. """ + + +class DiagnosticTag(IntEnum): + """The diagnostic tags. + + @since 3.15.0 + """ + + Unnecessary = 1 + """ Unused or unnecessary code. + + Clients are allowed to render diagnostics with this tag faded out instead of having + an error squiggle. """ + Deprecated = 2 + """ Deprecated or obsolete code. + + Clients are allowed to rendered diagnostics with this tag strike through. """ + + +class CompletionTriggerKind(IntEnum): + """How a completion was triggered""" + + Invoked = 1 + """ Completion was triggered by typing an identifier (24x7 code + complete), manual invocation (e.g Ctrl+Space) or via API. """ + TriggerCharacter = 2 + """ Completion was triggered by a trigger character specified by + the `triggerCharacters` properties of the `CompletionRegistrationOptions`. """ + TriggerForIncompleteCompletions = 3 + """ Completion was re-triggered as current completion list is incomplete """ + + +class SignatureHelpTriggerKind(IntEnum): + """How a signature help was triggered. + + @since 3.15.0 + """ + + Invoked = 1 + """ Signature help was invoked manually by the user or by a command. """ + TriggerCharacter = 2 + """ Signature help was triggered by a trigger character. """ + ContentChange = 3 + """ Signature help was triggered by the cursor moving or by the document content changing. """ + + +class CodeActionTriggerKind(IntEnum): + """The reason why code actions were requested. + + @since 3.17.0 + """ + + Invoked = 1 + """ Code actions were explicitly requested by the user or by an extension. """ + Automatic = 2 + """ Code actions were requested automatically. + + This typically happens when current selection in a file changes, but can + also be triggered when file content changes. """ + + +class FileOperationPatternKind(Enum): + """A pattern kind describing if a glob pattern matches a file a folder or + both. + + @since 3.16.0 + """ + + File = "file" + """ The pattern matches a file only. """ + Folder = "folder" + """ The pattern matches a folder only. """ + + +class NotebookCellKind(IntEnum): + """A notebook cell kind. + + @since 3.17.0 + """ + + Markup = 1 + """ A markup-cell is formatted source that is used for display. """ + Code = 2 + """ A code-cell is source code. """ + + +class ResourceOperationKind(Enum): + Create = "create" + """ Supports creating new files and folders. """ + Rename = "rename" + """ Supports renaming existing files and folders. """ + Delete = "delete" + """ Supports deleting existing files and folders. """ + + +class FailureHandlingKind(Enum): + Abort = "abort" + """ Applying the workspace change is simply aborted if one of the changes provided + fails. All operations executed before the failing operation stay executed. """ + Transactional = "transactional" + """ All operations are executed transactional. That means they either all + succeed or no changes at all are applied to the workspace. """ + TextOnlyTransactional = "textOnlyTransactional" + """ If the workspace edit contains only textual file changes they are executed transactional. + If resource changes (create, rename or delete file) are part of the change the failure + handling strategy is abort. """ + Undo = "undo" + """ The client tries to undo the operations already executed. But there is no + guarantee that this is succeeding. """ + + +class PrepareSupportDefaultBehavior(IntEnum): + Identifier = 1 + """ The client's default behavior is to select the identifier + according the to language's syntax rule. """ + + +class TokenFormat(Enum): + Relative = "relative" + + +Definition = Union["Location", list["Location"]] +""" The definition of a symbol represented as one or many {@link Location locations}. +For most programming languages there is only one location at which a symbol is +defined. + +Servers should prefer returning `DefinitionLink` over `Definition` if supported +by the client. """ + +DefinitionLink = "LocationLink" +""" Information about where a symbol is defined. + +Provides additional metadata over normal {@link Location location} definitions, including the range of +the defining symbol """ + +LSPArray = list["LSPAny"] +""" LSP arrays. +@since 3.17.0 """ + +LSPAny = Union["LSPObject", "LSPArray", str, int, Uint, float, bool, None] +""" The LSP any type. +Please note that strictly speaking a property with the value `undefined` +can't be converted into JSON preserving the property name. However for +convenience it is allowed and assumed that all these properties are +optional as well. +@since 3.17.0 """ + +Declaration = Union["Location", list["Location"]] +""" The declaration of a symbol representation as one or many {@link Location locations}. """ + +DeclarationLink = "LocationLink" +""" Information about where a symbol is declared. + +Provides additional metadata over normal {@link Location location} declarations, including the range of +the declaring symbol. + +Servers should prefer returning `DeclarationLink` over `Declaration` if supported +by the client. """ + +InlineValue = Union["InlineValueText", "InlineValueVariableLookup", "InlineValueEvaluatableExpression"] +""" Inline value information can be provided by different means: +- directly as a text value (class InlineValueText). +- as a name to use for a variable lookup (class InlineValueVariableLookup) +- as an evaluatable expression (class InlineValueEvaluatableExpression) +The InlineValue types combines all inline value types into one type. + +@since 3.17.0 """ + +DocumentDiagnosticReport = Union["RelatedFullDocumentDiagnosticReport", "RelatedUnchangedDocumentDiagnosticReport"] +""" The result of a document diagnostic pull request. A report can +either be a full report containing all diagnostics for the +requested document or an unchanged report indicating that nothing +has changed in terms of diagnostics in comparison to the last +pull request. + +@since 3.17.0 """ + +PrepareRenameResult = Union["Range", "__PrepareRenameResult_Type_1", "__PrepareRenameResult_Type_2"] + +DocumentSelector = list["DocumentFilter"] +""" A document selector is the combination of one or many document filters. + +@sample `let sel:DocumentSelector = [{ language: 'typescript' }, { language: 'json', pattern: '**/tsconfig.json' }]`; + +The use of a string as a document filter is deprecated @since 3.16.0. """ + +ProgressToken = Union[int, str] + +ChangeAnnotationIdentifier = str +""" An identifier to refer to a change annotation stored with a workspace edit. """ + +WorkspaceDocumentDiagnosticReport = Union[ + "WorkspaceFullDocumentDiagnosticReport", + "WorkspaceUnchangedDocumentDiagnosticReport", +] +""" A workspace diagnostic document report. + +@since 3.17.0 """ + +TextDocumentContentChangeEvent = Union["__TextDocumentContentChangeEvent_Type_1", "__TextDocumentContentChangeEvent_Type_2"] +""" An event describing a change to a text document. If only a text is provided +it is considered to be the full content of the document. """ + +MarkedString = Union[str, "__MarkedString_Type_1"] +""" MarkedString can be used to render human readable text. It is either a markdown string +or a code-block that provides a language and a code snippet. The language identifier +is semantically equal to the optional language identifier in fenced code blocks in GitHub +issues. See https://help.github.com/articles/creating-and-highlighting-code-blocks/#syntax-highlighting + +The pair of a language and a value is an equivalent to markdown: +```${language} +${value} +``` + +Note that markdown strings will be sanitized - that means html will be escaped. +@deprecated use MarkupContent instead. """ + +DocumentFilter = Union["TextDocumentFilter", "NotebookCellTextDocumentFilter"] +""" A document filter describes a top level text document or +a notebook cell document. + +@since 3.17.0 - proposed support for NotebookCellTextDocumentFilter. """ + +LSPObject = dict[str, "LSPAny"] +""" LSP object definition. +@since 3.17.0 """ + +GlobPattern = Union["Pattern", "RelativePattern"] +""" The glob pattern. Either a string pattern or a relative pattern. + +@since 3.17.0 """ + +TextDocumentFilter = Union[ + "__TextDocumentFilter_Type_1", + "__TextDocumentFilter_Type_2", + "__TextDocumentFilter_Type_3", +] +""" A document filter denotes a document by different properties like +the {@link TextDocument.languageId language}, the {@link Uri.scheme scheme} of +its resource, or a glob-pattern that is applied to the {@link TextDocument.fileName path}. + +Glob patterns can have the following syntax: +- `*` to match one or more characters in a path segment +- `?` to match on one character in a path segment +- `**` to match any number of path segments, including none +- `{}` to group sub patterns into an OR expression. (e.g. `**\u200b/*.{ts,js}` matches all TypeScript and JavaScript files) +- `[]` to declare a range of characters to match in a path segment (e.g., `example.[0-9]` to match on `example.0`, `example.1`, …) +- `[!...]` to negate a range of characters to match in a path segment (e.g., `example.[!0-9]` to match on `example.a`, `example.b`, but not `example.0`) + +@sample A language filter that applies to typescript files on disk: `{ language: 'typescript', scheme: 'file' }` +@sample A language filter that applies to all package.json paths: `{ language: 'json', pattern: '**package.json' }` + +@since 3.17.0 """ + +NotebookDocumentFilter = Union[ + "__NotebookDocumentFilter_Type_1", + "__NotebookDocumentFilter_Type_2", + "__NotebookDocumentFilter_Type_3", +] +""" A notebook document filter denotes a notebook document by +different properties. The properties will be match +against the notebook's URI (same as with documents) + +@since 3.17.0 """ + +Pattern = str +""" The glob pattern to watch relative to the base path. Glob patterns can have the following syntax: +- `*` to match one or more characters in a path segment +- `?` to match on one character in a path segment +- `**` to match any number of path segments, including none +- `{}` to group conditions (e.g. `**\u200b/*.{ts,js}` matches all TypeScript and JavaScript files) +- `[]` to declare a range of characters to match in a path segment (e.g., `example.[0-9]` to match on `example.0`, `example.1`, …) +- `[!...]` to negate a range of characters to match in a path segment (e.g., `example.[!0-9]` to match on `example.a`, `example.b`, but not `example.0`) + +@since 3.17.0 """ + + +class ImplementationParams(TypedDict): + textDocument: "TextDocumentIdentifier" + """ The text document. """ + position: "Position" + """ The position inside the text document. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class Location(TypedDict): + """Represents a location inside a resource, such as a line + inside a text file. + """ + + uri: "DocumentUri" + range: "Range" + + +class ImplementationRegistrationOptions(TypedDict): + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + id: NotRequired[str] + """ The id used to register the request. The id can be used to deregister + the request again. See also Registration#id. """ + + +class TypeDefinitionParams(TypedDict): + textDocument: "TextDocumentIdentifier" + """ The text document. """ + position: "Position" + """ The position inside the text document. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class TypeDefinitionRegistrationOptions(TypedDict): + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + id: NotRequired[str] + """ The id used to register the request. The id can be used to deregister + the request again. See also Registration#id. """ + + +class WorkspaceFolder(TypedDict): + """A workspace folder inside a client.""" + + uri: "URI" + """ The associated URI for this workspace folder. """ + name: str + """ The name of the workspace folder. Used to refer to this + workspace folder in the user interface. """ + + +class DidChangeWorkspaceFoldersParams(TypedDict): + """The parameters of a `workspace/didChangeWorkspaceFolders` notification.""" + + event: "WorkspaceFoldersChangeEvent" + """ The actual workspace folder change event. """ + + +class ConfigurationParams(TypedDict): + """The parameters of a configuration request.""" + + items: list["ConfigurationItem"] + + +class DocumentColorParams(TypedDict): + """Parameters for a {@link DocumentColorRequest}.""" + + textDocument: "TextDocumentIdentifier" + """ The text document. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class ColorInformation(TypedDict): + """Represents a color range from a document.""" + + range: "Range" + """ The range in the document where this color appears. """ + color: "Color" + """ The actual color value for this color range. """ + + +class DocumentColorRegistrationOptions(TypedDict): + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + id: NotRequired[str] + """ The id used to register the request. The id can be used to deregister + the request again. See also Registration#id. """ + + +class ColorPresentationParams(TypedDict): + """Parameters for a {@link ColorPresentationRequest}.""" + + textDocument: "TextDocumentIdentifier" + """ The text document. """ + color: "Color" + """ The color to request presentations for. """ + range: "Range" + """ The range where the color would be inserted. Serves as a context. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class ColorPresentation(TypedDict): + label: str + """ The label of this color presentation. It will be shown on the color + picker header. By default this is also the text that is inserted when selecting + this color presentation. """ + textEdit: NotRequired["TextEdit"] + """ An {@link TextEdit edit} which is applied to a document when selecting + this presentation for the color. When `falsy` the {@link ColorPresentation.label label} + is used. """ + additionalTextEdits: NotRequired[list["TextEdit"]] + """ An optional array of additional {@link TextEdit text edits} that are applied when + selecting this color presentation. Edits must not overlap with the main {@link ColorPresentation.textEdit edit} nor with themselves. """ + + +class WorkDoneProgressOptions(TypedDict): + workDoneProgress: NotRequired[bool] + + +class TextDocumentRegistrationOptions(TypedDict): + """General text document registration options.""" + + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + + +class FoldingRangeParams(TypedDict): + """Parameters for a {@link FoldingRangeRequest}.""" + + textDocument: "TextDocumentIdentifier" + """ The text document. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class FoldingRange(TypedDict): + """Represents a folding range. To be valid, start and end line must be bigger than zero and smaller + than the number of lines in the document. Clients are free to ignore invalid ranges. + """ + + startLine: Uint + """ The zero-based start line of the range to fold. The folded area starts after the line's last character. + To be valid, the end must be zero or larger and smaller than the number of lines in the document. """ + startCharacter: NotRequired[Uint] + """ The zero-based character offset from where the folded range starts. If not defined, defaults to the length of the start line. """ + endLine: Uint + """ The zero-based end line of the range to fold. The folded area ends with the line's last character. + To be valid, the end must be zero or larger and smaller than the number of lines in the document. """ + endCharacter: NotRequired[Uint] + """ The zero-based character offset before the folded range ends. If not defined, defaults to the length of the end line. """ + kind: NotRequired["FoldingRangeKind"] + """ Describes the kind of the folding range such as `comment' or 'region'. The kind + is used to categorize folding ranges and used by commands like 'Fold all comments'. + See {@link FoldingRangeKind} for an enumeration of standardized kinds. """ + collapsedText: NotRequired[str] + """ The text that the client should show when the specified range is + collapsed. If not defined or not supported by the client, a default + will be chosen by the client. + + @since 3.17.0 """ + + +class FoldingRangeRegistrationOptions(TypedDict): + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + id: NotRequired[str] + """ The id used to register the request. The id can be used to deregister + the request again. See also Registration#id. """ + + +class DeclarationParams(TypedDict): + textDocument: "TextDocumentIdentifier" + """ The text document. """ + position: "Position" + """ The position inside the text document. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class DeclarationRegistrationOptions(TypedDict): + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + id: NotRequired[str] + """ The id used to register the request. The id can be used to deregister + the request again. See also Registration#id. """ + + +class SelectionRangeParams(TypedDict): + """A parameter literal used in selection range requests.""" + + textDocument: "TextDocumentIdentifier" + """ The text document. """ + positions: list["Position"] + """ The positions inside the text document. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class SelectionRange(TypedDict): + """A selection range represents a part of a selection hierarchy. A selection range + may have a parent selection range that contains it. + """ + + range: "Range" + """ The {@link Range range} of this selection range. """ + parent: NotRequired["SelectionRange"] + """ The parent selection range containing this range. Therefore `parent.range` must contain `this.range`. """ + + +class SelectionRangeRegistrationOptions(TypedDict): + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + id: NotRequired[str] + """ The id used to register the request. The id can be used to deregister + the request again. See also Registration#id. """ + + +class WorkDoneProgressCreateParams(TypedDict): + token: "ProgressToken" + """ The token to be used to report progress. """ + + +class WorkDoneProgressCancelParams(TypedDict): + token: "ProgressToken" + """ The token to be used to report progress. """ + + +class CallHierarchyPrepareParams(TypedDict): + """The parameter of a `textDocument/prepareCallHierarchy` request. + + @since 3.16.0 + """ + + textDocument: "TextDocumentIdentifier" + """ The text document. """ + position: "Position" + """ The position inside the text document. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + + +class CallHierarchyItem(TypedDict): + """Represents programming constructs like functions or constructors in the context + of call hierarchy. + + @since 3.16.0 + """ + + name: str + """ The name of this item. """ + kind: "SymbolKind" + """ The kind of this item. """ + tags: NotRequired[list["SymbolTag"]] + """ Tags for this item. """ + detail: NotRequired[str] + """ More detail for this item, e.g. the signature of a function. """ + uri: "DocumentUri" + """ The resource identifier of this item. """ + range: "Range" + """ The range enclosing this symbol not including leading/trailing whitespace but everything else, e.g. comments and code. """ + selectionRange: "Range" + """ The range that should be selected and revealed when this symbol is being picked, e.g. the name of a function. + Must be contained by the {@link CallHierarchyItem.range `range`}. """ + data: NotRequired["LSPAny"] + """ A data entry field that is preserved between a call hierarchy prepare and + incoming calls or outgoing calls requests. """ + + +class CallHierarchyRegistrationOptions(TypedDict): + """Call hierarchy options used during static or dynamic registration. + + @since 3.16.0 + """ + + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + id: NotRequired[str] + """ The id used to register the request. The id can be used to deregister + the request again. See also Registration#id. """ + + +class CallHierarchyIncomingCallsParams(TypedDict): + """The parameter of a `callHierarchy/incomingCalls` request. + + @since 3.16.0 + """ + + item: "CallHierarchyItem" + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +CallHierarchyIncomingCall = TypedDict( + "CallHierarchyIncomingCall", + { + # The item that makes the call. + "from": "CallHierarchyItem", + # The ranges at which the calls appear. This is relative to the caller + # denoted by {@link CallHierarchyIncomingCall.from `this.from`}. + "fromRanges": list["Range"], + }, +) +""" Represents an incoming call, e.g. a caller of a method or constructor. + +@since 3.16.0 """ + + +class CallHierarchyOutgoingCallsParams(TypedDict): + """The parameter of a `callHierarchy/outgoingCalls` request. + + @since 3.16.0 + """ + + item: "CallHierarchyItem" + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class CallHierarchyOutgoingCall(TypedDict): + """Represents an outgoing call, e.g. calling a getter from a method or a method from a constructor etc. + + @since 3.16.0 + """ + + to: "CallHierarchyItem" + """ The item that is called. """ + fromRanges: list["Range"] + """ The range at which this item is called. This is the range relative to the caller, e.g the item + passed to {@link CallHierarchyItemProvider.provideCallHierarchyOutgoingCalls `provideCallHierarchyOutgoingCalls`} + and not {@link CallHierarchyOutgoingCall.to `this.to`}. """ + + +class SemanticTokensParams(TypedDict): + """@since 3.16.0""" + + textDocument: "TextDocumentIdentifier" + """ The text document. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class SemanticTokens(TypedDict): + """@since 3.16.0""" + + resultId: NotRequired[str] + """ An optional result id. If provided and clients support delta updating + the client will include the result id in the next semantic token request. + A server can then instead of computing all semantic tokens again simply + send a delta. """ + data: list[Uint] + """ The actual tokens. """ + + +class SemanticTokensPartialResult(TypedDict): + """@since 3.16.0""" + + data: list[Uint] + + +class SemanticTokensRegistrationOptions(TypedDict): + """@since 3.16.0""" + + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + legend: "SemanticTokensLegend" + """ The legend used by the server """ + range: NotRequired[bool | dict] + """ Server supports providing semantic tokens for a specific range + of a document. """ + full: NotRequired[Union[bool, "__SemanticTokensOptions_full_Type_1"]] + """ Server supports providing semantic tokens for a full document. """ + id: NotRequired[str] + """ The id used to register the request. The id can be used to deregister + the request again. See also Registration#id. """ + + +class SemanticTokensDeltaParams(TypedDict): + """@since 3.16.0""" + + textDocument: "TextDocumentIdentifier" + """ The text document. """ + previousResultId: str + """ The result id of a previous response. The result Id can either point to a full response + or a delta response depending on what was received last. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class SemanticTokensDelta(TypedDict): + """@since 3.16.0""" + + resultId: NotRequired[str] + edits: list["SemanticTokensEdit"] + """ The semantic token edits to transform a previous result into a new result. """ + + +class SemanticTokensDeltaPartialResult(TypedDict): + """@since 3.16.0""" + + edits: list["SemanticTokensEdit"] + + +class SemanticTokensRangeParams(TypedDict): + """@since 3.16.0""" + + textDocument: "TextDocumentIdentifier" + """ The text document. """ + range: "Range" + """ The range the semantic tokens are requested for. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class ShowDocumentParams(TypedDict): + """Params to show a document. + + @since 3.16.0 + """ + + uri: "URI" + """ The document uri to show. """ + external: NotRequired[bool] + """ Indicates to show the resource in an external program. + To show for example `https://code.visualstudio.com/` + in the default WEB browser set `external` to `true`. """ + takeFocus: NotRequired[bool] + """ An optional property to indicate whether the editor + showing the document should take focus or not. + Clients might ignore this property if an external + program is started. """ + selection: NotRequired["Range"] + """ An optional selection range if the document is a text + document. Clients might ignore the property if an + external program is started or the file is not a text + file. """ + + +class ShowDocumentResult(TypedDict): + """The result of a showDocument request. + + @since 3.16.0 + """ + + success: bool + """ A boolean indicating if the show was successful. """ + + +class LinkedEditingRangeParams(TypedDict): + textDocument: "TextDocumentIdentifier" + """ The text document. """ + position: "Position" + """ The position inside the text document. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + + +class LinkedEditingRanges(TypedDict): + """The result of a linked editing range request. + + @since 3.16.0 + """ + + ranges: list["Range"] + """ A list of ranges that can be edited together. The ranges must have + identical length and contain identical text content. The ranges cannot overlap. """ + wordPattern: NotRequired[str] + """ An optional word pattern (regular expression) that describes valid contents for + the given ranges. If no pattern is provided, the client configuration's word + pattern will be used. """ + + +class LinkedEditingRangeRegistrationOptions(TypedDict): + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + id: NotRequired[str] + """ The id used to register the request. The id can be used to deregister + the request again. See also Registration#id. """ + + +class CreateFilesParams(TypedDict): + """The parameters sent in notifications/requests for user-initiated creation of + files. + + @since 3.16.0 + """ + + files: list["FileCreate"] + """ An array of all files/folders created in this operation. """ + + +class WorkspaceEdit(TypedDict): + """A workspace edit represents changes to many resources managed in the workspace. The edit + should either provide `changes` or `documentChanges`. If documentChanges are present + they are preferred over `changes` if the client can handle versioned document edits. + + Since version 3.13.0 a workspace edit can contain resource operations as well. If resource + operations are present clients need to execute the operations in the order in which they + are provided. So a workspace edit for example can consist of the following two changes: + (1) a create file a.txt and (2) a text document edit which insert text into file a.txt. + + An invalid sequence (e.g. (1) delete file a.txt and (2) insert text into file a.txt) will + cause failure of the operation. How the client recovers from the failure is described by + the client capability: `workspace.workspaceEdit.failureHandling` + """ + + changes: NotRequired[dict["DocumentUri", list["TextEdit"]]] + """ Holds changes to existing resources. """ + documentChanges: NotRequired[list[Union["TextDocumentEdit", "CreateFile", "RenameFile", "DeleteFile"]]] + """ Depending on the client capability `workspace.workspaceEdit.resourceOperations` document changes + are either an array of `TextDocumentEdit`s to express changes to n different text documents + where each text document edit addresses a specific version of a text document. Or it can contain + above `TextDocumentEdit`s mixed with create, rename and delete file / folder operations. + + Whether a client supports versioned document edits is expressed via + `workspace.workspaceEdit.documentChanges` client capability. + + If a client neither supports `documentChanges` nor `workspace.workspaceEdit.resourceOperations` then + only plain `TextEdit`s using the `changes` property are supported. """ + changeAnnotations: NotRequired[dict["ChangeAnnotationIdentifier", "ChangeAnnotation"]] + """ A map of change annotations that can be referenced in `AnnotatedTextEdit`s or create, rename and + delete file / folder operations. + + Whether clients honor this property depends on the client capability `workspace.changeAnnotationSupport`. + + @since 3.16.0 """ + + +class FileOperationRegistrationOptions(TypedDict): + """The options to register for file operations. + + @since 3.16.0 + """ + + filters: list["FileOperationFilter"] + """ The actual filters. """ + + +class RenameFilesParams(TypedDict): + """The parameters sent in notifications/requests for user-initiated renames of + files. + + @since 3.16.0 + """ + + files: list["FileRename"] + """ An array of all files/folders renamed in this operation. When a folder is renamed, only + the folder will be included, and not its children. """ + + +class DeleteFilesParams(TypedDict): + """The parameters sent in notifications/requests for user-initiated deletes of + files. + + @since 3.16.0 + """ + + files: list["FileDelete"] + """ An array of all files/folders deleted in this operation. """ + + +class MonikerParams(TypedDict): + textDocument: "TextDocumentIdentifier" + """ The text document. """ + position: "Position" + """ The position inside the text document. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class Moniker(TypedDict): + """Moniker definition to match LSIF 0.5 moniker definition. + + @since 3.16.0 + """ + + scheme: str + """ The scheme of the moniker. For example tsc or .Net """ + identifier: str + """ The identifier of the moniker. The value is opaque in LSIF however + schema owners are allowed to define the structure if they want. """ + unique: "UniquenessLevel" + """ The scope in which the moniker is unique """ + kind: NotRequired["MonikerKind"] + """ The moniker kind if known. """ + + +class MonikerRegistrationOptions(TypedDict): + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + + +class TypeHierarchyPrepareParams(TypedDict): + """The parameter of a `textDocument/prepareTypeHierarchy` request. + + @since 3.17.0 + """ + + textDocument: "TextDocumentIdentifier" + """ The text document. """ + position: "Position" + """ The position inside the text document. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + + +class TypeHierarchyItem(TypedDict): + """@since 3.17.0""" + + name: str + """ The name of this item. """ + kind: "SymbolKind" + """ The kind of this item. """ + tags: NotRequired[list["SymbolTag"]] + """ Tags for this item. """ + detail: NotRequired[str] + """ More detail for this item, e.g. the signature of a function. """ + uri: "DocumentUri" + """ The resource identifier of this item. """ + range: "Range" + """ The range enclosing this symbol not including leading/trailing whitespace + but everything else, e.g. comments and code. """ + selectionRange: "Range" + """ The range that should be selected and revealed when this symbol is being + picked, e.g. the name of a function. Must be contained by the + {@link TypeHierarchyItem.range `range`}. """ + data: NotRequired["LSPAny"] + """ A data entry field that is preserved between a type hierarchy prepare and + supertypes or subtypes requests. It could also be used to identify the + type hierarchy in the server, helping improve the performance on + resolving supertypes and subtypes. """ + + +class TypeHierarchyRegistrationOptions(TypedDict): + """Type hierarchy options used during static or dynamic registration. + + @since 3.17.0 + """ + + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + id: NotRequired[str] + """ The id used to register the request. The id can be used to deregister + the request again. See also Registration#id. """ + + +class TypeHierarchySupertypesParams(TypedDict): + """The parameter of a `typeHierarchy/supertypes` request. + + @since 3.17.0 + """ + + item: "TypeHierarchyItem" + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class TypeHierarchySubtypesParams(TypedDict): + """The parameter of a `typeHierarchy/subtypes` request. + + @since 3.17.0 + """ + + item: "TypeHierarchyItem" + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class InlineValueParams(TypedDict): + """A parameter literal used in inline value requests. + + @since 3.17.0 + """ + + textDocument: "TextDocumentIdentifier" + """ The text document. """ + range: "Range" + """ The document range for which inline values should be computed. """ + context: "InlineValueContext" + """ Additional information about the context in which inline values were + requested. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + + +class InlineValueRegistrationOptions(TypedDict): + """Inline value options used during static or dynamic registration. + + @since 3.17.0 + """ + + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + id: NotRequired[str] + """ The id used to register the request. The id can be used to deregister + the request again. See also Registration#id. """ + + +class InlayHintParams(TypedDict): + """A parameter literal used in inlay hint requests. + + @since 3.17.0 + """ + + textDocument: "TextDocumentIdentifier" + """ The text document. """ + range: "Range" + """ The document range for which inlay hints should be computed. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + + +class InlayHint(TypedDict): + """Inlay hint information. + + @since 3.17.0 + """ + + position: "Position" + """ The position of this hint. """ + label: str | list["InlayHintLabelPart"] + """ The label of this hint. A human readable string or an array of + InlayHintLabelPart label parts. + + *Note* that neither the string nor the label part can be empty. """ + kind: NotRequired["InlayHintKind"] + """ The kind of this hint. Can be omitted in which case the client + should fall back to a reasonable default. """ + textEdits: NotRequired[list["TextEdit"]] + """ Optional text edits that are performed when accepting this inlay hint. + + *Note* that edits are expected to change the document so that the inlay + hint (or its nearest variant) is now part of the document and the inlay + hint itself is now obsolete. """ + tooltip: NotRequired[Union[str, "MarkupContent"]] + """ The tooltip text when you hover over this item. """ + paddingLeft: NotRequired[bool] + """ Render padding before the hint. + + Note: Padding should use the editor's background color, not the + background color of the hint itself. That means padding can be used + to visually align/separate an inlay hint. """ + paddingRight: NotRequired[bool] + """ Render padding after the hint. + + Note: Padding should use the editor's background color, not the + background color of the hint itself. That means padding can be used + to visually align/separate an inlay hint. """ + data: NotRequired["LSPAny"] + """ A data entry field that is preserved on an inlay hint between + a `textDocument/inlayHint` and a `inlayHint/resolve` request. """ + + +class InlayHintRegistrationOptions(TypedDict): + """Inlay hint options used during static or dynamic registration. + + @since 3.17.0 + """ + + resolveProvider: NotRequired[bool] + """ The server provides support to resolve additional + information for an inlay hint item. """ + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + id: NotRequired[str] + """ The id used to register the request. The id can be used to deregister + the request again. See also Registration#id. """ + + +class DocumentDiagnosticParams(TypedDict): + """Parameters of the document diagnostic request. + + @since 3.17.0 + """ + + textDocument: "TextDocumentIdentifier" + """ The text document. """ + identifier: NotRequired[str] + """ The additional identifier provided during registration. """ + previousResultId: NotRequired[str] + """ The result id of a previous response if provided. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class DocumentDiagnosticReportPartialResult(TypedDict): + """A partial result for a document diagnostic report. + + @since 3.17.0 + """ + + relatedDocuments: dict[ + "DocumentUri", + Union["FullDocumentDiagnosticReport", "UnchangedDocumentDiagnosticReport"], + ] + + +class DiagnosticServerCancellationData(TypedDict): + """Cancellation data returned from a diagnostic request. + + @since 3.17.0 + """ + + retriggerRequest: bool + + +class DiagnosticRegistrationOptions(TypedDict): + """Diagnostic registration options. + + @since 3.17.0 + """ + + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + identifier: NotRequired[str] + """ An optional identifier under which the diagnostics are + managed by the client. """ + interFileDependencies: bool + """ Whether the language has inter file dependencies meaning that + editing code in one file can result in a different diagnostic + set in another file. Inter file dependencies are common for + most programming languages and typically uncommon for linters. """ + workspaceDiagnostics: bool + """ The server provides support for workspace diagnostics as well. """ + id: NotRequired[str] + """ The id used to register the request. The id can be used to deregister + the request again. See also Registration#id. """ + + +class WorkspaceDiagnosticParams(TypedDict): + """Parameters of the workspace diagnostic request. + + @since 3.17.0 + """ + + identifier: NotRequired[str] + """ The additional identifier provided during registration. """ + previousResultIds: list["PreviousResultId"] + """ The currently known diagnostic reports with their + previous result ids. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class WorkspaceDiagnosticReport(TypedDict): + """A workspace diagnostic report. + + @since 3.17.0 + """ + + items: list["WorkspaceDocumentDiagnosticReport"] + + +class WorkspaceDiagnosticReportPartialResult(TypedDict): + """A partial result for a workspace diagnostic report. + + @since 3.17.0 + """ + + items: list["WorkspaceDocumentDiagnosticReport"] + + +class DidOpenNotebookDocumentParams(TypedDict): + """The params sent in an open notebook document notification. + + @since 3.17.0 + """ + + notebookDocument: "NotebookDocument" + """ The notebook document that got opened. """ + cellTextDocuments: list["TextDocumentItem"] + """ The text documents that represent the content + of a notebook cell. """ + + +class DidChangeNotebookDocumentParams(TypedDict): + """The params sent in a change notebook document notification. + + @since 3.17.0 + """ + + notebookDocument: "VersionedNotebookDocumentIdentifier" + """ The notebook document that did change. The version number points + to the version after all provided changes have been applied. If + only the text document content of a cell changes the notebook version + doesn't necessarily have to change. """ + change: "NotebookDocumentChangeEvent" + """ The actual changes to the notebook document. + + The changes describe single state changes to the notebook document. + So if there are two changes c1 (at array index 0) and c2 (at array + index 1) for a notebook in state S then c1 moves the notebook from + S to S' and c2 from S' to S''. So c1 is computed on the state S and + c2 is computed on the state S'. + + To mirror the content of a notebook using change events use the following approach: + - start with the same initial content + - apply the 'notebookDocument/didChange' notifications in the order you receive them. + - apply the `NotebookChangeEvent`s in a single notification in the order + you receive them. """ + + +class DidSaveNotebookDocumentParams(TypedDict): + """The params sent in a save notebook document notification. + + @since 3.17.0 + """ + + notebookDocument: "NotebookDocumentIdentifier" + """ The notebook document that got saved. """ + + +class DidCloseNotebookDocumentParams(TypedDict): + """The params sent in a close notebook document notification. + + @since 3.17.0 + """ + + notebookDocument: "NotebookDocumentIdentifier" + """ The notebook document that got closed. """ + cellTextDocuments: list["TextDocumentIdentifier"] + """ The text documents that represent the content + of a notebook cell that got closed. """ + + +class RegistrationParams(TypedDict): + registrations: list["Registration"] + + +class UnregistrationParams(TypedDict): + unregisterations: list["Unregistration"] + + +class InitializeParams(TypedDict): + processId: int | None + """ The process Id of the parent process that started + the server. + + Is `null` if the process has not been started by another process. + If the parent process is not alive then the server should exit. """ + clientInfo: NotRequired["___InitializeParams_clientInfo_Type_1"] + """ Information about the client + + @since 3.15.0 """ + locale: NotRequired[str] + """ The locale the client is currently showing the user interface + in. This must not necessarily be the locale of the operating + system. + + Uses IETF language tags as the value's syntax + (See https://en.wikipedia.org/wiki/IETF_language_tag) + + @since 3.16.0 """ + rootPath: NotRequired[str | None] + """ The rootPath of the workspace. Is null + if no folder is open. + + @deprecated in favour of rootUri. """ + rootUri: Union["DocumentUri", None] + """ The rootUri of the workspace. Is null if no + folder is open. If both `rootPath` and `rootUri` are set + `rootUri` wins. + + @deprecated in favour of workspaceFolders. """ + capabilities: "ClientCapabilities" + """ The capabilities provided by the client (editor or tool) """ + initializationOptions: NotRequired["LSPAny"] + """ User provided initialization options. """ + trace: NotRequired["TraceValues"] + """ The initial trace setting. If omitted trace is disabled ('off'). """ + workspaceFolders: NotRequired[list["WorkspaceFolder"] | None] + """ The workspace folders configured in the client when the server starts. + + This property is only available if the client supports workspace folders. + It can be `null` if the client supports workspace folders but none are + configured. + + @since 3.6.0 """ + + +class InitializeResult(TypedDict): + """The result returned from an initialize request.""" + + capabilities: "ServerCapabilities" + """ The capabilities the language server provides. """ + serverInfo: NotRequired["__InitializeResult_serverInfo_Type_1"] + """ Information about the server. + + @since 3.15.0 """ + + +class InitializeError(TypedDict): + """The data type of the ResponseError if the + initialize request fails. + """ + + retry: bool + """ Indicates whether the client execute the following retry logic: + (1) show the message provided by the ResponseError to the user + (2) user selects retry or cancel + (3) if user selected retry the initialize method is sent again. """ + + +class InitializedParams(TypedDict): + pass + + +class DidChangeConfigurationParams(TypedDict): + """The parameters of a change configuration notification.""" + + settings: "LSPAny" + """ The actual changed settings """ + + +class DidChangeConfigurationRegistrationOptions(TypedDict): + section: NotRequired[str | list[str]] + + +class ShowMessageParams(TypedDict): + """The parameters of a notification message.""" + + type: "MessageType" + """ The message type. See {@link MessageType} """ + message: str + """ The actual message. """ + + +class ShowMessageRequestParams(TypedDict): + type: "MessageType" + """ The message type. See {@link MessageType} """ + message: str + """ The actual message. """ + actions: NotRequired[list["MessageActionItem"]] + """ The message action items to present. """ + + +class MessageActionItem(TypedDict): + title: str + """ A short title like 'Retry', 'Open Log' etc. """ + + +class LogMessageParams(TypedDict): + """The log message parameters.""" + + type: "MessageType" + """ The message type. See {@link MessageType} """ + message: str + """ The actual message. """ + + +class DidOpenTextDocumentParams(TypedDict): + """The parameters sent in an open text document notification""" + + textDocument: "TextDocumentItem" + """ The document that was opened. """ + + +class DidChangeTextDocumentParams(TypedDict): + """The change text document notification's parameters.""" + + textDocument: "VersionedTextDocumentIdentifier" + """ The document that did change. The version number points + to the version after all provided content changes have + been applied. """ + contentChanges: list["TextDocumentContentChangeEvent"] + """ The actual content changes. The content changes describe single state changes + to the document. So if there are two content changes c1 (at array index 0) and + c2 (at array index 1) for a document in state S then c1 moves the document from + S to S' and c2 from S' to S''. So c1 is computed on the state S and c2 is computed + on the state S'. + + To mirror the content of a document using change events use the following approach: + - start with the same initial content + - apply the 'textDocument/didChange' notifications in the order you receive them. + - apply the `TextDocumentContentChangeEvent`s in a single notification in the order + you receive them. """ + + +class TextDocumentChangeRegistrationOptions(TypedDict): + """Describe options to be used when registered for text document change events.""" + + syncKind: "TextDocumentSyncKind" + """ How documents are synced to the server. """ + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + + +class DidCloseTextDocumentParams(TypedDict): + """The parameters sent in a close text document notification""" + + textDocument: "TextDocumentIdentifier" + """ The document that was closed. """ + + +class DidSaveTextDocumentParams(TypedDict): + """The parameters sent in a save text document notification""" + + textDocument: "TextDocumentIdentifier" + """ The document that was saved. """ + text: NotRequired[str] + """ Optional the content when saved. Depends on the includeText value + when the save notification was requested. """ + + +class TextDocumentSaveRegistrationOptions(TypedDict): + """Save registration options.""" + + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + includeText: NotRequired[bool] + """ The client is supposed to include the content on save. """ + + +class WillSaveTextDocumentParams(TypedDict): + """The parameters sent in a will save text document notification.""" + + textDocument: "TextDocumentIdentifier" + """ The document that will be saved. """ + reason: "TextDocumentSaveReason" + """ The 'TextDocumentSaveReason'. """ + + +class TextEdit(TypedDict): + """A text edit applicable to a text document.""" + + range: "Range" + """ The range of the text document to be manipulated. To insert + text into a document create a range where start === end. """ + newText: str + """ The string to be inserted. For delete operations use an + empty string. """ + + +class DidChangeWatchedFilesParams(TypedDict): + """The watched files change notification's parameters.""" + + changes: list["FileEvent"] + """ The actual file events. """ + + +class DidChangeWatchedFilesRegistrationOptions(TypedDict): + """Describe options to be used when registered for text document change events.""" + + watchers: list["FileSystemWatcher"] + """ The watchers to register. """ + + +class PublishDiagnosticsParams(TypedDict): + """The publish diagnostic notification's parameters.""" + + uri: "DocumentUri" + """ The URI for which diagnostic information is reported. """ + version: NotRequired[int] + """ Optional the version number of the document the diagnostics are published for. + + @since 3.15.0 """ + diagnostics: list["Diagnostic"] + """ An array of diagnostic information items. """ + + +class CompletionParams(TypedDict): + """Completion parameters""" + + context: NotRequired["CompletionContext"] + """ The completion context. This is only available it the client specifies + to send this using the client capability `textDocument.completion.contextSupport === true` """ + textDocument: "TextDocumentIdentifier" + """ The text document. """ + position: "Position" + """ The position inside the text document. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class CompletionItem(TypedDict): + """A completion item represents a text snippet that is + proposed to complete text that is being typed. + """ + + label: str + """ The label of this completion item. + + The label property is also by default the text that + is inserted when selecting this completion. + + If label details are provided the label itself should + be an unqualified name of the completion item. """ + labelDetails: NotRequired["CompletionItemLabelDetails"] + """ Additional details for the label + + @since 3.17.0 """ + kind: NotRequired["CompletionItemKind"] + """ The kind of this completion item. Based of the kind + an icon is chosen by the editor. """ + tags: NotRequired[list["CompletionItemTag"]] + """ Tags for this completion item. + + @since 3.15.0 """ + detail: NotRequired[str] + """ A human-readable string with additional information + about this item, like type or symbol information. """ + documentation: NotRequired[Union[str, "MarkupContent"]] + """ A human-readable string that represents a doc-comment. """ + deprecated: NotRequired[bool] + """ Indicates if this item is deprecated. + @deprecated Use `tags` instead. """ + preselect: NotRequired[bool] + """ Select this item when showing. + + *Note* that only one completion item can be selected and that the + tool / client decides which item that is. The rule is that the *first* + item of those that match best is selected. """ + sortText: NotRequired[str] + """ A string that should be used when comparing this item + with other items. When `falsy` the {@link CompletionItem.label label} + is used. """ + filterText: NotRequired[str] + """ A string that should be used when filtering a set of + completion items. When `falsy` the {@link CompletionItem.label label} + is used. """ + insertText: NotRequired[str] + """ A string that should be inserted into a document when selecting + this completion. When `falsy` the {@link CompletionItem.label label} + is used. + + The `insertText` is subject to interpretation by the client side. + Some tools might not take the string literally. For example + VS Code when code complete is requested in this example + `con` and a completion item with an `insertText` of + `console` is provided it will only insert `sole`. Therefore it is + recommended to use `textEdit` instead since it avoids additional client + side interpretation. """ + insertTextFormat: NotRequired["InsertTextFormat"] + """ The format of the insert text. The format applies to both the + `insertText` property and the `newText` property of a provided + `textEdit`. If omitted defaults to `InsertTextFormat.PlainText`. + + Please note that the insertTextFormat doesn't apply to + `additionalTextEdits`. """ + insertTextMode: NotRequired["InsertTextMode"] + """ How whitespace and indentation is handled during completion + item insertion. If not provided the clients default value depends on + the `textDocument.completion.insertTextMode` client capability. + + @since 3.16.0 """ + textEdit: NotRequired[Union["TextEdit", "InsertReplaceEdit"]] + """ An {@link TextEdit edit} which is applied to a document when selecting + this completion. When an edit is provided the value of + {@link CompletionItem.insertText insertText} is ignored. + + Most editors support two different operations when accepting a completion + item. One is to insert a completion text and the other is to replace an + existing text with a completion text. Since this can usually not be + predetermined by a server it can report both ranges. Clients need to + signal support for `InsertReplaceEdits` via the + `textDocument.completion.insertReplaceSupport` client capability + property. + + *Note 1:* The text edit's range as well as both ranges from an insert + replace edit must be a [single line] and they must contain the position + at which completion has been requested. + *Note 2:* If an `InsertReplaceEdit` is returned the edit's insert range + must be a prefix of the edit's replace range, that means it must be + contained and starting at the same position. + + @since 3.16.0 additional type `InsertReplaceEdit` """ + textEditText: NotRequired[str] + """ The edit text used if the completion item is part of a CompletionList and + CompletionList defines an item default for the text edit range. + + Clients will only honor this property if they opt into completion list + item defaults using the capability `completionList.itemDefaults`. + + If not provided and a list's default range is provided the label + property is used as a text. + + @since 3.17.0 """ + additionalTextEdits: NotRequired[list["TextEdit"]] + """ An optional array of additional {@link TextEdit text edits} that are applied when + selecting this completion. Edits must not overlap (including the same insert position) + with the main {@link CompletionItem.textEdit edit} nor with themselves. + + Additional text edits should be used to change text unrelated to the current cursor position + (for example adding an import statement at the top of the file if the completion item will + insert an unqualified type). """ + commitCharacters: NotRequired[list[str]] + """ An optional set of characters that when pressed while this completion is active will accept it first and + then type that character. *Note* that all commit characters should have `length=1` and that superfluous + characters will be ignored. """ + command: NotRequired["Command"] + """ An optional {@link Command command} that is executed *after* inserting this completion. *Note* that + additional modifications to the current document should be described with the + {@link CompletionItem.additionalTextEdits additionalTextEdits}-property. """ + data: NotRequired["LSPAny"] + """ A data entry field that is preserved on a completion item between a + {@link CompletionRequest} and a {@link CompletionResolveRequest}. """ + + +class CompletionList(TypedDict): + """Represents a collection of {@link CompletionItem completion items} to be presented + in the editor. + """ + + isIncomplete: bool + """ This list it not complete. Further typing results in recomputing this list. + + Recomputed lists have all their items replaced (not appended) in the + incomplete completion sessions. """ + itemDefaults: NotRequired["__CompletionList_itemDefaults_Type_1"] + """ In many cases the items of an actual completion result share the same + value for properties like `commitCharacters` or the range of a text + edit. A completion list can therefore define item defaults which will + be used if a completion item itself doesn't specify the value. + + If a completion list specifies a default value and a completion item + also specifies a corresponding value the one from the item is used. + + Servers are only allowed to return default values if the client + signals support for this via the `completionList.itemDefaults` + capability. + + @since 3.17.0 """ + items: list["CompletionItem"] + """ The completion items. """ + + +class CompletionRegistrationOptions(TypedDict): + """Registration options for a {@link CompletionRequest}.""" + + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + triggerCharacters: NotRequired[list[str]] + """ Most tools trigger completion request automatically without explicitly requesting + it using a keyboard shortcut (e.g. Ctrl+Space). Typically they do so when the user + starts to type an identifier. For example if the user types `c` in a JavaScript file + code complete will automatically pop up present `console` besides others as a + completion item. Characters that make up identifiers don't need to be listed here. + + If code complete should automatically be trigger on characters not being valid inside + an identifier (for example `.` in JavaScript) list them in `triggerCharacters`. """ + allCommitCharacters: NotRequired[list[str]] + """ The list of all possible characters that commit a completion. This field can be used + if clients don't support individual commit characters per completion item. See + `ClientCapabilities.textDocument.completion.completionItem.commitCharactersSupport` + + If a server provides both `allCommitCharacters` and commit characters on an individual + completion item the ones on the completion item win. + + @since 3.2.0 """ + resolveProvider: NotRequired[bool] + """ The server provides support to resolve additional + information for a completion item. """ + completionItem: NotRequired["__CompletionOptions_completionItem_Type_1"] + """ The server supports the following `CompletionItem` specific + capabilities. + + @since 3.17.0 """ + + +class HoverParams(TypedDict): + """Parameters for a {@link HoverRequest}.""" + + textDocument: "TextDocumentIdentifier" + """ The text document. """ + position: "Position" + """ The position inside the text document. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + + +class Hover(TypedDict): + """The result of a hover request.""" + + contents: Union["MarkupContent", "MarkedString", list["MarkedString"]] + """ The hover's content """ + range: NotRequired["Range"] + """ An optional range inside the text document that is used to + visualize the hover, e.g. by changing the background color. """ + + +class HoverRegistrationOptions(TypedDict): + """Registration options for a {@link HoverRequest}.""" + + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + + +class SignatureHelpParams(TypedDict): + """Parameters for a {@link SignatureHelpRequest}.""" + + context: NotRequired["SignatureHelpContext"] + """ The signature help context. This is only available if the client specifies + to send this using the client capability `textDocument.signatureHelp.contextSupport === true` + + @since 3.15.0 """ + textDocument: "TextDocumentIdentifier" + """ The text document. """ + position: "Position" + """ The position inside the text document. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + + +class SignatureHelp(TypedDict): + """Signature help represents the signature of something + callable. There can be multiple signature but only one + active and only one active parameter. + """ + + signatures: list["SignatureInformation"] + """ One or more signatures. """ + activeSignature: NotRequired[Uint] + """ The active signature. If omitted or the value lies outside the + range of `signatures` the value defaults to zero or is ignored if + the `SignatureHelp` has no signatures. + + Whenever possible implementers should make an active decision about + the active signature and shouldn't rely on a default value. + + In future version of the protocol this property might become + mandatory to better express this. """ + activeParameter: NotRequired[Uint] + """ The active parameter of the active signature. If omitted or the value + lies outside the range of `signatures[activeSignature].parameters` + defaults to 0 if the active signature has parameters. If + the active signature has no parameters it is ignored. + In future version of the protocol this property might become + mandatory to better express the active parameter if the + active signature does have any. """ + + +class SignatureHelpRegistrationOptions(TypedDict): + """Registration options for a {@link SignatureHelpRequest}.""" + + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + triggerCharacters: NotRequired[list[str]] + """ List of characters that trigger signature help automatically. """ + retriggerCharacters: NotRequired[list[str]] + """ List of characters that re-trigger signature help. + + These trigger characters are only active when signature help is already showing. All trigger characters + are also counted as re-trigger characters. + + @since 3.15.0 """ + + +class DefinitionParams(TypedDict): + """Parameters for a {@link DefinitionRequest}.""" + + textDocument: "TextDocumentIdentifier" + """ The text document. """ + position: "Position" + """ The position inside the text document. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class DefinitionRegistrationOptions(TypedDict): + """Registration options for a {@link DefinitionRequest}.""" + + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + + +class ReferenceParams(TypedDict): + """Parameters for a {@link ReferencesRequest}.""" + + context: "ReferenceContext" + textDocument: "TextDocumentIdentifier" + """ The text document. """ + position: "Position" + """ The position inside the text document. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class ReferenceRegistrationOptions(TypedDict): + """Registration options for a {@link ReferencesRequest}.""" + + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + + +class DocumentHighlightParams(TypedDict): + """Parameters for a {@link DocumentHighlightRequest}.""" + + textDocument: "TextDocumentIdentifier" + """ The text document. """ + position: "Position" + """ The position inside the text document. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class DocumentHighlight(TypedDict): + """A document highlight is a range inside a text document which deserves + special attention. Usually a document highlight is visualized by changing + the background color of its range. + """ + + range: "Range" + """ The range this highlight applies to. """ + kind: NotRequired["DocumentHighlightKind"] + """ The highlight kind, default is {@link DocumentHighlightKind.Text text}. """ + + +class DocumentHighlightRegistrationOptions(TypedDict): + """Registration options for a {@link DocumentHighlightRequest}.""" + + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + + +class DocumentSymbolParams(TypedDict): + """Parameters for a {@link DocumentSymbolRequest}.""" + + textDocument: "TextDocumentIdentifier" + """ The text document. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class SymbolInformation(TypedDict): + """Represents information about programming constructs like variables, classes, + interfaces etc. + """ + + deprecated: NotRequired[bool] + """ Indicates if this symbol is deprecated. + + @deprecated Use tags instead """ + location: "Location" + """ The location of this symbol. The location's range is used by a tool + to reveal the location in the editor. If the symbol is selected in the + tool the range's start information is used to position the cursor. So + the range usually spans more than the actual symbol's name and does + normally include things like visibility modifiers. + + The range doesn't have to denote a node range in the sense of an abstract + syntax tree. It can therefore not be used to re-construct a hierarchy of + the symbols. """ + name: str + """ The name of this symbol. """ + kind: "SymbolKind" + """ The kind of this symbol. """ + tags: NotRequired[list["SymbolTag"]] + """ Tags for this symbol. + + @since 3.16.0 """ + containerName: NotRequired[str] + """ The name of the symbol containing this symbol. This information is for + user interface purposes (e.g. to render a qualifier in the user interface + if necessary). It can't be used to re-infer a hierarchy for the document + symbols. """ + + +class DocumentSymbol(TypedDict): + """Represents programming constructs like variables, classes, interfaces etc. + that appear in a document. Document symbols can be hierarchical and they + have two ranges: one that encloses its definition and one that points to + its most interesting range, e.g. the range of an identifier. + """ + + name: str + """ The name of this symbol. Will be displayed in the user interface and therefore must not be + an empty string or a string only consisting of white spaces. """ + detail: NotRequired[str] + """ More detail for this symbol, e.g the signature of a function. """ + kind: "SymbolKind" + """ The kind of this symbol. """ + tags: NotRequired[list["SymbolTag"]] + """ Tags for this document symbol. + + @since 3.16.0 """ + deprecated: NotRequired[bool] + """ Indicates if this symbol is deprecated. + + @deprecated Use tags instead """ + range: "Range" + """ The range enclosing this symbol not including leading/trailing whitespace but everything else + like comments. This information is typically used to determine if the clients cursor is + inside the symbol to reveal in the symbol in the UI. """ + selectionRange: "Range" + """ The range that should be selected and revealed when this symbol is being picked, e.g the name of a function. + Must be contained by the `range`. """ + children: NotRequired[list["DocumentSymbol"]] + """ Children of this symbol, e.g. properties of a class. """ + + +class DocumentSymbolRegistrationOptions(TypedDict): + """Registration options for a {@link DocumentSymbolRequest}.""" + + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + label: NotRequired[str] + """ A human-readable string that is shown when multiple outlines trees + are shown for the same document. + + @since 3.16.0 """ + + +class CodeActionParams(TypedDict): + """The parameters of a {@link CodeActionRequest}.""" + + textDocument: "TextDocumentIdentifier" + """ The document in which the command was invoked. """ + range: "Range" + """ The range for which the command was invoked. """ + context: "CodeActionContext" + """ Context carrying additional information. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class Command(TypedDict): + """Represents a reference to a command. Provides a title which + will be used to represent a command in the UI and, optionally, + an array of arguments which will be passed to the command handler + function when invoked. + """ + + title: str + """ Title of the command, like `save`. """ + command: str + """ The identifier of the actual command handler. """ + arguments: NotRequired[list["LSPAny"]] + """ Arguments that the command handler should be + invoked with. """ + + +class CodeAction(TypedDict): + """A code action represents a change that can be performed in code, e.g. to fix a problem or + to refactor code. + + A CodeAction must set either `edit` and/or a `command`. If both are supplied, the `edit` is applied first, then the `command` is executed. + """ + + title: str + """ A short, human-readable, title for this code action. """ + kind: NotRequired["CodeActionKind"] + """ The kind of the code action. + + Used to filter code actions. """ + diagnostics: NotRequired[list["Diagnostic"]] + """ The diagnostics that this code action resolves. """ + isPreferred: NotRequired[bool] + """ Marks this as a preferred action. Preferred actions are used by the `auto fix` command and can be targeted + by keybindings. + + A quick fix should be marked preferred if it properly addresses the underlying error. + A refactoring should be marked preferred if it is the most reasonable choice of actions to take. + + @since 3.15.0 """ + disabled: NotRequired["__CodeAction_disabled_Type_1"] + """ Marks that the code action cannot currently be applied. + + Clients should follow the following guidelines regarding disabled code actions: + + - Disabled code actions are not shown in automatic [lightbulbs](https://code.visualstudio.com/docs/editor/editingevolved#_code-action) + code action menus. + + - Disabled actions are shown as faded out in the code action menu when the user requests a more specific type + of code action, such as refactorings. + + - If the user has a [keybinding](https://code.visualstudio.com/docs/editor/refactoring#_keybindings-for-code-actions) + that auto applies a code action and only disabled code actions are returned, the client should show the user an + error message with `reason` in the editor. + + @since 3.16.0 """ + edit: NotRequired["WorkspaceEdit"] + """ The workspace edit this code action performs. """ + command: NotRequired["Command"] + """ A command this code action executes. If a code action + provides an edit and a command, first the edit is + executed and then the command. """ + data: NotRequired["LSPAny"] + """ A data entry field that is preserved on a code action between + a `textDocument/codeAction` and a `codeAction/resolve` request. + + @since 3.16.0 """ + + +class CodeActionRegistrationOptions(TypedDict): + """Registration options for a {@link CodeActionRequest}.""" + + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + codeActionKinds: NotRequired[list["CodeActionKind"]] + """ CodeActionKinds that this server may return. + + The list of kinds may be generic, such as `CodeActionKind.Refactor`, or the server + may list out every specific kind they provide. """ + resolveProvider: NotRequired[bool] + """ The server provides support to resolve additional + information for a code action. + + @since 3.16.0 """ + + +class WorkspaceSymbolParams(TypedDict): + """The parameters of a {@link WorkspaceSymbolRequest}.""" + + query: str + """ A query string to filter symbols by. Clients may send an empty + string here to request all symbols. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class WorkspaceSymbol(TypedDict): + """A special workspace symbol that supports locations without a range. + + See also SymbolInformation. + + @since 3.17.0 + """ + + location: Union["Location", "__WorkspaceSymbol_location_Type_1"] + """ The location of the symbol. Whether a server is allowed to + return a location without a range depends on the client + capability `workspace.symbol.resolveSupport`. + + See SymbolInformation#location for more details. """ + data: NotRequired["LSPAny"] + """ A data entry field that is preserved on a workspace symbol between a + workspace symbol request and a workspace symbol resolve request. """ + name: str + """ The name of this symbol. """ + kind: "SymbolKind" + """ The kind of this symbol. """ + tags: NotRequired[list["SymbolTag"]] + """ Tags for this symbol. + + @since 3.16.0 """ + containerName: NotRequired[str] + """ The name of the symbol containing this symbol. This information is for + user interface purposes (e.g. to render a qualifier in the user interface + if necessary). It can't be used to re-infer a hierarchy for the document + symbols. """ + + +class WorkspaceSymbolRegistrationOptions(TypedDict): + """Registration options for a {@link WorkspaceSymbolRequest}.""" + + resolveProvider: NotRequired[bool] + """ The server provides support to resolve additional + information for a workspace symbol. + + @since 3.17.0 """ + + +class CodeLensParams(TypedDict): + """The parameters of a {@link CodeLensRequest}.""" + + textDocument: "TextDocumentIdentifier" + """ The document to request code lens for. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class CodeLens(TypedDict): + """A code lens represents a {@link Command command} that should be shown along with + source text, like the number of references, a way to run tests, etc. + + A code lens is _unresolved_ when no command is associated to it. For performance + reasons the creation of a code lens and resolving should be done in two stages. + """ + + range: "Range" + """ The range in which this code lens is valid. Should only span a single line. """ + command: NotRequired["Command"] + """ The command this code lens represents. """ + data: NotRequired["LSPAny"] + """ A data entry field that is preserved on a code lens item between + a {@link CodeLensRequest} and a [CodeLensResolveRequest] + (#CodeLensResolveRequest) """ + + +class CodeLensRegistrationOptions(TypedDict): + """Registration options for a {@link CodeLensRequest}.""" + + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + resolveProvider: NotRequired[bool] + """ Code lens has a resolve provider as well. """ + + +class DocumentLinkParams(TypedDict): + """The parameters of a {@link DocumentLinkRequest}.""" + + textDocument: "TextDocumentIdentifier" + """ The document to provide document links for. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class DocumentLink(TypedDict): + """A document link is a range in a text document that links to an internal or external resource, like another + text document or a web site. + """ + + range: "Range" + """ The range this link applies to. """ + target: NotRequired[str] + """ The uri this link points to. If missing a resolve request is sent later. """ + tooltip: NotRequired[str] + """ The tooltip text when you hover over this link. + + If a tooltip is provided, is will be displayed in a string that includes instructions on how to + trigger the link, such as `{0} (ctrl + click)`. The specific instructions vary depending on OS, + user settings, and localization. + + @since 3.15.0 """ + data: NotRequired["LSPAny"] + """ A data entry field that is preserved on a document link between a + DocumentLinkRequest and a DocumentLinkResolveRequest. """ + + +class DocumentLinkRegistrationOptions(TypedDict): + """Registration options for a {@link DocumentLinkRequest}.""" + + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + resolveProvider: NotRequired[bool] + """ Document links have a resolve provider as well. """ + + +class DocumentFormattingParams(TypedDict): + """The parameters of a {@link DocumentFormattingRequest}.""" + + textDocument: "TextDocumentIdentifier" + """ The document to format. """ + options: "FormattingOptions" + """ The format options. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + + +class DocumentFormattingRegistrationOptions(TypedDict): + """Registration options for a {@link DocumentFormattingRequest}.""" + + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + + +class DocumentRangeFormattingParams(TypedDict): + """The parameters of a {@link DocumentRangeFormattingRequest}.""" + + textDocument: "TextDocumentIdentifier" + """ The document to format. """ + range: "Range" + """ The range to format """ + options: "FormattingOptions" + """ The format options """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + + +class DocumentRangeFormattingRegistrationOptions(TypedDict): + """Registration options for a {@link DocumentRangeFormattingRequest}.""" + + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + + +class DocumentOnTypeFormattingParams(TypedDict): + """The parameters of a {@link DocumentOnTypeFormattingRequest}.""" + + textDocument: "TextDocumentIdentifier" + """ The document to format. """ + position: "Position" + """ The position around which the on type formatting should happen. + This is not necessarily the exact position where the character denoted + by the property `ch` got typed. """ + ch: str + """ The character that has been typed that triggered the formatting + on type request. That is not necessarily the last character that + got inserted into the document since the client could auto insert + characters as well (e.g. like automatic brace completion). """ + options: "FormattingOptions" + """ The formatting options. """ + + +class DocumentOnTypeFormattingRegistrationOptions(TypedDict): + """Registration options for a {@link DocumentOnTypeFormattingRequest}.""" + + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + firstTriggerCharacter: str + """ A character on which formatting should be triggered, like `{`. """ + moreTriggerCharacter: NotRequired[list[str]] + """ More trigger characters. """ + + +class RenameParams(TypedDict): + """The parameters of a {@link RenameRequest}.""" + + textDocument: "TextDocumentIdentifier" + """ The document to rename. """ + position: "Position" + """ The position at which this request was sent. """ + newName: str + """ The new name of the symbol. If the given name is not valid the + request must return a {@link ResponseError} with an + appropriate message set. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + + +class RenameRegistrationOptions(TypedDict): + """Registration options for a {@link RenameRequest}.""" + + documentSelector: Union["DocumentSelector", None] + """ A document selector to identify the scope of the registration. If set to null + the document selector provided on the client side will be used. """ + prepareProvider: NotRequired[bool] + """ Renames should be checked and tested before being executed. + + @since version 3.12.0 """ + + +class PrepareRenameParams(TypedDict): + textDocument: "TextDocumentIdentifier" + """ The text document. """ + position: "Position" + """ The position inside the text document. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + + +class ExecuteCommandParams(TypedDict): + """The parameters of a {@link ExecuteCommandRequest}.""" + + command: str + """ The identifier of the actual command handler. """ + arguments: NotRequired[list["LSPAny"]] + """ Arguments that the command should be invoked with. """ + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + + +class ExecuteCommandRegistrationOptions(TypedDict): + """Registration options for a {@link ExecuteCommandRequest}.""" + + commands: list[str] + """ The commands to be executed on the server """ + + +class ApplyWorkspaceEditParams(TypedDict): + """The parameters passed via a apply workspace edit request.""" + + label: NotRequired[str] + """ An optional label of the workspace edit. This label is + presented in the user interface for example on an undo + stack to undo the workspace edit. """ + edit: "WorkspaceEdit" + """ The edits to apply. """ + + +class ApplyWorkspaceEditResult(TypedDict): + """The result returned from the apply workspace edit request. + + @since 3.17 renamed from ApplyWorkspaceEditResponse + """ + + applied: bool + """ Indicates whether the edit was applied or not. """ + failureReason: NotRequired[str] + """ An optional textual description for why the edit was not applied. + This may be used by the server for diagnostic logging or to provide + a suitable error for a request that triggered the edit. """ + failedChange: NotRequired[Uint] + """ Depending on the client's failure handling strategy `failedChange` might + contain the index of the change that failed. This property is only available + if the client signals a `failureHandlingStrategy` in its client capabilities. """ + + +class WorkDoneProgressBegin(TypedDict): + kind: Literal["begin"] + title: str + """ Mandatory title of the progress operation. Used to briefly inform about + the kind of operation being performed. + + Examples: "Indexing" or "Linking dependencies". """ + cancellable: NotRequired[bool] + """ Controls if a cancel button should show to allow the user to cancel the + long running operation. Clients that don't support cancellation are allowed + to ignore the setting. """ + message: NotRequired[str] + """ Optional, more detailed associated progress message. Contains + complementary information to the `title`. + + Examples: "3/25 files", "project/src/module2", "node_modules/some_dep". + If unset, the previous progress message (if any) is still valid. """ + percentage: NotRequired[Uint] + """ Optional progress percentage to display (value 100 is considered 100%). + If not provided infinite progress is assumed and clients are allowed + to ignore the `percentage` value in subsequent in report notifications. + + The value should be steadily rising. Clients are free to ignore values + that are not following this rule. The value range is [0, 100]. """ + + +class WorkDoneProgressReport(TypedDict): + kind: Literal["report"] + cancellable: NotRequired[bool] + """ Controls enablement state of a cancel button. + + Clients that don't support cancellation or don't support controlling the button's + enablement state are allowed to ignore the property. """ + message: NotRequired[str] + """ Optional, more detailed associated progress message. Contains + complementary information to the `title`. + + Examples: "3/25 files", "project/src/module2", "node_modules/some_dep". + If unset, the previous progress message (if any) is still valid. """ + percentage: NotRequired[Uint] + """ Optional progress percentage to display (value 100 is considered 100%). + If not provided infinite progress is assumed and clients are allowed + to ignore the `percentage` value in subsequent in report notifications. + + The value should be steadily rising. Clients are free to ignore values + that are not following this rule. The value range is [0, 100] """ + + +class WorkDoneProgressEnd(TypedDict): + kind: Literal["end"] + message: NotRequired[str] + """ Optional, a final message indicating to for example indicate the outcome + of the operation. """ + + +class SetTraceParams(TypedDict): + value: "TraceValues" + + +class LogTraceParams(TypedDict): + message: str + verbose: NotRequired[str] + + +class CancelParams(TypedDict): + id: int | str + """ The request id to cancel. """ + + +class ProgressParams(TypedDict): + token: "ProgressToken" + """ The progress token provided by the client or server. """ + value: "LSPAny" + """ The progress data. """ + + +class TextDocumentPositionParams(TypedDict): + """A parameter literal used in requests to pass a text document and a position inside that + document. + """ + + textDocument: "TextDocumentIdentifier" + """ The text document. """ + position: "Position" + """ The position inside the text document. """ + + +class WorkDoneProgressParams(TypedDict): + workDoneToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report work done progress. """ + + +class PartialResultParams(TypedDict): + partialResultToken: NotRequired["ProgressToken"] + """ An optional token that a server can use to report partial results (e.g. streaming) to + the client. """ + + +class LocationLink(TypedDict): + """Represents the connection of two locations. Provides additional metadata over normal {@link Location locations}, + including an origin range. + """ + + originSelectionRange: NotRequired["Range"] + """ Span of the origin of this link. + + Used as the underlined span for mouse interaction. Defaults to the word range at + the definition position. """ + targetUri: "DocumentUri" + """ The target resource identifier of this link. """ + targetRange: "Range" + """ The full target range of this link. If the target for example is a symbol then target range is the + range enclosing this symbol not including leading/trailing whitespace but everything else + like comments. This information is typically used to highlight the range in the editor. """ + targetSelectionRange: "Range" + """ The range that should be selected and revealed when this link is being followed, e.g the name of a function. + Must be contained by the `targetRange`. See also `DocumentSymbol#range` """ + + +class Range(TypedDict): + """A range in a text document expressed as (zero-based) start and end positions. + + If you want to specify a range that contains a line including the line ending + character(s) then use an end position denoting the start of the next line. + For example: + ```ts + { + start: { line: 5, character: 23 } + end : { line 6, character : 0 } + } + ``` + """ + + start: "Position" + """ The range's start position. """ + end: "Position" + """ The range's end position. """ + + +class ImplementationOptions(TypedDict): + workDoneProgress: NotRequired[bool] + + +class StaticRegistrationOptions(TypedDict): + """Static registration options to be returned in the initialize + request. + """ + + id: NotRequired[str] + """ The id used to register the request. The id can be used to deregister + the request again. See also Registration#id. """ + + +class TypeDefinitionOptions(TypedDict): + workDoneProgress: NotRequired[bool] + + +class WorkspaceFoldersChangeEvent(TypedDict): + """The workspace folder change event.""" + + added: list["WorkspaceFolder"] + """ The array of added workspace folders """ + removed: list["WorkspaceFolder"] + """ The array of the removed workspace folders """ + + +class ConfigurationItem(TypedDict): + scopeUri: NotRequired[str] + """ The scope to get the configuration section for. """ + section: NotRequired[str] + """ The configuration section asked for. """ + + +class TextDocumentIdentifier(TypedDict): + """A literal to identify a text document in the client.""" + + uri: "DocumentUri" + """ The text document's uri. """ + + +class Color(TypedDict): + """Represents a color in RGBA space.""" + + red: float + """ The red component of this color in the range [0-1]. """ + green: float + """ The green component of this color in the range [0-1]. """ + blue: float + """ The blue component of this color in the range [0-1]. """ + alpha: float + """ The alpha component of this color in the range [0-1]. """ + + +class DocumentColorOptions(TypedDict): + workDoneProgress: NotRequired[bool] + + +class FoldingRangeOptions(TypedDict): + workDoneProgress: NotRequired[bool] + + +class DeclarationOptions(TypedDict): + workDoneProgress: NotRequired[bool] + + +class Position(TypedDict): + r"""Position in a text document expressed as zero-based line and character + offset. Prior to 3.17 the offsets were always based on a UTF-16 string + representation. So a string of the form `a𐐀b` the character offset of the + character `a` is 0, the character offset of `𐐀` is 1 and the character + offset of b is 3 since `𐐀` is represented using two code units in UTF-16. + Since 3.17 clients and servers can agree on a different string encoding + representation (e.g. UTF-8). The client announces it's supported encoding + via the client capability [`general.positionEncodings`](#clientCapabilities). + The value is an array of position encodings the client supports, with + decreasing preference (e.g. the encoding at index `0` is the most preferred + one). To stay backwards compatible the only mandatory encoding is UTF-16 + represented via the string `utf-16`. The server can pick one of the + encodings offered by the client and signals that encoding back to the + client via the initialize result's property + [`capabilities.positionEncoding`](#serverCapabilities). If the string value + `utf-16` is missing from the client's capability `general.positionEncodings` + servers can safely assume that the client supports UTF-16. If the server + omits the position encoding in its initialize result the encoding defaults + to the string value `utf-16`. Implementation considerations: since the + conversion from one encoding into another requires the content of the + file / line the conversion is best done where the file is read which is + usually on the server side. + + Positions are line end character agnostic. So you can not specify a position + that denotes `\r|\n` or `\n|` where `|` represents the character offset. + + @since 3.17.0 - support for negotiated position encoding. + """ + + line: Uint + """ Line position in a document (zero-based). + + If a line number is greater than the number of lines in a document, it defaults back to the number of lines in the document. + If a line number is negative, it defaults to 0. """ + character: Uint + """ Character offset on a line in a document (zero-based). + + The meaning of this offset is determined by the negotiated + `PositionEncodingKind`. + + If the character value is greater than the line length it defaults back to the + line length. """ + + +class SelectionRangeOptions(TypedDict): + workDoneProgress: NotRequired[bool] + + +class CallHierarchyOptions(TypedDict): + """Call hierarchy options used during static registration. + + @since 3.16.0 + """ + + workDoneProgress: NotRequired[bool] + + +class SemanticTokensOptions(TypedDict): + """@since 3.16.0""" + + legend: "SemanticTokensLegend" + """ The legend used by the server """ + range: NotRequired[bool | dict] + """ Server supports providing semantic tokens for a specific range + of a document. """ + full: NotRequired[Union[bool, "__SemanticTokensOptions_full_Type_2"]] + """ Server supports providing semantic tokens for a full document. """ + workDoneProgress: NotRequired[bool] + + +class SemanticTokensEdit(TypedDict): + """@since 3.16.0""" + + start: Uint + """ The start offset of the edit. """ + deleteCount: Uint + """ The count of elements to remove. """ + data: NotRequired[list[Uint]] + """ The elements to insert. """ + + +class LinkedEditingRangeOptions(TypedDict): + workDoneProgress: NotRequired[bool] + + +class FileCreate(TypedDict): + """Represents information on a file/folder create. + + @since 3.16.0 + """ + + uri: str + """ A file:// URI for the location of the file/folder being created. """ + + +class TextDocumentEdit(TypedDict): + """Describes textual changes on a text document. A TextDocumentEdit describes all changes + on a document version Si and after they are applied move the document to version Si+1. + So the creator of a TextDocumentEdit doesn't need to sort the array of edits or do any + kind of ordering. However the edits must be non overlapping. + """ + + textDocument: "OptionalVersionedTextDocumentIdentifier" + """ The text document to change. """ + edits: list[Union["TextEdit", "AnnotatedTextEdit"]] + """ The edits to be applied. + + @since 3.16.0 - support for AnnotatedTextEdit. This is guarded using a + client capability. """ + + +class CreateFile(TypedDict): + """Create file operation.""" + + kind: Literal["create"] + """ A create """ + uri: "DocumentUri" + """ The resource to create. """ + options: NotRequired["CreateFileOptions"] + """ Additional options """ + annotationId: NotRequired["ChangeAnnotationIdentifier"] + """ An optional annotation identifier describing the operation. + + @since 3.16.0 """ + + +class RenameFile(TypedDict): + """Rename file operation""" + + kind: Literal["rename"] + """ A rename """ + oldUri: "DocumentUri" + """ The old (existing) location. """ + newUri: "DocumentUri" + """ The new location. """ + options: NotRequired["RenameFileOptions"] + """ Rename options. """ + annotationId: NotRequired["ChangeAnnotationIdentifier"] + """ An optional annotation identifier describing the operation. + + @since 3.16.0 """ + + +class DeleteFile(TypedDict): + """Delete file operation""" + + kind: Literal["delete"] + """ A delete """ + uri: "DocumentUri" + """ The file to delete. """ + options: NotRequired["DeleteFileOptions"] + """ Delete options. """ + annotationId: NotRequired["ChangeAnnotationIdentifier"] + """ An optional annotation identifier describing the operation. + + @since 3.16.0 """ + + +class ChangeAnnotation(TypedDict): + """Additional information that describes document changes. + + @since 3.16.0 + """ + + label: str + """ A human-readable string describing the actual change. The string + is rendered prominent in the user interface. """ + needsConfirmation: NotRequired[bool] + """ A flag which indicates that user confirmation is needed + before applying the change. """ + description: NotRequired[str] + """ A human-readable string which is rendered less prominent in + the user interface. """ + + +class FileOperationFilter(TypedDict): + """A filter to describe in which file operation requests or notifications + the server is interested in receiving. + + @since 3.16.0 + """ + + scheme: NotRequired[str] + """ A Uri scheme like `file` or `untitled`. """ + pattern: "FileOperationPattern" + """ The actual file operation pattern. """ + + +class FileRename(TypedDict): + """Represents information on a file/folder rename. + + @since 3.16.0 + """ + + oldUri: str + """ A file:// URI for the original location of the file/folder being renamed. """ + newUri: str + """ A file:// URI for the new location of the file/folder being renamed. """ + + +class FileDelete(TypedDict): + """Represents information on a file/folder delete. + + @since 3.16.0 + """ + + uri: str + """ A file:// URI for the location of the file/folder being deleted. """ + + +class MonikerOptions(TypedDict): + workDoneProgress: NotRequired[bool] + + +class TypeHierarchyOptions(TypedDict): + """Type hierarchy options used during static registration. + + @since 3.17.0 + """ + + workDoneProgress: NotRequired[bool] + + +class InlineValueContext(TypedDict): + """@since 3.17.0""" + + frameId: int + """ The stack frame (as a DAP Id) where the execution has stopped. """ + stoppedLocation: "Range" + """ The document range where execution has stopped. + Typically the end position of the range denotes the line where the inline values are shown. """ + + +class InlineValueText(TypedDict): + """Provide inline value as text. + + @since 3.17.0 + """ + + range: "Range" + """ The document range for which the inline value applies. """ + text: str + """ The text of the inline value. """ + + +class InlineValueVariableLookup(TypedDict): + """Provide inline value through a variable lookup. + If only a range is specified, the variable name will be extracted from the underlying document. + An optional variable name can be used to override the extracted name. + + @since 3.17.0 + """ + + range: "Range" + """ The document range for which the inline value applies. + The range is used to extract the variable name from the underlying document. """ + variableName: NotRequired[str] + """ If specified the name of the variable to look up. """ + caseSensitiveLookup: bool + """ How to perform the lookup. """ + + +class InlineValueEvaluatableExpression(TypedDict): + """Provide an inline value through an expression evaluation. + If only a range is specified, the expression will be extracted from the underlying document. + An optional expression can be used to override the extracted expression. + + @since 3.17.0 + """ + + range: "Range" + """ The document range for which the inline value applies. + The range is used to extract the evaluatable expression from the underlying document. """ + expression: NotRequired[str] + """ If specified the expression overrides the extracted expression. """ + + +class InlineValueOptions(TypedDict): + """Inline value options used during static registration. + + @since 3.17.0 + """ + + workDoneProgress: NotRequired[bool] + + +class InlayHintLabelPart(TypedDict): + """An inlay hint label part allows for interactive and composite labels + of inlay hints. + + @since 3.17.0 + """ + + value: str + """ The value of this label part. """ + tooltip: NotRequired[Union[str, "MarkupContent"]] + """ The tooltip text when you hover over this label part. Depending on + the client capability `inlayHint.resolveSupport` clients might resolve + this property late using the resolve request. """ + location: NotRequired["Location"] + """ An optional source code location that represents this + label part. + + The editor will use this location for the hover and for code navigation + features: This part will become a clickable link that resolves to the + definition of the symbol at the given location (not necessarily the + location itself), it shows the hover that shows at the given location, + and it shows a context menu with further code navigation commands. + + Depending on the client capability `inlayHint.resolveSupport` clients + might resolve this property late using the resolve request. """ + command: NotRequired["Command"] + """ An optional command for this label part. + + Depending on the client capability `inlayHint.resolveSupport` clients + might resolve this property late using the resolve request. """ + + +class MarkupContent(TypedDict): + r"""A `MarkupContent` literal represents a string value which content is interpreted base on its + kind flag. Currently the protocol supports `plaintext` and `markdown` as markup kinds. + + If the kind is `markdown` then the value can contain fenced code blocks like in GitHub issues. + See https://help.github.com/articles/creating-and-highlighting-code-blocks/#syntax-highlighting + + Here is an example how such a string can be constructed using JavaScript / TypeScript: + ```ts + let markdown: MarkdownContent = { + kind: MarkupKind.Markdown, + value: [ + '# Header', + 'Some text', + '```typescript', + 'someCode();', + '```' + ].join('\n') + }; + ``` + + *Please Note* that clients might sanitize the return markdown. A client could decide to + remove HTML from the markdown to avoid script execution. + """ + + kind: "MarkupKind" + """ The type of the Markup """ + value: str + """ The content itself """ + + +class InlayHintOptions(TypedDict): + """Inlay hint options used during static registration. + + @since 3.17.0 + """ + + resolveProvider: NotRequired[bool] + """ The server provides support to resolve additional + information for an inlay hint item. """ + workDoneProgress: NotRequired[bool] + + +class RelatedFullDocumentDiagnosticReport(TypedDict): + """A full diagnostic report with a set of related documents. + + @since 3.17.0 + """ + + relatedDocuments: NotRequired[ + dict[ + "DocumentUri", + Union["FullDocumentDiagnosticReport", "UnchangedDocumentDiagnosticReport"], + ] + ] + """ Diagnostics of related documents. This information is useful + in programming languages where code in a file A can generate + diagnostics in a file B which A depends on. An example of + such a language is C/C++ where marco definitions in a file + a.cpp and result in errors in a header file b.hpp. + + @since 3.17.0 """ + kind: Literal["full"] + """ A full document diagnostic report. """ + resultId: NotRequired[str] + """ An optional result id. If provided it will + be sent on the next diagnostic request for the + same document. """ + items: list["Diagnostic"] + """ The actual items. """ + + +class RelatedUnchangedDocumentDiagnosticReport(TypedDict): + """An unchanged diagnostic report with a set of related documents. + + @since 3.17.0 + """ + + relatedDocuments: NotRequired[ + dict[ + "DocumentUri", + Union["FullDocumentDiagnosticReport", "UnchangedDocumentDiagnosticReport"], + ] + ] + """ Diagnostics of related documents. This information is useful + in programming languages where code in a file A can generate + diagnostics in a file B which A depends on. An example of + such a language is C/C++ where marco definitions in a file + a.cpp and result in errors in a header file b.hpp. + + @since 3.17.0 """ + kind: Literal["unchanged"] + """ A document diagnostic report indicating + no changes to the last result. A server can + only return `unchanged` if result ids are + provided. """ + resultId: str + """ A result id which will be sent on the next + diagnostic request for the same document. """ + + +class FullDocumentDiagnosticReport(TypedDict): + """A diagnostic report with a full set of problems. + + @since 3.17.0 + """ + + kind: Literal["full"] + """ A full document diagnostic report. """ + resultId: NotRequired[str] + """ An optional result id. If provided it will + be sent on the next diagnostic request for the + same document. """ + items: list["Diagnostic"] + """ The actual items. """ + + +class UnchangedDocumentDiagnosticReport(TypedDict): + """A diagnostic report indicating that the last returned + report is still accurate. + + @since 3.17.0 + """ + + kind: Literal["unchanged"] + """ A document diagnostic report indicating + no changes to the last result. A server can + only return `unchanged` if result ids are + provided. """ + resultId: str + """ A result id which will be sent on the next + diagnostic request for the same document. """ + + +class DiagnosticOptions(TypedDict): + """Diagnostic options. + + @since 3.17.0 + """ + + identifier: NotRequired[str] + """ An optional identifier under which the diagnostics are + managed by the client. """ + interFileDependencies: bool + """ Whether the language has inter file dependencies meaning that + editing code in one file can result in a different diagnostic + set in another file. Inter file dependencies are common for + most programming languages and typically uncommon for linters. """ + workspaceDiagnostics: bool + """ The server provides support for workspace diagnostics as well. """ + workDoneProgress: NotRequired[bool] + + +class PreviousResultId(TypedDict): + """A previous result id in a workspace pull request. + + @since 3.17.0 + """ + + uri: "DocumentUri" + """ The URI for which the client knowns a + result id. """ + value: str + """ The value of the previous result id. """ + + +class NotebookDocument(TypedDict): + """A notebook document. + + @since 3.17.0 + """ + + uri: "URI" + """ The notebook document's uri. """ + notebookType: str + """ The type of the notebook. """ + version: int + """ The version number of this document (it will increase after each + change, including undo/redo). """ + metadata: NotRequired["LSPObject"] + """ Additional metadata stored with the notebook + document. + + Note: should always be an object literal (e.g. LSPObject) """ + cells: list["NotebookCell"] + """ The cells of a notebook. """ + + +class TextDocumentItem(TypedDict): + """An item to transfer a text document from the client to the + server. + """ + + uri: "DocumentUri" + """ The text document's uri. """ + languageId: str + """ The text document's language identifier. """ + version: int + """ The version number of this document (it will increase after each + change, including undo/redo). """ + text: str + """ The content of the opened text document. """ + + +class VersionedNotebookDocumentIdentifier(TypedDict): + """A versioned notebook document identifier. + + @since 3.17.0 + """ + + version: int + """ The version number of this notebook document. """ + uri: "URI" + """ The notebook document's uri. """ + + +class NotebookDocumentChangeEvent(TypedDict): + """A change event for a notebook document. + + @since 3.17.0 + """ + + metadata: NotRequired["LSPObject"] + """ The changed meta data if any. + + Note: should always be an object literal (e.g. LSPObject) """ + cells: NotRequired["__NotebookDocumentChangeEvent_cells_Type_1"] + """ Changes to cells """ + + +class NotebookDocumentIdentifier(TypedDict): + """A literal to identify a notebook document in the client. + + @since 3.17.0 + """ + + uri: "URI" + """ The notebook document's uri. """ + + +class Registration(TypedDict): + """General parameters to to register for an notification or to register a provider.""" + + id: str + """ The id used to register the request. The id can be used to deregister + the request again. """ + method: str + """ The method / capability to register for. """ + registerOptions: NotRequired["LSPAny"] + """ Options necessary for the registration. """ + + +class Unregistration(TypedDict): + """General parameters to unregister a request or notification.""" + + id: str + """ The id used to unregister the request or notification. Usually an id + provided during the register request. """ + method: str + """ The method to unregister for. """ + + +class WorkspaceFoldersInitializeParams(TypedDict): + workspaceFolders: NotRequired[list["WorkspaceFolder"] | None] + """ The workspace folders configured in the client when the server starts. + + This property is only available if the client supports workspace folders. + It can be `null` if the client supports workspace folders but none are + configured. + + @since 3.6.0 """ + + +class ServerCapabilities(TypedDict): + """Defines the capabilities provided by a language + server. + """ + + positionEncoding: NotRequired["PositionEncodingKind"] + """ The position encoding the server picked from the encodings offered + by the client via the client capability `general.positionEncodings`. + + If the client didn't provide any position encodings the only valid + value that a server can return is 'utf-16'. + + If omitted it defaults to 'utf-16'. + + @since 3.17.0 """ + textDocumentSync: NotRequired[Union["TextDocumentSyncOptions", "TextDocumentSyncKind"]] + """ Defines how text documents are synced. Is either a detailed structure + defining each notification or for backwards compatibility the + TextDocumentSyncKind number. """ + notebookDocumentSync: NotRequired[Union["NotebookDocumentSyncOptions", "NotebookDocumentSyncRegistrationOptions"]] + """ Defines how notebook documents are synced. + + @since 3.17.0 """ + completionProvider: NotRequired["CompletionOptions"] + """ The server provides completion support. """ + hoverProvider: NotRequired[Union[bool, "HoverOptions"]] + """ The server provides hover support. """ + signatureHelpProvider: NotRequired["SignatureHelpOptions"] + """ The server provides signature help support. """ + declarationProvider: NotRequired[Union[bool, "DeclarationOptions", "DeclarationRegistrationOptions"]] + """ The server provides Goto Declaration support. """ + definitionProvider: NotRequired[Union[bool, "DefinitionOptions"]] + """ The server provides goto definition support. """ + typeDefinitionProvider: NotRequired[Union[bool, "TypeDefinitionOptions", "TypeDefinitionRegistrationOptions"]] + """ The server provides Goto Type Definition support. """ + implementationProvider: NotRequired[Union[bool, "ImplementationOptions", "ImplementationRegistrationOptions"]] + """ The server provides Goto Implementation support. """ + referencesProvider: NotRequired[Union[bool, "ReferenceOptions"]] + """ The server provides find references support. """ + documentHighlightProvider: NotRequired[Union[bool, "DocumentHighlightOptions"]] + """ The server provides document highlight support. """ + documentSymbolProvider: NotRequired[Union[bool, "DocumentSymbolOptions"]] + """ The server provides document symbol support. """ + codeActionProvider: NotRequired[Union[bool, "CodeActionOptions"]] + """ The server provides code actions. CodeActionOptions may only be + specified if the client states that it supports + `codeActionLiteralSupport` in its initial `initialize` request. """ + codeLensProvider: NotRequired["CodeLensOptions"] + """ The server provides code lens. """ + documentLinkProvider: NotRequired["DocumentLinkOptions"] + """ The server provides document link support. """ + colorProvider: NotRequired[Union[bool, "DocumentColorOptions", "DocumentColorRegistrationOptions"]] + """ The server provides color provider support. """ + workspaceSymbolProvider: NotRequired[Union[bool, "WorkspaceSymbolOptions"]] + """ The server provides workspace symbol support. """ + documentFormattingProvider: NotRequired[Union[bool, "DocumentFormattingOptions"]] + """ The server provides document formatting. """ + documentRangeFormattingProvider: NotRequired[Union[bool, "DocumentRangeFormattingOptions"]] + """ The server provides document range formatting. """ + documentOnTypeFormattingProvider: NotRequired["DocumentOnTypeFormattingOptions"] + """ The server provides document formatting on typing. """ + renameProvider: NotRequired[Union[bool, "RenameOptions"]] + """ The server provides rename support. RenameOptions may only be + specified if the client states that it supports + `prepareSupport` in its initial `initialize` request. """ + foldingRangeProvider: NotRequired[Union[bool, "FoldingRangeOptions", "FoldingRangeRegistrationOptions"]] + """ The server provides folding provider support. """ + selectionRangeProvider: NotRequired[Union[bool, "SelectionRangeOptions", "SelectionRangeRegistrationOptions"]] + """ The server provides selection range support. """ + executeCommandProvider: NotRequired["ExecuteCommandOptions"] + """ The server provides execute command support. """ + callHierarchyProvider: NotRequired[Union[bool, "CallHierarchyOptions", "CallHierarchyRegistrationOptions"]] + """ The server provides call hierarchy support. + + @since 3.16.0 """ + linkedEditingRangeProvider: NotRequired[Union[bool, "LinkedEditingRangeOptions", "LinkedEditingRangeRegistrationOptions"]] + """ The server provides linked editing range support. + + @since 3.16.0 """ + semanticTokensProvider: NotRequired[Union["SemanticTokensOptions", "SemanticTokensRegistrationOptions"]] + """ The server provides semantic tokens support. + + @since 3.16.0 """ + monikerProvider: NotRequired[Union[bool, "MonikerOptions", "MonikerRegistrationOptions"]] + """ The server provides moniker support. + + @since 3.16.0 """ + typeHierarchyProvider: NotRequired[Union[bool, "TypeHierarchyOptions", "TypeHierarchyRegistrationOptions"]] + """ The server provides type hierarchy support. + + @since 3.17.0 """ + inlineValueProvider: NotRequired[Union[bool, "InlineValueOptions", "InlineValueRegistrationOptions"]] + """ The server provides inline values. + + @since 3.17.0 """ + inlayHintProvider: NotRequired[Union[bool, "InlayHintOptions", "InlayHintRegistrationOptions"]] + """ The server provides inlay hints. + + @since 3.17.0 """ + diagnosticProvider: NotRequired[Union["DiagnosticOptions", "DiagnosticRegistrationOptions"]] + """ The server has support for pull model diagnostics. + + @since 3.17.0 """ + workspace: NotRequired["__ServerCapabilities_workspace_Type_1"] + """ Workspace specific server capabilities. """ + experimental: NotRequired["LSPAny"] + """ Experimental server capabilities. """ + + +class VersionedTextDocumentIdentifier(TypedDict): + """A text document identifier to denote a specific version of a text document.""" + + version: int + """ The version number of this document. """ + uri: "DocumentUri" + """ The text document's uri. """ + + +class SaveOptions(TypedDict): + """Save options.""" + + includeText: NotRequired[bool] + """ The client is supposed to include the content on save. """ + + +class FileEvent(TypedDict): + """An event describing a file change.""" + + uri: "DocumentUri" + """ The file's uri. """ + type: "FileChangeType" + """ The change type. """ + + +class FileSystemWatcher(TypedDict): + globPattern: "GlobPattern" + """ The glob pattern to watch. See {@link GlobPattern glob pattern} for more detail. + + @since 3.17.0 support for relative patterns. """ + kind: NotRequired["WatchKind"] + """ The kind of events of interest. If omitted it defaults + to WatchKind.Create | WatchKind.Change | WatchKind.Delete + which is 7. """ + + +class Diagnostic(TypedDict): + """Represents a diagnostic, such as a compiler error or warning. Diagnostic objects + are only valid in the scope of a resource. + """ + + range: "Range" + """ The range at which the message applies """ + severity: NotRequired["DiagnosticSeverity"] + """ The diagnostic's severity. Can be omitted. If omitted it is up to the + client to interpret diagnostics as error, warning, info or hint. """ + code: NotRequired[int | str] + """ The diagnostic's code, which usually appear in the user interface. """ + codeDescription: NotRequired["CodeDescription"] + """ An optional property to describe the error code. + Requires the code field (above) to be present/not null. + + @since 3.16.0 """ + source: NotRequired[str] + """ A human-readable string describing the source of this + diagnostic, e.g. 'typescript' or 'super lint'. It usually + appears in the user interface. """ + message: str + """ The diagnostic's message. It usually appears in the user interface """ + tags: NotRequired[list["DiagnosticTag"]] + """ Additional metadata about the diagnostic. + + @since 3.15.0 """ + relatedInformation: NotRequired[list["DiagnosticRelatedInformation"]] + """ An array of related diagnostic information, e.g. when symbol-names within + a scope collide all definitions can be marked via this property. """ + data: NotRequired["LSPAny"] + """ A data entry field that is preserved between a `textDocument/publishDiagnostics` + notification and `textDocument/codeAction` request. + + @since 3.16.0 """ + + +class CompletionContext(TypedDict): + """Contains additional information about the context in which a completion request is triggered.""" + + triggerKind: "CompletionTriggerKind" + """ How the completion was triggered. """ + triggerCharacter: NotRequired[str] + """ The trigger character (a single character) that has trigger code complete. + Is undefined if `triggerKind !== CompletionTriggerKind.TriggerCharacter` """ + + +class CompletionItemLabelDetails(TypedDict): + """Additional details for a completion item label. + + @since 3.17.0 + """ + + detail: NotRequired[str] + """ An optional string which is rendered less prominently directly after {@link CompletionItem.label label}, + without any spacing. Should be used for function signatures and type annotations. """ + description: NotRequired[str] + """ An optional string which is rendered less prominently after {@link CompletionItem.detail}. Should be used + for fully qualified names and file paths. """ + + +class InsertReplaceEdit(TypedDict): + """A special text edit to provide an insert and a replace operation. + + @since 3.16.0 + """ + + newText: str + """ The string to be inserted. """ + insert: "Range" + """ The range if the insert is requested """ + replace: "Range" + """ The range if the replace is requested. """ + + +class CompletionOptions(TypedDict): + """Completion options.""" + + triggerCharacters: NotRequired[list[str]] + """ Most tools trigger completion request automatically without explicitly requesting + it using a keyboard shortcut (e.g. Ctrl+Space). Typically they do so when the user + starts to type an identifier. For example if the user types `c` in a JavaScript file + code complete will automatically pop up present `console` besides others as a + completion item. Characters that make up identifiers don't need to be listed here. + + If code complete should automatically be trigger on characters not being valid inside + an identifier (for example `.` in JavaScript) list them in `triggerCharacters`. """ + allCommitCharacters: NotRequired[list[str]] + """ The list of all possible characters that commit a completion. This field can be used + if clients don't support individual commit characters per completion item. See + `ClientCapabilities.textDocument.completion.completionItem.commitCharactersSupport` + + If a server provides both `allCommitCharacters` and commit characters on an individual + completion item the ones on the completion item win. + + @since 3.2.0 """ + resolveProvider: NotRequired[bool] + """ The server provides support to resolve additional + information for a completion item. """ + completionItem: NotRequired["__CompletionOptions_completionItem_Type_2"] + """ The server supports the following `CompletionItem` specific + capabilities. + + @since 3.17.0 """ + workDoneProgress: NotRequired[bool] + + +class HoverOptions(TypedDict): + """Hover options.""" + + workDoneProgress: NotRequired[bool] + + +class SignatureHelpContext(TypedDict): + """Additional information about the context in which a signature help request was triggered. + + @since 3.15.0 + """ + + triggerKind: "SignatureHelpTriggerKind" + """ Action that caused signature help to be triggered. """ + triggerCharacter: NotRequired[str] + """ Character that caused signature help to be triggered. + + This is undefined when `triggerKind !== SignatureHelpTriggerKind.TriggerCharacter` """ + isRetrigger: bool + """ `true` if signature help was already showing when it was triggered. + + Retriggers occurs when the signature help is already active and can be caused by actions such as + typing a trigger character, a cursor move, or document content changes. """ + activeSignatureHelp: NotRequired["SignatureHelp"] + """ The currently active `SignatureHelp`. + + The `activeSignatureHelp` has its `SignatureHelp.activeSignature` field updated based on + the user navigating through available signatures. """ + + +class SignatureInformation(TypedDict): + """Represents the signature of something callable. A signature + can have a label, like a function-name, a doc-comment, and + a set of parameters. + """ + + label: str + """ The label of this signature. Will be shown in + the UI. """ + documentation: NotRequired[Union[str, "MarkupContent"]] + """ The human-readable doc-comment of this signature. Will be shown + in the UI but can be omitted. """ + parameters: NotRequired[list["ParameterInformation"]] + """ The parameters of this signature. """ + activeParameter: NotRequired[Uint] + """ The index of the active parameter. + + If provided, this is used in place of `SignatureHelp.activeParameter`. + + @since 3.16.0 """ + + +class SignatureHelpOptions(TypedDict): + """Server Capabilities for a {@link SignatureHelpRequest}.""" + + triggerCharacters: NotRequired[list[str]] + """ List of characters that trigger signature help automatically. """ + retriggerCharacters: NotRequired[list[str]] + """ List of characters that re-trigger signature help. + + These trigger characters are only active when signature help is already showing. All trigger characters + are also counted as re-trigger characters. + + @since 3.15.0 """ + workDoneProgress: NotRequired[bool] + + +class DefinitionOptions(TypedDict): + """Server Capabilities for a {@link DefinitionRequest}.""" + + workDoneProgress: NotRequired[bool] + + +class ReferenceContext(TypedDict): + """Value-object that contains additional information when + requesting references. + """ + + includeDeclaration: bool + """ Include the declaration of the current symbol. """ + + +class ReferenceOptions(TypedDict): + """Reference options.""" + + workDoneProgress: NotRequired[bool] + + +class DocumentHighlightOptions(TypedDict): + """Provider options for a {@link DocumentHighlightRequest}.""" + + workDoneProgress: NotRequired[bool] + + +class BaseSymbolInformation(TypedDict): + """A base for all symbol information.""" + + name: str + """ The name of this symbol. """ + kind: "SymbolKind" + """ The kind of this symbol. """ + tags: NotRequired[list["SymbolTag"]] + """ Tags for this symbol. + + @since 3.16.0 """ + containerName: NotRequired[str] + """ The name of the symbol containing this symbol. This information is for + user interface purposes (e.g. to render a qualifier in the user interface + if necessary). It can't be used to re-infer a hierarchy for the document + symbols. """ + + +class DocumentSymbolOptions(TypedDict): + """Provider options for a {@link DocumentSymbolRequest}.""" + + label: NotRequired[str] + """ A human-readable string that is shown when multiple outlines trees + are shown for the same document. + + @since 3.16.0 """ + workDoneProgress: NotRequired[bool] + + +class CodeActionContext(TypedDict): + """Contains additional diagnostic information about the context in which + a {@link CodeActionProvider.provideCodeActions code action} is run. + """ + + diagnostics: list["Diagnostic"] + """ An array of diagnostics known on the client side overlapping the range provided to the + `textDocument/codeAction` request. They are provided so that the server knows which + errors are currently presented to the user for the given range. There is no guarantee + that these accurately reflect the error state of the resource. The primary parameter + to compute code actions is the provided range. """ + only: NotRequired[list["CodeActionKind"]] + """ Requested kind of actions to return. + + Actions not of this kind are filtered out by the client before being shown. So servers + can omit computing them. """ + triggerKind: NotRequired["CodeActionTriggerKind"] + """ The reason why code actions were requested. + + @since 3.17.0 """ + + +class CodeActionOptions(TypedDict): + """Provider options for a {@link CodeActionRequest}.""" + + codeActionKinds: NotRequired[list["CodeActionKind"]] + """ CodeActionKinds that this server may return. + + The list of kinds may be generic, such as `CodeActionKind.Refactor`, or the server + may list out every specific kind they provide. """ + resolveProvider: NotRequired[bool] + """ The server provides support to resolve additional + information for a code action. + + @since 3.16.0 """ + workDoneProgress: NotRequired[bool] + + +class WorkspaceSymbolOptions(TypedDict): + """Server capabilities for a {@link WorkspaceSymbolRequest}.""" + + resolveProvider: NotRequired[bool] + """ The server provides support to resolve additional + information for a workspace symbol. + + @since 3.17.0 """ + workDoneProgress: NotRequired[bool] + + +class CodeLensOptions(TypedDict): + """Code Lens provider options of a {@link CodeLensRequest}.""" + + resolveProvider: NotRequired[bool] + """ Code lens has a resolve provider as well. """ + workDoneProgress: NotRequired[bool] + + +class DocumentLinkOptions(TypedDict): + """Provider options for a {@link DocumentLinkRequest}.""" + + resolveProvider: NotRequired[bool] + """ Document links have a resolve provider as well. """ + workDoneProgress: NotRequired[bool] + + +class FormattingOptions(TypedDict): + """Value-object describing what options formatting should use.""" + + tabSize: Uint + """ Size of a tab in spaces. """ + insertSpaces: bool + """ Prefer spaces over tabs. """ + trimTrailingWhitespace: NotRequired[bool] + """ Trim trailing whitespace on a line. + + @since 3.15.0 """ + insertFinalNewline: NotRequired[bool] + """ Insert a newline character at the end of the file if one does not exist. + + @since 3.15.0 """ + trimFinalNewlines: NotRequired[bool] + """ Trim all newlines after the final newline at the end of the file. + + @since 3.15.0 """ + + +class DocumentFormattingOptions(TypedDict): + """Provider options for a {@link DocumentFormattingRequest}.""" + + workDoneProgress: NotRequired[bool] + + +class DocumentRangeFormattingOptions(TypedDict): + """Provider options for a {@link DocumentRangeFormattingRequest}.""" + + workDoneProgress: NotRequired[bool] + + +class DocumentOnTypeFormattingOptions(TypedDict): + """Provider options for a {@link DocumentOnTypeFormattingRequest}.""" + + firstTriggerCharacter: str + """ A character on which formatting should be triggered, like `{`. """ + moreTriggerCharacter: NotRequired[list[str]] + """ More trigger characters. """ + + +class RenameOptions(TypedDict): + """Provider options for a {@link RenameRequest}.""" + + prepareProvider: NotRequired[bool] + """ Renames should be checked and tested before being executed. + + @since version 3.12.0 """ + workDoneProgress: NotRequired[bool] + + +class ExecuteCommandOptions(TypedDict): + """The server capabilities of a {@link ExecuteCommandRequest}.""" + + commands: list[str] + """ The commands to be executed on the server """ + workDoneProgress: NotRequired[bool] + + +class SemanticTokensLegend(TypedDict): + """@since 3.16.0""" + + tokenTypes: list[str] + """ The token types a server uses. """ + tokenModifiers: list[str] + """ The token modifiers a server uses. """ + + +class OptionalVersionedTextDocumentIdentifier(TypedDict): + """A text document identifier to optionally denote a specific version of a text document.""" + + version: int | None + """ The version number of this document. If a versioned text document identifier + is sent from the server to the client and the file is not open in the editor + (the server has not received an open notification before) the server can send + `null` to indicate that the version is unknown and the content on disk is the + truth (as specified with document content ownership). """ + uri: "DocumentUri" + """ The text document's uri. """ + + +class AnnotatedTextEdit(TypedDict): + """A special text edit with an additional change annotation. + + @since 3.16.0. + """ + + annotationId: "ChangeAnnotationIdentifier" + """ The actual identifier of the change annotation """ + range: "Range" + """ The range of the text document to be manipulated. To insert + text into a document create a range where start === end. """ + newText: str + """ The string to be inserted. For delete operations use an + empty string. """ + + +class ResourceOperation(TypedDict): + """A generic resource operation.""" + + kind: str + """ The resource operation kind. """ + annotationId: NotRequired["ChangeAnnotationIdentifier"] + """ An optional annotation identifier describing the operation. + + @since 3.16.0 """ + + +class CreateFileOptions(TypedDict): + """Options to create a file.""" + + overwrite: NotRequired[bool] + """ Overwrite existing file. Overwrite wins over `ignoreIfExists` """ + ignoreIfExists: NotRequired[bool] + """ Ignore if exists. """ + + +class RenameFileOptions(TypedDict): + """Rename file options""" + + overwrite: NotRequired[bool] + """ Overwrite target if existing. Overwrite wins over `ignoreIfExists` """ + ignoreIfExists: NotRequired[bool] + """ Ignores if target exists. """ + + +class DeleteFileOptions(TypedDict): + """Delete file options""" + + recursive: NotRequired[bool] + """ Delete the content recursively if a folder is denoted. """ + ignoreIfNotExists: NotRequired[bool] + """ Ignore the operation if the file doesn't exist. """ + + +class FileOperationPattern(TypedDict): + """A pattern to describe in which file operation requests or notifications + the server is interested in receiving. + + @since 3.16.0 + """ + + glob: str + """ The glob pattern to match. Glob patterns can have the following syntax: + - `*` to match one or more characters in a path segment + - `?` to match on one character in a path segment + - `**` to match any number of path segments, including none + - `{}` to group sub patterns into an OR expression. (e.g. `**\u200b/*.{ts,js}` matches all TypeScript and JavaScript files) + - `[]` to declare a range of characters to match in a path segment (e.g., `example.[0-9]` to match on `example.0`, `example.1`, …) + - `[!...]` to negate a range of characters to match in a path segment (e.g., `example.[!0-9]` to match on `example.a`, `example.b`, but not `example.0`) """ + matches: NotRequired["FileOperationPatternKind"] + """ Whether to match files or folders with this pattern. + + Matches both if undefined. """ + options: NotRequired["FileOperationPatternOptions"] + """ Additional options used during matching. """ + + +class WorkspaceFullDocumentDiagnosticReport(TypedDict): + """A full document diagnostic report for a workspace diagnostic result. + + @since 3.17.0 + """ + + uri: "DocumentUri" + """ The URI for which diagnostic information is reported. """ + version: int | None + """ The version number for which the diagnostics are reported. + If the document is not marked as open `null` can be provided. """ + kind: Literal["full"] + """ A full document diagnostic report. """ + resultId: NotRequired[str] + """ An optional result id. If provided it will + be sent on the next diagnostic request for the + same document. """ + items: list["Diagnostic"] + """ The actual items. """ + + +class WorkspaceUnchangedDocumentDiagnosticReport(TypedDict): + """An unchanged document diagnostic report for a workspace diagnostic result. + + @since 3.17.0 + """ + + uri: "DocumentUri" + """ The URI for which diagnostic information is reported. """ + version: int | None + """ The version number for which the diagnostics are reported. + If the document is not marked as open `null` can be provided. """ + kind: Literal["unchanged"] + """ A document diagnostic report indicating + no changes to the last result. A server can + only return `unchanged` if result ids are + provided. """ + resultId: str + """ A result id which will be sent on the next + diagnostic request for the same document. """ + + +class NotebookCell(TypedDict): + """A notebook cell. + + A cell's document URI must be unique across ALL notebook + cells and can therefore be used to uniquely identify a + notebook cell or the cell's text document. + + @since 3.17.0 + """ + + kind: "NotebookCellKind" + """ The cell's kind """ + document: "DocumentUri" + """ The URI of the cell's text document + content. """ + metadata: NotRequired["LSPObject"] + """ Additional metadata stored with the cell. + + Note: should always be an object literal (e.g. LSPObject) """ + executionSummary: NotRequired["ExecutionSummary"] + """ Additional execution summary information + if supported by the client. """ + + +class NotebookCellArrayChange(TypedDict): + """A change describing how to move a `NotebookCell` + array from state S to S'. + + @since 3.17.0 + """ + + start: Uint + """ The start oftest of the cell that changed. """ + deleteCount: Uint + """ The deleted cells """ + cells: NotRequired[list["NotebookCell"]] + """ The new cells, if any """ + + +class ClientCapabilities(TypedDict): + """Defines the capabilities provided by the client.""" + + workspace: NotRequired["WorkspaceClientCapabilities"] + """ Workspace specific client capabilities. """ + textDocument: NotRequired["TextDocumentClientCapabilities"] + """ Text document specific client capabilities. """ + notebookDocument: NotRequired["NotebookDocumentClientCapabilities"] + """ Capabilities specific to the notebook document support. + + @since 3.17.0 """ + window: NotRequired["WindowClientCapabilities"] + """ Window specific client capabilities. """ + general: NotRequired["GeneralClientCapabilities"] + """ General client capabilities. + + @since 3.16.0 """ + experimental: NotRequired["LSPAny"] + """ Experimental client capabilities. """ + + +class TextDocumentSyncOptions(TypedDict): + openClose: NotRequired[bool] + """ Open and close notifications are sent to the server. If omitted open close notification should not + be sent. """ + change: NotRequired["TextDocumentSyncKind"] + """ Change notifications are sent to the server. See TextDocumentSyncKind.None, TextDocumentSyncKind.Full + and TextDocumentSyncKind.Incremental. If omitted it defaults to TextDocumentSyncKind.None. """ + willSave: NotRequired[bool] + """ If present will save notifications are sent to the server. If omitted the notification should not be + sent. """ + willSaveWaitUntil: NotRequired[bool] + """ If present will save wait until requests are sent to the server. If omitted the request should not be + sent. """ + save: NotRequired[Union[bool, "SaveOptions"]] + """ If present save notifications are sent to the server. If omitted the notification should not be + sent. """ + + +class NotebookDocumentSyncOptions(TypedDict): + """Options specific to a notebook plus its cells + to be synced to the server. + + If a selector provides a notebook document + filter but no cell selector all cells of a + matching notebook document will be synced. + + If a selector provides no notebook document + filter but only a cell selector all notebook + document that contain at least one matching + cell will be synced. + + @since 3.17.0 + """ + + notebookSelector: list[ + Union[ + "__NotebookDocumentSyncOptions_notebookSelector_Type_1", + "__NotebookDocumentSyncOptions_notebookSelector_Type_2", + ] + ] + """ The notebooks to be synced """ + save: NotRequired[bool] + """ Whether save notification should be forwarded to + the server. Will only be honored if mode === `notebook`. """ + + +class NotebookDocumentSyncRegistrationOptions(TypedDict): + """Registration options specific to a notebook. + + @since 3.17.0 + """ + + notebookSelector: list[ + Union[ + "__NotebookDocumentSyncOptions_notebookSelector_Type_3", + "__NotebookDocumentSyncOptions_notebookSelector_Type_4", + ] + ] + """ The notebooks to be synced """ + save: NotRequired[bool] + """ Whether save notification should be forwarded to + the server. Will only be honored if mode === `notebook`. """ + id: NotRequired[str] + """ The id used to register the request. The id can be used to deregister + the request again. See also Registration#id. """ + + +class WorkspaceFoldersServerCapabilities(TypedDict): + supported: NotRequired[bool] + """ The server has support for workspace folders """ + changeNotifications: NotRequired[str | bool] + """ Whether the server wants to receive workspace folder + change notifications. + + If a string is provided the string is treated as an ID + under which the notification is registered on the client + side. The ID can be used to unregister for these events + using the `client/unregisterCapability` request. """ + + +class FileOperationOptions(TypedDict): + """Options for notifications/requests for user operations on files. + + @since 3.16.0 + """ + + didCreate: NotRequired["FileOperationRegistrationOptions"] + """ The server is interested in receiving didCreateFiles notifications. """ + willCreate: NotRequired["FileOperationRegistrationOptions"] + """ The server is interested in receiving willCreateFiles requests. """ + didRename: NotRequired["FileOperationRegistrationOptions"] + """ The server is interested in receiving didRenameFiles notifications. """ + willRename: NotRequired["FileOperationRegistrationOptions"] + """ The server is interested in receiving willRenameFiles requests. """ + didDelete: NotRequired["FileOperationRegistrationOptions"] + """ The server is interested in receiving didDeleteFiles file notifications. """ + willDelete: NotRequired["FileOperationRegistrationOptions"] + """ The server is interested in receiving willDeleteFiles file requests. """ + + +class CodeDescription(TypedDict): + """Structure to capture a description for an error code. + + @since 3.16.0 + """ + + href: "URI" + """ An URI to open with more information about the diagnostic error. """ + + +class DiagnosticRelatedInformation(TypedDict): + """Represents a related message and source code location for a diagnostic. This should be + used to point to code locations that cause or related to a diagnostics, e.g when duplicating + a symbol in a scope. + """ + + location: "Location" + """ The location of this related diagnostic information. """ + message: str + """ The message of this related diagnostic information. """ + + +class ParameterInformation(TypedDict): + """Represents a parameter of a callable-signature. A parameter can + have a label and a doc-comment. + """ + + label: str | list[Uint | Uint] + """ The label of this parameter information. + + Either a string or an inclusive start and exclusive end offsets within its containing + signature label. (see SignatureInformation.label). The offsets are based on a UTF-16 + string representation as `Position` and `Range` does. + + *Note*: a label of type string should be a substring of its containing signature label. + Its intended use case is to highlight the parameter label part in the `SignatureInformation.label`. """ + documentation: NotRequired[Union[str, "MarkupContent"]] + """ The human-readable doc-comment of this parameter. Will be shown + in the UI but can be omitted. """ + + +class NotebookCellTextDocumentFilter(TypedDict): + """A notebook cell text document filter denotes a cell text + document by different properties. + + @since 3.17.0 + """ + + notebook: Union[str, "NotebookDocumentFilter"] + """ A filter that matches against the notebook + containing the notebook cell. If a string + value is provided it matches against the + notebook type. '*' matches every notebook. """ + language: NotRequired[str] + """ A language id like `python`. + + Will be matched against the language id of the + notebook cell document. '*' matches every language. """ + + +class FileOperationPatternOptions(TypedDict): + """Matching options for the file operation pattern. + + @since 3.16.0 + """ + + ignoreCase: NotRequired[bool] + """ The pattern should be matched ignoring casing. """ + + +class ExecutionSummary(TypedDict): + executionOrder: Uint + """ A strict monotonically increasing value + indicating the execution order of a cell + inside a notebook. """ + success: NotRequired[bool] + """ Whether the execution was successful or + not if known by the client. """ + + +class WorkspaceClientCapabilities(TypedDict): + """Workspace specific client capabilities.""" + + applyEdit: NotRequired[bool] + """ The client supports applying batch edits + to the workspace by supporting the request + 'workspace/applyEdit' """ + workspaceEdit: NotRequired["WorkspaceEditClientCapabilities"] + """ Capabilities specific to `WorkspaceEdit`s. """ + didChangeConfiguration: NotRequired["DidChangeConfigurationClientCapabilities"] + """ Capabilities specific to the `workspace/didChangeConfiguration` notification. """ + didChangeWatchedFiles: NotRequired["DidChangeWatchedFilesClientCapabilities"] + """ Capabilities specific to the `workspace/didChangeWatchedFiles` notification. """ + symbol: NotRequired["WorkspaceSymbolClientCapabilities"] + """ Capabilities specific to the `workspace/symbol` request. """ + executeCommand: NotRequired["ExecuteCommandClientCapabilities"] + """ Capabilities specific to the `workspace/executeCommand` request. """ + workspaceFolders: NotRequired[bool] + """ The client has support for workspace folders. + + @since 3.6.0 """ + configuration: NotRequired[bool] + """ The client supports `workspace/configuration` requests. + + @since 3.6.0 """ + semanticTokens: NotRequired["SemanticTokensWorkspaceClientCapabilities"] + """ Capabilities specific to the semantic token requests scoped to the + workspace. + + @since 3.16.0. """ + codeLens: NotRequired["CodeLensWorkspaceClientCapabilities"] + """ Capabilities specific to the code lens requests scoped to the + workspace. + + @since 3.16.0. """ + fileOperations: NotRequired["FileOperationClientCapabilities"] + """ The client has support for file notifications/requests for user operations on files. + + Since 3.16.0 """ + inlineValue: NotRequired["InlineValueWorkspaceClientCapabilities"] + """ Capabilities specific to the inline values requests scoped to the + workspace. + + @since 3.17.0. """ + inlayHint: NotRequired["InlayHintWorkspaceClientCapabilities"] + """ Capabilities specific to the inlay hint requests scoped to the + workspace. + + @since 3.17.0. """ + diagnostics: NotRequired["DiagnosticWorkspaceClientCapabilities"] + """ Capabilities specific to the diagnostic requests scoped to the + workspace. + + @since 3.17.0. """ + + +class TextDocumentClientCapabilities(TypedDict): + """Text document specific client capabilities.""" + + synchronization: NotRequired["TextDocumentSyncClientCapabilities"] + """ Defines which synchronization capabilities the client supports. """ + completion: NotRequired["CompletionClientCapabilities"] + """ Capabilities specific to the `textDocument/completion` request. """ + hover: NotRequired["HoverClientCapabilities"] + """ Capabilities specific to the `textDocument/hover` request. """ + signatureHelp: NotRequired["SignatureHelpClientCapabilities"] + """ Capabilities specific to the `textDocument/signatureHelp` request. """ + declaration: NotRequired["DeclarationClientCapabilities"] + """ Capabilities specific to the `textDocument/declaration` request. + + @since 3.14.0 """ + definition: NotRequired["DefinitionClientCapabilities"] + """ Capabilities specific to the `textDocument/definition` request. """ + typeDefinition: NotRequired["TypeDefinitionClientCapabilities"] + """ Capabilities specific to the `textDocument/typeDefinition` request. + + @since 3.6.0 """ + implementation: NotRequired["ImplementationClientCapabilities"] + """ Capabilities specific to the `textDocument/implementation` request. + + @since 3.6.0 """ + references: NotRequired["ReferenceClientCapabilities"] + """ Capabilities specific to the `textDocument/references` request. """ + documentHighlight: NotRequired["DocumentHighlightClientCapabilities"] + """ Capabilities specific to the `textDocument/documentHighlight` request. """ + documentSymbol: NotRequired["DocumentSymbolClientCapabilities"] + """ Capabilities specific to the `textDocument/documentSymbol` request. """ + codeAction: NotRequired["CodeActionClientCapabilities"] + """ Capabilities specific to the `textDocument/codeAction` request. """ + codeLens: NotRequired["CodeLensClientCapabilities"] + """ Capabilities specific to the `textDocument/codeLens` request. """ + documentLink: NotRequired["DocumentLinkClientCapabilities"] + """ Capabilities specific to the `textDocument/documentLink` request. """ + colorProvider: NotRequired["DocumentColorClientCapabilities"] + """ Capabilities specific to the `textDocument/documentColor` and the + `textDocument/colorPresentation` request. + + @since 3.6.0 """ + formatting: NotRequired["DocumentFormattingClientCapabilities"] + """ Capabilities specific to the `textDocument/formatting` request. """ + rangeFormatting: NotRequired["DocumentRangeFormattingClientCapabilities"] + """ Capabilities specific to the `textDocument/rangeFormatting` request. """ + onTypeFormatting: NotRequired["DocumentOnTypeFormattingClientCapabilities"] + """ Capabilities specific to the `textDocument/onTypeFormatting` request. """ + rename: NotRequired["RenameClientCapabilities"] + """ Capabilities specific to the `textDocument/rename` request. """ + foldingRange: NotRequired["FoldingRangeClientCapabilities"] + """ Capabilities specific to the `textDocument/foldingRange` request. + + @since 3.10.0 """ + selectionRange: NotRequired["SelectionRangeClientCapabilities"] + """ Capabilities specific to the `textDocument/selectionRange` request. + + @since 3.15.0 """ + publishDiagnostics: NotRequired["PublishDiagnosticsClientCapabilities"] + """ Capabilities specific to the `textDocument/publishDiagnostics` notification. """ + callHierarchy: NotRequired["CallHierarchyClientCapabilities"] + """ Capabilities specific to the various call hierarchy requests. + + @since 3.16.0 """ + semanticTokens: NotRequired["SemanticTokensClientCapabilities"] + """ Capabilities specific to the various semantic token request. + + @since 3.16.0 """ + linkedEditingRange: NotRequired["LinkedEditingRangeClientCapabilities"] + """ Capabilities specific to the `textDocument/linkedEditingRange` request. + + @since 3.16.0 """ + moniker: NotRequired["MonikerClientCapabilities"] + """ Client capabilities specific to the `textDocument/moniker` request. + + @since 3.16.0 """ + typeHierarchy: NotRequired["TypeHierarchyClientCapabilities"] + """ Capabilities specific to the various type hierarchy requests. + + @since 3.17.0 """ + inlineValue: NotRequired["InlineValueClientCapabilities"] + """ Capabilities specific to the `textDocument/inlineValue` request. + + @since 3.17.0 """ + inlayHint: NotRequired["InlayHintClientCapabilities"] + """ Capabilities specific to the `textDocument/inlayHint` request. + + @since 3.17.0 """ + diagnostic: NotRequired["DiagnosticClientCapabilities"] + """ Capabilities specific to the diagnostic pull model. + + @since 3.17.0 """ + + +class NotebookDocumentClientCapabilities(TypedDict): + """Capabilities specific to the notebook document support. + + @since 3.17.0 + """ + + synchronization: "NotebookDocumentSyncClientCapabilities" + """ Capabilities specific to notebook document synchronization + + @since 3.17.0 """ + + +class WindowClientCapabilities(TypedDict): + workDoneProgress: NotRequired[bool] + """ It indicates whether the client supports server initiated + progress using the `window/workDoneProgress/create` request. + + The capability also controls Whether client supports handling + of progress notifications. If set servers are allowed to report a + `workDoneProgress` property in the request specific server + capabilities. + + @since 3.15.0 """ + showMessage: NotRequired["ShowMessageRequestClientCapabilities"] + """ Capabilities specific to the showMessage request. + + @since 3.16.0 """ + showDocument: NotRequired["ShowDocumentClientCapabilities"] + """ Capabilities specific to the showDocument request. + + @since 3.16.0 """ + + +class GeneralClientCapabilities(TypedDict): + """General client capabilities. + + @since 3.16.0 + """ + + staleRequestSupport: NotRequired["__GeneralClientCapabilities_staleRequestSupport_Type_1"] + """ Client capability that signals how the client + handles stale requests (e.g. a request + for which the client will not process the response + anymore since the information is outdated). + + @since 3.17.0 """ + regularExpressions: NotRequired["RegularExpressionsClientCapabilities"] + """ Client capabilities specific to regular expressions. + + @since 3.16.0 """ + markdown: NotRequired["MarkdownClientCapabilities"] + """ Client capabilities specific to the client's markdown parser. + + @since 3.16.0 """ + positionEncodings: NotRequired[list["PositionEncodingKind"]] + """ The position encodings supported by the client. Client and server + have to agree on the same position encoding to ensure that offsets + (e.g. character position in a line) are interpreted the same on both + sides. + + To keep the protocol backwards compatible the following applies: if + the value 'utf-16' is missing from the array of position encodings + servers can assume that the client supports UTF-16. UTF-16 is + therefore a mandatory encoding. + + If omitted it defaults to ['utf-16']. + + Implementation considerations: since the conversion from one encoding + into another requires the content of the file / line the conversion + is best done where the file is read which is usually on the server + side. + + @since 3.17.0 """ + + +class RelativePattern(TypedDict): + """A relative pattern is a helper to construct glob patterns that are matched + relatively to a base URI. The common value for a `baseUri` is a workspace + folder root, but it can be another absolute URI as well. + + @since 3.17.0 + """ + + baseUri: Union["WorkspaceFolder", "URI"] + """ A workspace folder or a base URI to which this pattern will be matched + against relatively. """ + pattern: "Pattern" + """ The actual glob pattern; """ + + +class WorkspaceEditClientCapabilities(TypedDict): + documentChanges: NotRequired[bool] + """ The client supports versioned document changes in `WorkspaceEdit`s """ + resourceOperations: NotRequired[list["ResourceOperationKind"]] + """ The resource operations the client supports. Clients should at least + support 'create', 'rename' and 'delete' files and folders. + + @since 3.13.0 """ + failureHandling: NotRequired["FailureHandlingKind"] + """ The failure handling strategy of a client if applying the workspace edit + fails. + + @since 3.13.0 """ + normalizesLineEndings: NotRequired[bool] + """ Whether the client normalizes line endings to the client specific + setting. + If set to `true` the client will normalize line ending characters + in a workspace edit to the client-specified new line + character. + + @since 3.16.0 """ + changeAnnotationSupport: NotRequired["__WorkspaceEditClientCapabilities_changeAnnotationSupport_Type_1"] + """ Whether the client in general supports change annotations on text edits, + create file, rename file and delete file changes. + + @since 3.16.0 """ + + +class DidChangeConfigurationClientCapabilities(TypedDict): + dynamicRegistration: NotRequired[bool] + """ Did change configuration notification supports dynamic registration. """ + + +class DidChangeWatchedFilesClientCapabilities(TypedDict): + dynamicRegistration: NotRequired[bool] + """ Did change watched files notification supports dynamic registration. Please note + that the current protocol doesn't support static configuration for file changes + from the server side. """ + relativePatternSupport: NotRequired[bool] + """ Whether the client has support for {@link RelativePattern relative pattern} + or not. + + @since 3.17.0 """ + + +class WorkspaceSymbolClientCapabilities(TypedDict): + """Client capabilities for a {@link WorkspaceSymbolRequest}.""" + + dynamicRegistration: NotRequired[bool] + """ Symbol request supports dynamic registration. """ + symbolKind: NotRequired["__WorkspaceSymbolClientCapabilities_symbolKind_Type_1"] + """ Specific capabilities for the `SymbolKind` in the `workspace/symbol` request. """ + tagSupport: NotRequired["__WorkspaceSymbolClientCapabilities_tagSupport_Type_1"] + """ The client supports tags on `SymbolInformation`. + Clients supporting tags have to handle unknown tags gracefully. + + @since 3.16.0 """ + resolveSupport: NotRequired["__WorkspaceSymbolClientCapabilities_resolveSupport_Type_1"] + """ The client support partial workspace symbols. The client will send the + request `workspaceSymbol/resolve` to the server to resolve additional + properties. + + @since 3.17.0 """ + + +class ExecuteCommandClientCapabilities(TypedDict): + """The client capabilities of a {@link ExecuteCommandRequest}.""" + + dynamicRegistration: NotRequired[bool] + """ Execute command supports dynamic registration. """ + + +class SemanticTokensWorkspaceClientCapabilities(TypedDict): + """@since 3.16.0""" + + refreshSupport: NotRequired[bool] + """ Whether the client implementation supports a refresh request sent from + the server to the client. + + Note that this event is global and will force the client to refresh all + semantic tokens currently shown. It should be used with absolute care + and is useful for situation where a server for example detects a project + wide change that requires such a calculation. """ + + +class CodeLensWorkspaceClientCapabilities(TypedDict): + """@since 3.16.0""" + + refreshSupport: NotRequired[bool] + """ Whether the client implementation supports a refresh request sent from the + server to the client. + + Note that this event is global and will force the client to refresh all + code lenses currently shown. It should be used with absolute care and is + useful for situation where a server for example detect a project wide + change that requires such a calculation. """ + + +class FileOperationClientCapabilities(TypedDict): + """Capabilities relating to events from file operations by the user in the client. + + These events do not come from the file system, they come from user operations + like renaming a file in the UI. + + @since 3.16.0 + """ + + dynamicRegistration: NotRequired[bool] + """ Whether the client supports dynamic registration for file requests/notifications. """ + didCreate: NotRequired[bool] + """ The client has support for sending didCreateFiles notifications. """ + willCreate: NotRequired[bool] + """ The client has support for sending willCreateFiles requests. """ + didRename: NotRequired[bool] + """ The client has support for sending didRenameFiles notifications. """ + willRename: NotRequired[bool] + """ The client has support for sending willRenameFiles requests. """ + didDelete: NotRequired[bool] + """ The client has support for sending didDeleteFiles notifications. """ + willDelete: NotRequired[bool] + """ The client has support for sending willDeleteFiles requests. """ + + +class InlineValueWorkspaceClientCapabilities(TypedDict): + """Client workspace capabilities specific to inline values. + + @since 3.17.0 + """ + + refreshSupport: NotRequired[bool] + """ Whether the client implementation supports a refresh request sent from the + server to the client. + + Note that this event is global and will force the client to refresh all + inline values currently shown. It should be used with absolute care and is + useful for situation where a server for example detects a project wide + change that requires such a calculation. """ + + +class InlayHintWorkspaceClientCapabilities(TypedDict): + """Client workspace capabilities specific to inlay hints. + + @since 3.17.0 + """ + + refreshSupport: NotRequired[bool] + """ Whether the client implementation supports a refresh request sent from + the server to the client. + + Note that this event is global and will force the client to refresh all + inlay hints currently shown. It should be used with absolute care and + is useful for situation where a server for example detects a project wide + change that requires such a calculation. """ + + +class DiagnosticWorkspaceClientCapabilities(TypedDict): + """Workspace client capabilities specific to diagnostic pull requests. + + @since 3.17.0 + """ + + refreshSupport: NotRequired[bool] + """ Whether the client implementation supports a refresh request sent from + the server to the client. + + Note that this event is global and will force the client to refresh all + pulled diagnostics currently shown. It should be used with absolute care and + is useful for situation where a server for example detects a project wide + change that requires such a calculation. """ + + +class TextDocumentSyncClientCapabilities(TypedDict): + dynamicRegistration: NotRequired[bool] + """ Whether text document synchronization supports dynamic registration. """ + willSave: NotRequired[bool] + """ The client supports sending will save notifications. """ + willSaveWaitUntil: NotRequired[bool] + """ The client supports sending a will save request and + waits for a response providing text edits which will + be applied to the document before it is saved. """ + didSave: NotRequired[bool] + """ The client supports did save notifications. """ + + +class CompletionClientCapabilities(TypedDict): + """Completion client capabilities""" + + dynamicRegistration: NotRequired[bool] + """ Whether completion supports dynamic registration. """ + completionItem: NotRequired["__CompletionClientCapabilities_completionItem_Type_1"] + """ The client supports the following `CompletionItem` specific + capabilities. """ + completionItemKind: NotRequired["__CompletionClientCapabilities_completionItemKind_Type_1"] + insertTextMode: NotRequired["InsertTextMode"] + """ Defines how the client handles whitespace and indentation + when accepting a completion item that uses multi line + text in either `insertText` or `textEdit`. + + @since 3.17.0 """ + contextSupport: NotRequired[bool] + """ The client supports to send additional context information for a + `textDocument/completion` request. """ + completionList: NotRequired["__CompletionClientCapabilities_completionList_Type_1"] + """ The client supports the following `CompletionList` specific + capabilities. + + @since 3.17.0 """ + + +class HoverClientCapabilities(TypedDict): + dynamicRegistration: NotRequired[bool] + """ Whether hover supports dynamic registration. """ + contentFormat: NotRequired[list["MarkupKind"]] + """ Client supports the following content formats for the content + property. The order describes the preferred format of the client. """ + + +class SignatureHelpClientCapabilities(TypedDict): + """Client Capabilities for a {@link SignatureHelpRequest}.""" + + dynamicRegistration: NotRequired[bool] + """ Whether signature help supports dynamic registration. """ + signatureInformation: NotRequired["__SignatureHelpClientCapabilities_signatureInformation_Type_1"] + """ The client supports the following `SignatureInformation` + specific properties. """ + contextSupport: NotRequired[bool] + """ The client supports to send additional context information for a + `textDocument/signatureHelp` request. A client that opts into + contextSupport will also support the `retriggerCharacters` on + `SignatureHelpOptions`. + + @since 3.15.0 """ + + +class DeclarationClientCapabilities(TypedDict): + """@since 3.14.0""" + + dynamicRegistration: NotRequired[bool] + """ Whether declaration supports dynamic registration. If this is set to `true` + the client supports the new `DeclarationRegistrationOptions` return value + for the corresponding server capability as well. """ + linkSupport: NotRequired[bool] + """ The client supports additional metadata in the form of declaration links. """ + + +class DefinitionClientCapabilities(TypedDict): + """Client Capabilities for a {@link DefinitionRequest}.""" + + dynamicRegistration: NotRequired[bool] + """ Whether definition supports dynamic registration. """ + linkSupport: NotRequired[bool] + """ The client supports additional metadata in the form of definition links. + + @since 3.14.0 """ + + +class TypeDefinitionClientCapabilities(TypedDict): + """Since 3.6.0""" + + dynamicRegistration: NotRequired[bool] + """ Whether implementation supports dynamic registration. If this is set to `true` + the client supports the new `TypeDefinitionRegistrationOptions` return value + for the corresponding server capability as well. """ + linkSupport: NotRequired[bool] + """ The client supports additional metadata in the form of definition links. + + Since 3.14.0 """ + + +class ImplementationClientCapabilities(TypedDict): + """@since 3.6.0""" + + dynamicRegistration: NotRequired[bool] + """ Whether implementation supports dynamic registration. If this is set to `true` + the client supports the new `ImplementationRegistrationOptions` return value + for the corresponding server capability as well. """ + linkSupport: NotRequired[bool] + """ The client supports additional metadata in the form of definition links. + + @since 3.14.0 """ + + +class ReferenceClientCapabilities(TypedDict): + """Client Capabilities for a {@link ReferencesRequest}.""" + + dynamicRegistration: NotRequired[bool] + """ Whether references supports dynamic registration. """ + + +class DocumentHighlightClientCapabilities(TypedDict): + """Client Capabilities for a {@link DocumentHighlightRequest}.""" + + dynamicRegistration: NotRequired[bool] + """ Whether document highlight supports dynamic registration. """ + + +class DocumentSymbolClientCapabilities(TypedDict): + """Client Capabilities for a {@link DocumentSymbolRequest}.""" + + dynamicRegistration: NotRequired[bool] + """ Whether document symbol supports dynamic registration. """ + symbolKind: NotRequired["__DocumentSymbolClientCapabilities_symbolKind_Type_1"] + """ Specific capabilities for the `SymbolKind` in the + `textDocument/documentSymbol` request. """ + hierarchicalDocumentSymbolSupport: NotRequired[bool] + """ The client supports hierarchical document symbols. """ + tagSupport: NotRequired["__DocumentSymbolClientCapabilities_tagSupport_Type_1"] + """ The client supports tags on `SymbolInformation`. Tags are supported on + `DocumentSymbol` if `hierarchicalDocumentSymbolSupport` is set to true. + Clients supporting tags have to handle unknown tags gracefully. + + @since 3.16.0 """ + labelSupport: NotRequired[bool] + """ The client supports an additional label presented in the UI when + registering a document symbol provider. + + @since 3.16.0 """ + + +class CodeActionClientCapabilities(TypedDict): + """The Client Capabilities of a {@link CodeActionRequest}.""" + + dynamicRegistration: NotRequired[bool] + """ Whether code action supports dynamic registration. """ + codeActionLiteralSupport: NotRequired["__CodeActionClientCapabilities_codeActionLiteralSupport_Type_1"] + """ The client support code action literals of type `CodeAction` as a valid + response of the `textDocument/codeAction` request. If the property is not + set the request can only return `Command` literals. + + @since 3.8.0 """ + isPreferredSupport: NotRequired[bool] + """ Whether code action supports the `isPreferred` property. + + @since 3.15.0 """ + disabledSupport: NotRequired[bool] + """ Whether code action supports the `disabled` property. + + @since 3.16.0 """ + dataSupport: NotRequired[bool] + """ Whether code action supports the `data` property which is + preserved between a `textDocument/codeAction` and a + `codeAction/resolve` request. + + @since 3.16.0 """ + resolveSupport: NotRequired["__CodeActionClientCapabilities_resolveSupport_Type_1"] + """ Whether the client supports resolving additional code action + properties via a separate `codeAction/resolve` request. + + @since 3.16.0 """ + honorsChangeAnnotations: NotRequired[bool] + """ Whether the client honors the change annotations in + text edits and resource operations returned via the + `CodeAction#edit` property by for example presenting + the workspace edit in the user interface and asking + for confirmation. + + @since 3.16.0 """ + + +class CodeLensClientCapabilities(TypedDict): + """The client capabilities of a {@link CodeLensRequest}.""" + + dynamicRegistration: NotRequired[bool] + """ Whether code lens supports dynamic registration. """ + + +class DocumentLinkClientCapabilities(TypedDict): + """The client capabilities of a {@link DocumentLinkRequest}.""" + + dynamicRegistration: NotRequired[bool] + """ Whether document link supports dynamic registration. """ + tooltipSupport: NotRequired[bool] + """ Whether the client supports the `tooltip` property on `DocumentLink`. + + @since 3.15.0 """ + + +class DocumentColorClientCapabilities(TypedDict): + dynamicRegistration: NotRequired[bool] + """ Whether implementation supports dynamic registration. If this is set to `true` + the client supports the new `DocumentColorRegistrationOptions` return value + for the corresponding server capability as well. """ + + +class DocumentFormattingClientCapabilities(TypedDict): + """Client capabilities of a {@link DocumentFormattingRequest}.""" + + dynamicRegistration: NotRequired[bool] + """ Whether formatting supports dynamic registration. """ + + +class DocumentRangeFormattingClientCapabilities(TypedDict): + """Client capabilities of a {@link DocumentRangeFormattingRequest}.""" + + dynamicRegistration: NotRequired[bool] + """ Whether range formatting supports dynamic registration. """ + + +class DocumentOnTypeFormattingClientCapabilities(TypedDict): + """Client capabilities of a {@link DocumentOnTypeFormattingRequest}.""" + + dynamicRegistration: NotRequired[bool] + """ Whether on type formatting supports dynamic registration. """ + + +class RenameClientCapabilities(TypedDict): + dynamicRegistration: NotRequired[bool] + """ Whether rename supports dynamic registration. """ + prepareSupport: NotRequired[bool] + """ Client supports testing for validity of rename operations + before execution. + + @since 3.12.0 """ + prepareSupportDefaultBehavior: NotRequired["PrepareSupportDefaultBehavior"] + """ Client supports the default behavior result. + + The value indicates the default behavior used by the + client. + + @since 3.16.0 """ + honorsChangeAnnotations: NotRequired[bool] + """ Whether the client honors the change annotations in + text edits and resource operations returned via the + rename request's workspace edit by for example presenting + the workspace edit in the user interface and asking + for confirmation. + + @since 3.16.0 """ + + +class FoldingRangeClientCapabilities(TypedDict): + dynamicRegistration: NotRequired[bool] + """ Whether implementation supports dynamic registration for folding range + providers. If this is set to `true` the client supports the new + `FoldingRangeRegistrationOptions` return value for the corresponding + server capability as well. """ + rangeLimit: NotRequired[Uint] + """ The maximum number of folding ranges that the client prefers to receive + per document. The value serves as a hint, servers are free to follow the + limit. """ + lineFoldingOnly: NotRequired[bool] + """ If set, the client signals that it only supports folding complete lines. + If set, client will ignore specified `startCharacter` and `endCharacter` + properties in a FoldingRange. """ + foldingRangeKind: NotRequired["__FoldingRangeClientCapabilities_foldingRangeKind_Type_1"] + """ Specific options for the folding range kind. + + @since 3.17.0 """ + foldingRange: NotRequired["__FoldingRangeClientCapabilities_foldingRange_Type_1"] + """ Specific options for the folding range. + + @since 3.17.0 """ + + +class SelectionRangeClientCapabilities(TypedDict): + dynamicRegistration: NotRequired[bool] + """ Whether implementation supports dynamic registration for selection range providers. If this is set to `true` + the client supports the new `SelectionRangeRegistrationOptions` return value for the corresponding server + capability as well. """ + + +class PublishDiagnosticsClientCapabilities(TypedDict): + """The publish diagnostic client capabilities.""" + + relatedInformation: NotRequired[bool] + """ Whether the clients accepts diagnostics with related information. """ + tagSupport: NotRequired["__PublishDiagnosticsClientCapabilities_tagSupport_Type_1"] + """ Client supports the tag property to provide meta data about a diagnostic. + Clients supporting tags have to handle unknown tags gracefully. + + @since 3.15.0 """ + versionSupport: NotRequired[bool] + """ Whether the client interprets the version property of the + `textDocument/publishDiagnostics` notification's parameter. + + @since 3.15.0 """ + codeDescriptionSupport: NotRequired[bool] + """ Client supports a codeDescription property + + @since 3.16.0 """ + dataSupport: NotRequired[bool] + """ Whether code action supports the `data` property which is + preserved between a `textDocument/publishDiagnostics` and + `textDocument/codeAction` request. + + @since 3.16.0 """ + + +class CallHierarchyClientCapabilities(TypedDict): + """@since 3.16.0""" + + dynamicRegistration: NotRequired[bool] + """ Whether implementation supports dynamic registration. If this is set to `true` + the client supports the new `(TextDocumentRegistrationOptions & StaticRegistrationOptions)` + return value for the corresponding server capability as well. """ + + +class SemanticTokensClientCapabilities(TypedDict): + """@since 3.16.0""" + + dynamicRegistration: NotRequired[bool] + """ Whether implementation supports dynamic registration. If this is set to `true` + the client supports the new `(TextDocumentRegistrationOptions & StaticRegistrationOptions)` + return value for the corresponding server capability as well. """ + requests: "__SemanticTokensClientCapabilities_requests_Type_1" + """ Which requests the client supports and might send to the server + depending on the server's capability. Please note that clients might not + show semantic tokens or degrade some of the user experience if a range + or full request is advertised by the client but not provided by the + server. If for example the client capability `requests.full` and + `request.range` are both set to true but the server only provides a + range provider the client might not render a minimap correctly or might + even decide to not show any semantic tokens at all. """ + tokenTypes: list[str] + """ The token types that the client supports. """ + tokenModifiers: list[str] + """ The token modifiers that the client supports. """ + formats: list["TokenFormat"] + """ The token formats the clients supports. """ + overlappingTokenSupport: NotRequired[bool] + """ Whether the client supports tokens that can overlap each other. """ + multilineTokenSupport: NotRequired[bool] + """ Whether the client supports tokens that can span multiple lines. """ + serverCancelSupport: NotRequired[bool] + """ Whether the client allows the server to actively cancel a + semantic token request, e.g. supports returning + LSPErrorCodes.ServerCancelled. If a server does the client + needs to retrigger the request. + + @since 3.17.0 """ + augmentsSyntaxTokens: NotRequired[bool] + """ Whether the client uses semantic tokens to augment existing + syntax tokens. If set to `true` client side created syntax + tokens and semantic tokens are both used for colorization. If + set to `false` the client only uses the returned semantic tokens + for colorization. + + If the value is `undefined` then the client behavior is not + specified. + + @since 3.17.0 """ + + +class LinkedEditingRangeClientCapabilities(TypedDict): + """Client capabilities for the linked editing range request. + + @since 3.16.0 + """ + + dynamicRegistration: NotRequired[bool] + """ Whether implementation supports dynamic registration. If this is set to `true` + the client supports the new `(TextDocumentRegistrationOptions & StaticRegistrationOptions)` + return value for the corresponding server capability as well. """ + + +class MonikerClientCapabilities(TypedDict): + """Client capabilities specific to the moniker request. + + @since 3.16.0 + """ + + dynamicRegistration: NotRequired[bool] + """ Whether moniker supports dynamic registration. If this is set to `true` + the client supports the new `MonikerRegistrationOptions` return value + for the corresponding server capability as well. """ + + +class TypeHierarchyClientCapabilities(TypedDict): + """@since 3.17.0""" + + dynamicRegistration: NotRequired[bool] + """ Whether implementation supports dynamic registration. If this is set to `true` + the client supports the new `(TextDocumentRegistrationOptions & StaticRegistrationOptions)` + return value for the corresponding server capability as well. """ + + +class InlineValueClientCapabilities(TypedDict): + """Client capabilities specific to inline values. + + @since 3.17.0 + """ + + dynamicRegistration: NotRequired[bool] + """ Whether implementation supports dynamic registration for inline value providers. """ + + +class InlayHintClientCapabilities(TypedDict): + """Inlay hint client capabilities. + + @since 3.17.0 + """ + + dynamicRegistration: NotRequired[bool] + """ Whether inlay hints support dynamic registration. """ + resolveSupport: NotRequired["__InlayHintClientCapabilities_resolveSupport_Type_1"] + """ Indicates which properties a client can resolve lazily on an inlay + hint. """ + + +class DiagnosticClientCapabilities(TypedDict): + """Client capabilities specific to diagnostic pull requests. + + @since 3.17.0 + """ + + dynamicRegistration: NotRequired[bool] + """ Whether implementation supports dynamic registration. If this is set to `true` + the client supports the new `(TextDocumentRegistrationOptions & StaticRegistrationOptions)` + return value for the corresponding server capability as well. """ + relatedDocumentSupport: NotRequired[bool] + """ Whether the clients supports related documents for document diagnostic pulls. """ + + +class NotebookDocumentSyncClientCapabilities(TypedDict): + """Notebook specific client capabilities. + + @since 3.17.0 + """ + + dynamicRegistration: NotRequired[bool] + """ Whether implementation supports dynamic registration. If this is + set to `true` the client supports the new + `(TextDocumentRegistrationOptions & StaticRegistrationOptions)` + return value for the corresponding server capability as well. """ + executionSummarySupport: NotRequired[bool] + """ The client supports sending execution summary data per cell. """ + + +class ShowMessageRequestClientCapabilities(TypedDict): + """Show message request client capabilities""" + + messageActionItem: NotRequired["__ShowMessageRequestClientCapabilities_messageActionItem_Type_1"] + """ Capabilities specific to the `MessageActionItem` type. """ + + +class ShowDocumentClientCapabilities(TypedDict): + """Client capabilities for the showDocument request. + + @since 3.16.0 + """ + + support: bool + """ The client has support for the showDocument + request. """ + + +class RegularExpressionsClientCapabilities(TypedDict): + """Client capabilities specific to regular expressions. + + @since 3.16.0 + """ + + engine: str + """ The engine's name. """ + version: NotRequired[str] + """ The engine's version. """ + + +class MarkdownClientCapabilities(TypedDict): + """Client capabilities specific to the used markdown parser. + + @since 3.16.0 + """ + + parser: str + """ The name of the parser. """ + version: NotRequired[str] + """ The version of the parser. """ + allowedTags: NotRequired[list[str]] + """ A list of HTML tags that the client allows / supports in + Markdown. + + @since 3.17.0 """ + + +class __CodeActionClientCapabilities_codeActionLiteralSupport_Type_1(TypedDict): + codeActionKind: "__CodeActionClientCapabilities_codeActionLiteralSupport_codeActionKind_Type_1" + """ The code action kind is support with the following value + set. """ + + +class __CodeActionClientCapabilities_codeActionLiteralSupport_codeActionKind_Type_1(TypedDict): + valueSet: list["CodeActionKind"] + """ The code action kind values the client supports. When this + property exists the client also guarantees that it will + handle values outside its set gracefully and falls back + to a default value when unknown. """ + + +class __CodeActionClientCapabilities_resolveSupport_Type_1(TypedDict): + properties: list[str] + """ The properties that a client can resolve lazily. """ + + +class __CodeAction_disabled_Type_1(TypedDict): + reason: str + """ Human readable description of why the code action is currently disabled. + + This is displayed in the code actions UI. """ + + +class __CompletionClientCapabilities_completionItemKind_Type_1(TypedDict): + valueSet: NotRequired[list["CompletionItemKind"]] + """ The completion item kind values the client supports. When this + property exists the client also guarantees that it will + handle values outside its set gracefully and falls back + to a default value when unknown. + + If this property is not present the client only supports + the completion items kinds from `Text` to `Reference` as defined in + the initial version of the protocol. """ + + +class __CompletionClientCapabilities_completionItem_Type_1(TypedDict): + snippetSupport: NotRequired[bool] + """ Client supports snippets as insert text. + + A snippet can define tab stops and placeholders with `$1`, `$2` + and `${3:foo}`. `$0` defines the final tab stop, it defaults to + the end of the snippet. Placeholders with equal identifiers are linked, + that is typing in one will update others too. """ + commitCharactersSupport: NotRequired[bool] + """ Client supports commit characters on a completion item. """ + documentationFormat: NotRequired[list["MarkupKind"]] + """ Client supports the following content formats for the documentation + property. The order describes the preferred format of the client. """ + deprecatedSupport: NotRequired[bool] + """ Client supports the deprecated property on a completion item. """ + preselectSupport: NotRequired[bool] + """ Client supports the preselect property on a completion item. """ + tagSupport: NotRequired["__CompletionClientCapabilities_completionItem_tagSupport_Type_1"] + """ Client supports the tag property on a completion item. Clients supporting + tags have to handle unknown tags gracefully. Clients especially need to + preserve unknown tags when sending a completion item back to the server in + a resolve call. + + @since 3.15.0 """ + insertReplaceSupport: NotRequired[bool] + """ Client support insert replace edit to control different behavior if a + completion item is inserted in the text or should replace text. + + @since 3.16.0 """ + resolveSupport: NotRequired["__CompletionClientCapabilities_completionItem_resolveSupport_Type_1"] + """ Indicates which properties a client can resolve lazily on a completion + item. Before version 3.16.0 only the predefined properties `documentation` + and `details` could be resolved lazily. + + @since 3.16.0 """ + insertTextModeSupport: NotRequired["__CompletionClientCapabilities_completionItem_insertTextModeSupport_Type_1"] + """ The client supports the `insertTextMode` property on + a completion item to override the whitespace handling mode + as defined by the client (see `insertTextMode`). + + @since 3.16.0 """ + labelDetailsSupport: NotRequired[bool] + """ The client has support for completion item label + details (see also `CompletionItemLabelDetails`). + + @since 3.17.0 """ + + +class __CompletionClientCapabilities_completionItem_insertTextModeSupport_Type_1(TypedDict): + valueSet: list["InsertTextMode"] + + +class __CompletionClientCapabilities_completionItem_resolveSupport_Type_1(TypedDict): + properties: list[str] + """ The properties that a client can resolve lazily. """ + + +class __CompletionClientCapabilities_completionItem_tagSupport_Type_1(TypedDict): + valueSet: list["CompletionItemTag"] + """ The tags supported by the client. """ + + +class __CompletionClientCapabilities_completionList_Type_1(TypedDict): + itemDefaults: NotRequired[list[str]] + """ The client supports the following itemDefaults on + a completion list. + + The value lists the supported property names of the + `CompletionList.itemDefaults` object. If omitted + no properties are supported. + + @since 3.17.0 """ + + +class __CompletionList_itemDefaults_Type_1(TypedDict): + commitCharacters: NotRequired[list[str]] + """ A default commit character set. + + @since 3.17.0 """ + editRange: NotRequired[Union["Range", "__CompletionList_itemDefaults_editRange_Type_1"]] + """ A default edit range. + + @since 3.17.0 """ + insertTextFormat: NotRequired["InsertTextFormat"] + """ A default insert text format. + + @since 3.17.0 """ + insertTextMode: NotRequired["InsertTextMode"] + """ A default insert text mode. + + @since 3.17.0 """ + data: NotRequired["LSPAny"] + """ A default data value. + + @since 3.17.0 """ + + +class __CompletionList_itemDefaults_editRange_Type_1(TypedDict): + insert: "Range" + replace: "Range" + + +class __CompletionOptions_completionItem_Type_1(TypedDict): + labelDetailsSupport: NotRequired[bool] + """ The server has support for completion item label + details (see also `CompletionItemLabelDetails`) when + receiving a completion item in a resolve call. + + @since 3.17.0 """ + + +class __CompletionOptions_completionItem_Type_2(TypedDict): + labelDetailsSupport: NotRequired[bool] + """ The server has support for completion item label + details (see also `CompletionItemLabelDetails`) when + receiving a completion item in a resolve call. + + @since 3.17.0 """ + + +class __DocumentSymbolClientCapabilities_symbolKind_Type_1(TypedDict): + valueSet: NotRequired[list["SymbolKind"]] + """ The symbol kind values the client supports. When this + property exists the client also guarantees that it will + handle values outside its set gracefully and falls back + to a default value when unknown. + + If this property is not present the client only supports + the symbol kinds from `File` to `Array` as defined in + the initial version of the protocol. """ + + +class __DocumentSymbolClientCapabilities_tagSupport_Type_1(TypedDict): + valueSet: list["SymbolTag"] + """ The tags supported by the client. """ + + +class __FoldingRangeClientCapabilities_foldingRangeKind_Type_1(TypedDict): + valueSet: NotRequired[list["FoldingRangeKind"]] + """ The folding range kind values the client supports. When this + property exists the client also guarantees that it will + handle values outside its set gracefully and falls back + to a default value when unknown. """ + + +class __FoldingRangeClientCapabilities_foldingRange_Type_1(TypedDict): + collapsedText: NotRequired[bool] + """ If set, the client signals that it supports setting collapsedText on + folding ranges to display custom labels instead of the default text. + + @since 3.17.0 """ + + +class __GeneralClientCapabilities_staleRequestSupport_Type_1(TypedDict): + cancel: bool + """ The client will actively cancel the request. """ + retryOnContentModified: list[str] + """ The list of requests for which the client + will retry the request if it receives a + response with error code `ContentModified` """ + + +class __InitializeResult_serverInfo_Type_1(TypedDict): + name: str + """ The name of the server as defined by the server. """ + version: NotRequired[str] + """ The server's version as defined by the server. """ + + +class __InlayHintClientCapabilities_resolveSupport_Type_1(TypedDict): + properties: list[str] + """ The properties that a client can resolve lazily. """ + + +class __MarkedString_Type_1(TypedDict): + language: str + value: str + + +class __NotebookDocumentChangeEvent_cells_Type_1(TypedDict): + structure: NotRequired["__NotebookDocumentChangeEvent_cells_structure_Type_1"] + """ Changes to the cell structure to add or + remove cells. """ + data: NotRequired[list["NotebookCell"]] + """ Changes to notebook cells properties like its + kind, execution summary or metadata. """ + textContent: NotRequired[list["__NotebookDocumentChangeEvent_cells_textContent_Type_1"]] + """ Changes to the text content of notebook cells. """ + + +class __NotebookDocumentChangeEvent_cells_structure_Type_1(TypedDict): + array: "NotebookCellArrayChange" + """ The change to the cell array. """ + didOpen: NotRequired[list["TextDocumentItem"]] + """ Additional opened cell text documents. """ + didClose: NotRequired[list["TextDocumentIdentifier"]] + """ Additional closed cell text documents. """ + + +class __NotebookDocumentChangeEvent_cells_textContent_Type_1(TypedDict): + document: "VersionedTextDocumentIdentifier" + changes: list["TextDocumentContentChangeEvent"] + + +class __NotebookDocumentFilter_Type_1(TypedDict): + notebookType: str + """ The type of the enclosing notebook. """ + scheme: NotRequired[str] + """ A Uri {@link Uri.scheme scheme}, like `file` or `untitled`. """ + pattern: NotRequired[str] + """ A glob pattern. """ + + +class __NotebookDocumentFilter_Type_2(TypedDict): + notebookType: NotRequired[str] + """ The type of the enclosing notebook. """ + scheme: str + """ A Uri {@link Uri.scheme scheme}, like `file` or `untitled`. """ + pattern: NotRequired[str] + """ A glob pattern. """ + + +class __NotebookDocumentFilter_Type_3(TypedDict): + notebookType: NotRequired[str] + """ The type of the enclosing notebook. """ + scheme: NotRequired[str] + """ A Uri {@link Uri.scheme scheme}, like `file` or `untitled`. """ + pattern: str + """ A glob pattern. """ + + +class __NotebookDocumentSyncOptions_notebookSelector_Type_1(TypedDict): + notebook: Union[str, "NotebookDocumentFilter"] + """ The notebook to be synced If a string + value is provided it matches against the + notebook type. '*' matches every notebook. """ + cells: NotRequired[list["__NotebookDocumentSyncOptions_notebookSelector_cells_Type_1"]] + """ The cells of the matching notebook to be synced. """ + + +class __NotebookDocumentSyncOptions_notebookSelector_Type_2(TypedDict): + notebook: NotRequired[Union[str, "NotebookDocumentFilter"]] + """ The notebook to be synced If a string + value is provided it matches against the + notebook type. '*' matches every notebook. """ + cells: list["__NotebookDocumentSyncOptions_notebookSelector_cells_Type_2"] + """ The cells of the matching notebook to be synced. """ + + +class __NotebookDocumentSyncOptions_notebookSelector_Type_3(TypedDict): + notebook: Union[str, "NotebookDocumentFilter"] + """ The notebook to be synced If a string + value is provided it matches against the + notebook type. '*' matches every notebook. """ + cells: NotRequired[list["__NotebookDocumentSyncOptions_notebookSelector_cells_Type_3"]] + """ The cells of the matching notebook to be synced. """ + + +class __NotebookDocumentSyncOptions_notebookSelector_Type_4(TypedDict): + notebook: NotRequired[Union[str, "NotebookDocumentFilter"]] + """ The notebook to be synced If a string + value is provided it matches against the + notebook type. '*' matches every notebook. """ + cells: list["__NotebookDocumentSyncOptions_notebookSelector_cells_Type_4"] + """ The cells of the matching notebook to be synced. """ + + +class __NotebookDocumentSyncOptions_notebookSelector_cells_Type_1(TypedDict): + language: str + + +class __NotebookDocumentSyncOptions_notebookSelector_cells_Type_2(TypedDict): + language: str + + +class __NotebookDocumentSyncOptions_notebookSelector_cells_Type_3(TypedDict): + language: str + + +class __NotebookDocumentSyncOptions_notebookSelector_cells_Type_4(TypedDict): + language: str + + +class __PrepareRenameResult_Type_1(TypedDict): + range: "Range" + placeholder: str + + +class __PrepareRenameResult_Type_2(TypedDict): + defaultBehavior: bool + + +class __PublishDiagnosticsClientCapabilities_tagSupport_Type_1(TypedDict): + valueSet: list["DiagnosticTag"] + """ The tags supported by the client. """ + + +class __SemanticTokensClientCapabilities_requests_Type_1(TypedDict): + range: NotRequired[bool | dict] + """ The client will send the `textDocument/semanticTokens/range` request if + the server provides a corresponding handler. """ + full: NotRequired[Union[bool, "__SemanticTokensClientCapabilities_requests_full_Type_1"]] + """ The client will send the `textDocument/semanticTokens/full` request if + the server provides a corresponding handler. """ + + +class __SemanticTokensClientCapabilities_requests_full_Type_1(TypedDict): + delta: NotRequired[bool] + """ The client will send the `textDocument/semanticTokens/full/delta` request if + the server provides a corresponding handler. """ + + +class __SemanticTokensOptions_full_Type_1(TypedDict): + delta: NotRequired[bool] + """ The server supports deltas for full documents. """ + + +class __SemanticTokensOptions_full_Type_2(TypedDict): + delta: NotRequired[bool] + """ The server supports deltas for full documents. """ + + +class __ServerCapabilities_workspace_Type_1(TypedDict): + workspaceFolders: NotRequired["WorkspaceFoldersServerCapabilities"] + """ The server supports workspace folder. + + @since 3.6.0 """ + fileOperations: NotRequired["FileOperationOptions"] + """ The server is interested in notifications/requests for operations on files. + + @since 3.16.0 """ + + +class __ShowMessageRequestClientCapabilities_messageActionItem_Type_1(TypedDict): + additionalPropertiesSupport: NotRequired[bool] + """ Whether the client supports additional attributes which + are preserved and send back to the server in the + request's response. """ + + +class __SignatureHelpClientCapabilities_signatureInformation_Type_1(TypedDict): + documentationFormat: NotRequired[list["MarkupKind"]] + """ Client supports the following content formats for the documentation + property. The order describes the preferred format of the client. """ + parameterInformation: NotRequired["__SignatureHelpClientCapabilities_signatureInformation_parameterInformation_Type_1"] + """ Client capabilities specific to parameter information. """ + activeParameterSupport: NotRequired[bool] + """ The client supports the `activeParameter` property on `SignatureInformation` + literal. + + @since 3.16.0 """ + + +class __SignatureHelpClientCapabilities_signatureInformation_parameterInformation_Type_1(TypedDict): + labelOffsetSupport: NotRequired[bool] + """ The client supports processing label offsets instead of a + simple label string. + + @since 3.14.0 """ + + +class __TextDocumentContentChangeEvent_Type_1(TypedDict): + range: "Range" + """ The range of the document that changed. """ + rangeLength: NotRequired[Uint] + """ The optional length of the range that got replaced. + + @deprecated use range instead. """ + text: str + """ The new text for the provided range. """ + + +class __TextDocumentContentChangeEvent_Type_2(TypedDict): + text: str + """ The new text of the whole document. """ + + +class __TextDocumentFilter_Type_1(TypedDict): + language: str + """ A language id, like `typescript`. """ + scheme: NotRequired[str] + """ A Uri {@link Uri.scheme scheme}, like `file` or `untitled`. """ + pattern: NotRequired[str] + """ A glob pattern, like `*.{ts,js}`. """ + + +class __TextDocumentFilter_Type_2(TypedDict): + language: NotRequired[str] + """ A language id, like `typescript`. """ + scheme: str + """ A Uri {@link Uri.scheme scheme}, like `file` or `untitled`. """ + pattern: NotRequired[str] + """ A glob pattern, like `*.{ts,js}`. """ + + +class __TextDocumentFilter_Type_3(TypedDict): + language: NotRequired[str] + """ A language id, like `typescript`. """ + scheme: NotRequired[str] + """ A Uri {@link Uri.scheme scheme}, like `file` or `untitled`. """ + pattern: str + """ A glob pattern, like `*.{ts,js}`. """ + + +class __WorkspaceEditClientCapabilities_changeAnnotationSupport_Type_1(TypedDict): + groupsOnLabel: NotRequired[bool] + """ Whether the client groups edits with equal labels into tree nodes, + for instance all edits labelled with "Changes in Strings" would + be a tree node. """ + + +class __WorkspaceSymbolClientCapabilities_resolveSupport_Type_1(TypedDict): + properties: list[str] + """ The properties that a client can resolve lazily. Usually + `location.range` """ + + +class __WorkspaceSymbolClientCapabilities_symbolKind_Type_1(TypedDict): + valueSet: NotRequired[list["SymbolKind"]] + """ The symbol kind values the client supports. When this + property exists the client also guarantees that it will + handle values outside its set gracefully and falls back + to a default value when unknown. + + If this property is not present the client only supports + the symbol kinds from `File` to `Array` as defined in + the initial version of the protocol. """ + + +class __WorkspaceSymbolClientCapabilities_tagSupport_Type_1(TypedDict): + valueSet: list["SymbolTag"] + """ The tags supported by the client. """ + + +class __WorkspaceSymbol_location_Type_1(TypedDict): + uri: "DocumentUri" + + +class ___InitializeParams_clientInfo_Type_1(TypedDict): + name: str + """ The name of the client as defined by the client. """ + version: NotRequired[str] + """ The client's version as defined by the client. """ diff --git a/src/solidlsp/lsp_protocol_handler/server.py b/src/solidlsp/lsp_protocol_handler/server.py new file mode 100644 index 0000000..da94e1e --- /dev/null +++ b/src/solidlsp/lsp_protocol_handler/server.py @@ -0,0 +1,161 @@ +""" +This file provides the implementation of the JSON-RPC client, that launches and +communicates with the language server. + +The initial implementation of this file was obtained from +https://github.com/predragnikolic/OLSP under the MIT License with the following terms: + +MIT License + +Copyright (c) 2023 Предраг Николић + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import dataclasses +import json +import logging +import os +from typing import Any, Union + +from .lsp_types import ErrorCodes + +StringDict = dict[str, Any] +PayloadLike = Union[list[StringDict], StringDict, None, bool] +CONTENT_LENGTH = "Content-Length: " +ENCODING = "utf-8" +log = logging.getLogger(__name__) + + +@dataclasses.dataclass +class ProcessLaunchInfo: + """ + This class is used to store the information required to launch a (language server) process. + """ + + cmd: str | list[str] + """ + the command used to launch the process. + Specification as a list is preferred (as it is more robust and avoids incorrect quoting of arguments); + the string variant is supported for backward compatibility only + """ + + env: dict[str, str] = dataclasses.field(default_factory=dict) + """ + the environment variables to set for the process + """ + + cwd: str = os.getcwd() + """ + the working directory for the process + """ + + +class LSPError(Exception): + def __init__(self, code: ErrorCodes, message: str) -> None: + super().__init__(message) + self.code = code + + def to_lsp(self) -> StringDict: + return {"code": self.code, "message": super().__str__()} + + @classmethod + def from_lsp(cls, d: StringDict) -> "LSPError": + return LSPError(d["code"], d["message"]) + + def __str__(self) -> str: + return f"{super().__str__()} ({self.code})" + + +def make_response(request_id: Any, params: PayloadLike) -> StringDict: + return {"jsonrpc": "2.0", "id": request_id, "result": params} + + +def make_error_response(request_id: Any, err: LSPError) -> StringDict: + return {"jsonrpc": "2.0", "id": request_id, "error": err.to_lsp()} + + +# LSP methods that expect NO params field at all (not even empty object). +# These methods use Void/unit type in their protocol definition. +# - shutdown: HLS uses Haskell's Void type, rust-analyzer expects unit +# - exit: Similar - notification with no params +# Sending params:{} to these methods causes parse errors like "Cannot parse Void" +# See: https://www.jsonrpc.org/specification ("params MAY be omitted") +_NO_PARAMS_METHODS = frozenset({"shutdown", "exit"}) + + +def _build_params_field(method: str, params: PayloadLike) -> StringDict: + """Build the params portion of a JSON-RPC message based on LSP method requirements. + + LSP methods with Void/unit type (shutdown, exit) must omit params field entirely + to satisfy HLS and rust-analyzer. Other methods send empty {} for None params + to maintain Delphi/FPC LSP compatibility (PR #851). + + Returns a dict that can be merged into the message using ** unpacking. + """ + if method in _NO_PARAMS_METHODS: + return {} # Omit params entirely for Void-type methods + elif params is not None: + return {"params": params} + else: + return {"params": {}} # Keep {} for Delphi/FPC compatibility + + +def make_notification(method: str, params: PayloadLike) -> StringDict: + """Create a JSON-RPC 2.0 notification message.""" + return {"jsonrpc": "2.0", "method": method, **_build_params_field(method, params)} + + +def make_request(method: str, request_id: Any, params: PayloadLike) -> StringDict: + """Create a JSON-RPC 2.0 request message.""" + return {"jsonrpc": "2.0", "method": method, "id": request_id, **_build_params_field(method, params)} + + +class StopLoopException(Exception): + pass + + +def create_message(payload: PayloadLike) -> tuple[bytes, bytes]: + body = json.dumps(payload, check_circular=False, ensure_ascii=False, separators=(",", ":")).encode(ENCODING) + # NOTE: only Content-Length is sent. Content-Type is optional per the LSP + # spec (default: application/vscode-jsonrpc; charset=utf-8), and Godot's + # GDScript LSP parser only handles Content-Length — any additional header + # makes it silently drop the message. + return ( + f"Content-Length: {len(body)}\r\n\r\n".encode(ENCODING), + body, + ) + + +class MessageType: + error = 1 + warning = 2 + info = 3 + log = 4 + + +def content_length(line: bytes) -> int | None: + if line.startswith(b"Content-Length: "): + _, value = line.split(b"Content-Length: ") + value = value.strip() + try: + return int(value) + except ValueError: + raise ValueError(f"Invalid Content-Length header: {value!r}") + return None diff --git a/src/solidlsp/settings.py b/src/solidlsp/settings.py new file mode 100644 index 0000000..373f5d2 --- /dev/null +++ b/src/solidlsp/settings.py @@ -0,0 +1,79 @@ +""" +Defines settings for Solid-LSP +""" + +import logging +import os +import pathlib +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from sensai.util.string import ToStringMixin + +if TYPE_CHECKING: + from solidlsp.ls_config import Language + +log = logging.getLogger(__name__) + + +@dataclass +class SolidLSPSettings: + """ + Configures SolidLSP-specific data storage as well as global settings. + + Note: Server instance-specific settings belong in LanguageServerConfig, not here. + """ + + solidlsp_dir: str = str(pathlib.Path.home() / ".solidlsp") + """ + Path to the directory in which to store global Solid-LSP data (which is not project-specific) + """ + project_data_path: str = "" + """ + Absolute path to a directory where Solid-LSP can store project-specific data, e.g. cache files. + For instance, if this is "/home/user/myproject/.solidlsp", + then Solid-LSP will store project-specific data (e.g. caches) in that directory. + """ + ls_specific_settings: dict["Language", dict[str, Any]] = field(default_factory=dict) + """ + Advanced configuration option allowing to configure language server implementation specific options. + Have a look at the docstring of the constructors of the corresponding LS implementations within solidlsp to see which options are available. + No documentation on options means no options are available. + """ + + def __post_init__(self) -> None: + os.makedirs(str(self.solidlsp_dir), exist_ok=True) + os.makedirs(str(self.ls_resources_dir), exist_ok=True) + + @property + def ls_resources_dir(self) -> str: + return os.path.join(str(self.solidlsp_dir), "language_servers", "static") + + class CustomLSSettings(ToStringMixin): + def __init__(self, settings: dict[str, Any] | None) -> None: + self.settings = settings or {} + + def get(self, key: str, default_value: Any = None) -> Any: + """ + Returns the custom setting for the given key or the default value if not set. + If a custom value is set for the given key, the retrieval is logged. + + :param key: the key + :param default_value: the default value to use if no custom value is set + :return: the value + """ + if key in self.settings: + value = self.settings[key] + log.info("Using custom LS setting %s for key '%s'", value, key) + else: + value = default_value + return value + + def get_ls_specific_settings(self, language: "Language") -> CustomLSSettings: + """ + Get the language server specific settings for the given language. + + :param language: The programming language. + :return: A dictionary of settings for the language server. + """ + return self.CustomLSSettings(self.ls_specific_settings.get(language)) diff --git a/src/solidlsp/util/cache.py b/src/solidlsp/util/cache.py new file mode 100644 index 0000000..64ee134 --- /dev/null +++ b/src/solidlsp/util/cache.py @@ -0,0 +1,23 @@ +import logging +from typing import Any, Optional + +from sensai.util.pickle import dump_pickle, load_pickle + +log = logging.getLogger(__name__) + + +def load_cache(path: str, version: Any) -> Optional[Any]: + data = load_pickle(path) + if not isinstance(data, dict) or "__cache_version" not in data: + log.info("Cache is outdated (expected version %s). Ignoring cache at %s", version, path) + return None + saved_version = data["__cache_version"] + if saved_version != version: + log.info("Cache is outdated (expected version %s, got %s). Ignoring cache at %s", version, saved_version, path) + return None + return data["obj"] + + +def save_cache(path: str, version: Any, obj: Any) -> None: + data = {"__cache_version": version, "obj": obj} + dump_pickle(data, path) diff --git a/src/solidlsp/util/metals_db_utils.py b/src/solidlsp/util/metals_db_utils.py new file mode 100644 index 0000000..9e91eeb --- /dev/null +++ b/src/solidlsp/util/metals_db_utils.py @@ -0,0 +1,280 @@ +""" +Utilities for detecting and managing Scala Metals H2 database state. + +This module provides functions to detect existing Metals LSP instances by checking +the H2 database lock file, and to clean up stale locks from crashed processes. + +Metals uses H2 AUTO_SERVER mode (enabled by default) to support multiple concurrent +instances sharing the same database. However, if a Metals process crashes without +proper cleanup, it can leave a stale lock file that prevents proper AUTO_SERVER +coordination, causing new instances to fall back to in-memory database mode. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + pass + +log = logging.getLogger(__name__) + + +class MetalsDbStatus(Enum): + """Status of the Metals H2 database for a project.""" + + NO_DATABASE = "no_database" + """No .metals directory or database exists (fresh project).""" + + NO_LOCK = "no_lock" + """Database exists but no lock file (safe to start).""" + + ACTIVE_INSTANCE = "active_instance" + """Lock held by a running process (will share via AUTO_SERVER).""" + + STALE_LOCK = "stale_lock" + """Lock held by a dead process (needs cleanup).""" + + +@dataclass +class MetalsLockInfo: + """Information extracted from an H2 database lock file.""" + + pid: int | None + """Process ID that holds the lock, if parseable.""" + + port: int | None + """TCP port for AUTO_SERVER connection, if parseable.""" + + lock_path: Path + """Path to the lock file.""" + + is_stale: bool + """True if the owning process is no longer running.""" + + raw_content: str + """Raw content of the lock file for debugging.""" + + +def parse_h2_lock_file(lock_path: Path) -> MetalsLockInfo | None: + """ + Parse an H2 database lock file to extract connection information. + + The H2 lock file format varies by version but typically contains + server connection information. Common formats include: + - Text format: "server:localhost:9092" or similar + - Binary format with embedded PID + + Args: + lock_path: Path to the .lock.db file + + Returns: + MetalsLockInfo if the file can be parsed, None if file doesn't exist + or is completely unparsable. + + """ + if not lock_path.exists(): + return None + + try: + # Try reading as text first (most common for H2 AUTO_SERVER) + content = lock_path.read_text(encoding="utf-8", errors="replace") + except OSError as e: + log.debug(f"Could not read lock file {lock_path}: {e}") + return None + + pid: int | None = None + port: int | None = None + + # Try to extract port from common H2 lock file formats + # Format 1: "server:localhost:PORT" + server_match = re.search(r"server:[\w.]+:(\d+)", content, re.IGNORECASE) + if server_match: + port = int(server_match.group(1)) + + # Format 2: Look for standalone port numbers (H2 uses ports in 9000+ range typically) + if port is None: + port_match = re.search(r"\b(9\d{3})\b", content) + if port_match: + port = int(port_match.group(1)) + + # Try to extract PID - H2 may embed this in various formats + pid_match = re.search(r"pid[=:]?\s*(\d+)", content, re.IGNORECASE) + if pid_match: + pid = int(pid_match.group(1)) + + # Check if the process is still alive + is_stale = False + if pid is not None: + is_stale = not is_metals_process_alive(pid) + elif port is not None: + # If we have a port but no PID, try to find a Metals process using that port + is_stale = not _is_port_in_use_by_metals(port) + else: + # Can't determine - assume stale if lock exists but we can't parse it + # and no Metals processes are running for this project + log.debug(f"Could not parse PID or port from lock file: {lock_path}") + is_stale = True # Conservative: treat unparsable as stale + + return MetalsLockInfo( + pid=pid, + port=port, + lock_path=lock_path, + is_stale=is_stale, + raw_content=content[:200], # Truncate for logging + ) + + +def is_metals_process_alive(pid: int) -> bool: + """ + Check if a process with the given PID is alive and is a Metals process. + + Args: + pid: Process ID to check + + Returns: + True if the process exists and appears to be a Metals LSP server. + + """ + try: + import psutil + + proc = psutil.Process(pid) + if not proc.is_running(): + return False + + # Check if this is actually a Metals process + cmdline = " ".join(proc.cmdline()).lower() + return _is_metals_cmdline(cmdline) + + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + return False + except Exception as e: + log.debug(f"Error checking process {pid}: {e}") + return False + + +def _is_metals_cmdline(cmdline: str) -> bool: + """Check if a command line string appears to be a Metals LSP server.""" + cmdline_lower = cmdline.lower() + # Metals is a Scala/Java application + if "java" not in cmdline_lower: + return False + # Look for Metals-specific identifiers + return any( + marker in cmdline_lower + for marker in [ + "metals", + "org.scalameta", + "-dmetals.client", + ] + ) + + +def _is_port_in_use_by_metals(port: int) -> bool: + """Check if the given port is in use by a Metals process.""" + try: + import psutil + + for conn in psutil.net_connections(kind="tcp"): + if conn.laddr.port == port and conn.status == "LISTEN": + try: + proc = psutil.Process(conn.pid) + cmdline = " ".join(proc.cmdline()).lower() + if _is_metals_cmdline(cmdline): + return True + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + return False + except (psutil.AccessDenied, OSError) as e: + # On some systems, net_connections requires elevated privileges + log.debug(f"Could not check port {port}: {e}") + return False + + +def check_metals_db_status(project_path: Path) -> tuple[MetalsDbStatus, MetalsLockInfo | None]: + """ + Check the status of the Metals H2 database for a project. + + This function determines whether it's safe to start a new Metals instance + and whether any cleanup is needed. + + Args: + project_path: Path to the project root directory + + Returns: + A tuple of (status, lock_info) where lock_info is populated for + ACTIVE_INSTANCE and STALE_LOCK statuses. + + """ + metals_dir = project_path / ".metals" + db_path = metals_dir / "metals.mv.db" + lock_path = metals_dir / "metals.mv.db.lock.db" + + if not metals_dir.exists(): + log.debug(f"No .metals directory found at {metals_dir}") + return MetalsDbStatus.NO_DATABASE, None + + if not db_path.exists(): + log.debug(f"No Metals database found at {db_path}") + return MetalsDbStatus.NO_DATABASE, None + + if not lock_path.exists(): + log.debug(f"Metals database exists but no lock file at {lock_path}") + return MetalsDbStatus.NO_LOCK, None + + # Lock file exists - parse it to determine status + lock_info = parse_h2_lock_file(lock_path) + + if lock_info is None: + # Lock file exists but couldn't be read - treat as stale + log.warning(f"Could not read lock file at {lock_path}, treating as stale") + return MetalsDbStatus.STALE_LOCK, MetalsLockInfo( + pid=None, + port=None, + lock_path=lock_path, + is_stale=True, + raw_content="", + ) + + if lock_info.is_stale: + log.debug(f"Stale Metals lock detected: {lock_info}") + return MetalsDbStatus.STALE_LOCK, lock_info + else: + log.debug(f"Active Metals instance detected: {lock_info}") + return MetalsDbStatus.ACTIVE_INSTANCE, lock_info + + +def cleanup_stale_lock(lock_path: Path) -> bool: + """ + Remove a stale H2 database lock file. + + This should only be called when we've verified the owning process is dead. + Removing a lock file from a running process could cause database corruption. + + Args: + lock_path: Path to the .lock.db file to remove + + Returns: + True if cleanup succeeded, False otherwise. + + """ + if not lock_path.exists(): + log.debug(f"Lock file already removed: {lock_path}") + return True + + try: + lock_path.unlink() + log.info(f"Cleaned up stale Metals lock file: {lock_path}") + return True + except PermissionError as e: + log.warning(f"Permission denied removing stale lock file {lock_path}: {e}") + return False + except OSError as e: + log.warning(f"Could not remove stale lock file {lock_path}: {e}") + return False diff --git a/src/solidlsp/util/subprocess_util.py b/src/solidlsp/util/subprocess_util.py new file mode 100644 index 0000000..bf994e4 --- /dev/null +++ b/src/solidlsp/util/subprocess_util.py @@ -0,0 +1,96 @@ +import logging +import platform +import subprocess + +import oslex +import psutil + +log = logging.getLogger(__name__) + + +def subprocess_kwargs() -> dict: + """ + Returns a dictionary of keyword arguments for subprocess calls, adding platform-specific + flags that we want to use consistently. + """ + kwargs = {} + if platform.system() == "Windows": + kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + return kwargs + + +def convert_shell_cmd(cmd: str | list[str]) -> str: + """ + Converts a command (specified as a list or string) to a format supported by subprocess calls with shell=True on the current platform, + applying necessary escaping and quoting if the command is specified as a list of arguments. + + :param cmd: the command to convert, specified as a list of arguments + :return: a suitable representation of the command for subprocess calls on the current platform + """ + if isinstance(cmd, list): + return oslex.join(cmd) + else: + return cmd + + +def _signal_process_tree(process: subprocess.Popen[bytes], terminate: bool = True) -> None: + """ + Sends a signal (terminate or kill) to the given process and all its children. + + :param terminate: if True, signal terminate, otherwise signal kill + """ + + def signal_process(p: subprocess.Popen | psutil.Process) -> None: + try: + if terminate: + p.terminate() + else: + p.kill() + except: + pass + + # Try to get the parent process + parent = None + try: + parent = psutil.Process(process.pid) + except (psutil.NoSuchProcess, psutil.AccessDenied, Exception): + pass + + # If we have the parent process and it's running, signal the entire tree + if parent and parent.is_running(): + for child in parent.children(recursive=True): + signal_process(child) + signal_process(parent) + # Otherwise, fall back to direct process signaling + else: + signal_process(process) + + +def terminate_process_tree_with_kill_fallback(process: subprocess.Popen, terminate_timeout: float, process_name: str = "Process") -> None: + """ + Attempts to terminate the given process and its children by signaling them to terminate, + and if that fails (i.e. they don't exit within the given timeout), forcefully kills them. + + The termination is logged. + + :param process: the process to terminate + :param terminate_timeout: the time to wait for the process to terminate gracefully before killing it + :param process_name: the name of the process (used for logging purposes); should start with capital letter + """ + log.debug(f"Terminating process {process.pid}, current status: {process.poll()}") + _signal_process_tree(process, terminate=True) + try: + log.debug(f"Waiting for process {process.pid} to terminate...") + exit_code = process.wait(timeout=terminate_timeout) + log.info(f"{process_name} terminated successfully with exit code {exit_code}.") + except subprocess.TimeoutExpired: + # If termination failed, forcefully kill the process + log.warning(f"{process_name} (pid={process.pid}) termination timed out, killing process forcefully...") + _signal_process_tree(process, terminate=False) + try: + exit_code = process.wait(timeout=2.0) + log.info(f"{process_name} killed successfully with exit code {exit_code}.") + except subprocess.TimeoutExpired: + log.error(f"{process_name} (pid={process.pid}) could not be killed within timeout.") + except Exception as e: + log.error(f"Error during process shutdown: {e}") diff --git a/src/solidlsp/util/zip.py b/src/solidlsp/util/zip.py new file mode 100644 index 0000000..d93d942 --- /dev/null +++ b/src/solidlsp/util/zip.py @@ -0,0 +1,128 @@ +import fnmatch +import logging +import os +import sys +import zipfile +from pathlib import Path +from typing import Optional + +log = logging.getLogger(__name__) + + +class SafeZipExtractor: + """ + A utility class for extracting ZIP archives safely. + + Features: + - Handles long file paths on Windows + - Skips files that fail to extract, continuing with the rest + - Creates necessary directories automatically + - Optional include/exclude pattern filters + """ + + def __init__( + self, + archive_path: Path, + extract_dir: Path, + verbose: bool = True, + include_patterns: Optional[list[str]] = None, + exclude_patterns: Optional[list[str]] = None, + ) -> None: + """ + Initialize the SafeZipExtractor. + + :param archive_path: Path to the ZIP archive file + :param extract_dir: Directory where files will be extracted + :param verbose: Whether to log status messages + :param include_patterns: List of glob patterns for files to extract (None = all files) + :param exclude_patterns: List of glob patterns for files to skip + """ + self.archive_path = Path(archive_path) + self.extract_dir = Path(extract_dir) + self.verbose = verbose + self.include_patterns = include_patterns or [] + self.exclude_patterns = exclude_patterns or [] + + def extract_all(self) -> None: + """ + Extract all files from the archive, skipping any that fail. + """ + if not self.archive_path.exists(): + raise FileNotFoundError(f"Archive not found: {self.archive_path}") + + if self.verbose: + log.info(f"Extracting from: {self.archive_path} to {self.extract_dir}") + + with zipfile.ZipFile(self.archive_path, "r") as zip_ref: + for member in zip_ref.infolist(): + if self._should_extract(member.filename): + self._extract_member(zip_ref, member) + elif self.verbose: + log.info(f"Skipped: {member.filename}") + + def _should_extract(self, filename: str) -> bool: + """ + Determine whether a file should be extracted based on include/exclude patterns. + + :param filename: The file name from the archive + :return: True if the file should be extracted + """ + # If include_patterns is set, only extract if it matches at least one pattern + if self.include_patterns: + if not any(fnmatch.fnmatch(filename, pattern) for pattern in self.include_patterns): + return False + + # If exclude_patterns is set, skip if it matches any pattern + if self.exclude_patterns: + if any(fnmatch.fnmatch(filename, pattern) for pattern in self.exclude_patterns): + return False + + return True + + def _extract_member(self, zip_ref: zipfile.ZipFile, member: zipfile.ZipInfo) -> None: + """ + Extract a single member from the archive with error handling. + + :param zip_ref: Open ZipFile object + :param member: ZipInfo object representing the file + """ + try: + target_path = self.extract_dir / member.filename + + # Ensure directory structure exists + target_path.parent.mkdir(parents=True, exist_ok=True) + + # Handle long paths on Windows + final_path = self._normalize_path(target_path) + + # Extract file + with zip_ref.open(member) as source, open(final_path, "wb") as target: + target.write(source.read()) + + if self.verbose: + log.info(f"Extracted: {member.filename}") + + except Exception as e: + log.error(f"Failed to extract {member.filename}: {e}") + + @staticmethod + def _normalize_path(path: Path) -> Path: + """ + Adjust path to handle long paths on Windows. + + :param path: Original path + :return: Normalized path + """ + if sys.platform.startswith("win"): + return Path(rf"\\?\{os.path.abspath(path)}") + return path + + +# Example usage: +# extractor = SafeZipExtractor( +# archive_path=Path("file.nupkg"), +# extract_dir=Path("extract_dir"), +# include_patterns=["*.dll", "*.xml"], +# exclude_patterns=["*.pdb"] +# ) +# extractor.extract_all() diff --git a/sync.py b/sync.py new file mode 100644 index 0000000..08cb8d5 --- /dev/null +++ b/sync.py @@ -0,0 +1,6 @@ +import os +from repo_dir_sync import LibRepo, OtherRepo + +r = LibRepo(name="serena", libDirectory="src") +r.add(OtherRepo(name="mux", branch="mux", pathToLib=os.path.join("..", "serena-multiplexer", "src-serena"))) +r.runMain() diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/test/__init__.py @@ -0,0 +1 @@ + diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..fc04627 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,529 @@ +import logging +import os +import platform +import re +import shutil as _sh +import subprocess +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from typing import Any + +import pytest +from _pytest.mark import Mark, MarkDecorator +from sensai.util.logging import configure + +from serena.config.serena_config import SerenaConfig, SerenaPaths +from serena.constants import SERENA_MANAGED_DIR_NAME +from serena.project import Project +from serena.util.file_system import GitignoreParser +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +from .solidlsp.clojure import is_clojure_cli_available +from .solidlsp.elixir import EXPERT_UNAVAILABLE +from .solidlsp.erlang import ERLANG_LS_UNAVAILABLE + +configure(level=logging.INFO) + +log = logging.getLogger(__name__) + + +@pytest.fixture(scope="session") +def resources_dir() -> Path: + """Path to the test resources directory.""" + current_dir = Path(__file__).parent + return current_dir / "resources" + + +class LanguageParamRequest: + param: Language + + +_LANGUAGE_REPO_ALIASES: dict[Language, Language] = { + Language.CPP_CCLS: Language.CPP, + Language.PHP_PHPACTOR: Language.PHP, + Language.PHP_PHPANTOM: Language.PHP, + Language.PYTHON_JEDI: Language.PYTHON, + Language.PYTHON_TY: Language.PYTHON, + Language.RUBY_SOLARGRAPH: Language.RUBY, + Language.PYTHON_TY: Language.PYTHON, + Language.PYTHON_PYREFLY: Language.PYTHON, +} + +PYTHON_LANGUAGE_BACKENDS = [Language.PYTHON, Language.PYTHON_TY] + + +def get_repo_path(language: Language) -> Path: + repo_language = _LANGUAGE_REPO_ALIASES.get(language, language) + return Path(__file__).parent / "resources" / "repos" / repo_language / "test_repo" + + +def _create_ls( + language: Language, + repo_path: str | None = None, + ignored_paths: list[str] | None = None, + trace_lsp_communication: bool = False, + ls_specific_settings: dict[Language, dict[str, Any]] | None = None, + workspace_folders: list[str] | None = None, + additional_workspace_folders: list[str] | None = None, + solidlsp_dir: Path | None = None, +) -> SolidLanguageServer: + ignored_paths = ignored_paths or [] + if repo_path is None: + repo_path = str(get_repo_path(language)) + gitignore_parser = GitignoreParser(str(repo_path)) + for spec in gitignore_parser.get_ignore_specs(): + ignored_paths.extend(spec.patterns) + config = LanguageServerConfig( + code_language=language, + ignored_paths=ignored_paths, + trace_lsp_communication=trace_lsp_communication, + workspace_folders=workspace_folders or ["."], + additional_workspace_folders=additional_workspace_folders or [], + ) + effective_solidlsp_dir = solidlsp_dir if solidlsp_dir is not None else SerenaPaths().serena_user_home_dir + project_data_path = os.path.join(repo_path, SERENA_MANAGED_DIR_NAME) + return SolidLanguageServer.create( + config, + repo_path, + solidlsp_settings=SolidLSPSettings( + solidlsp_dir=effective_solidlsp_dir, + project_data_path=project_data_path, + ls_specific_settings=ls_specific_settings or {}, + ), + ) + + +@contextmanager +def start_ls_context( + language: Language, + repo_path: str | None = None, + ignored_paths: list[str] | None = None, + trace_lsp_communication: bool = False, + ls_specific_settings: dict[Language, dict[str, Any]] | None = None, + workspace_folders: list[str] | None = None, + additional_workspace_folders: list[str] | None = None, + solidlsp_dir: Path | None = None, +) -> Iterator[SolidLanguageServer]: + ls = _create_ls( + language, + repo_path, + ignored_paths, + trace_lsp_communication, + ls_specific_settings, + workspace_folders, + additional_workspace_folders, + solidlsp_dir, + ) + log.info(f"Starting language server for {language} {repo_path}") + with ls.start_server_context(): + yield ls + + +@contextmanager +def start_default_ls_context(language: Language) -> Iterator[SolidLanguageServer]: + with start_ls_context(language) as ls: + yield ls + + +def create_default_serena_config(): + return SerenaConfig(gui_log_window=False, web_dashboard=False) + + +def _create_default_project(language: Language, repo_root_override: str | None = None) -> Project: + repo_path = str(get_repo_path(language)) if repo_root_override is None else repo_root_override + return Project.load(repo_path, serena_config=create_default_serena_config()) + + +@pytest.fixture(scope="session") +def repo_path(request: LanguageParamRequest) -> Path: + """Get the repository path for a specific language. + + This fixture requires a language parameter via pytest.mark.parametrize: + + Example: + ``` + @pytest.mark.parametrize("repo_path", [Language.PYTHON], indirect=True) + def test_python_repo(repo_path): + assert (repo_path / "src").exists() + ``` + + """ + if not hasattr(request, "param"): + raise ValueError("Language parameter must be provided via pytest.mark.parametrize") + + language = request.param + return get_repo_path(language) + + +# Note: using module scope here to avoid restarting LS for each test function but still terminate between test modules +@pytest.fixture(scope="module") +def language_server(request: LanguageParamRequest): + """Create a language server instance configured for the specified language. + + This fixture requires a language parameter via pytest.mark.parametrize: + + Example: + ``` + @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + def test_python_server(language_server: SyncLanguageServer) -> None: + # Use the Python language server + pass + ``` + + You can also test multiple languages in a single test: + ``` + @pytest.mark.parametrize("language_server", [Language.PYTHON, Language.TYPESCRIPT], indirect=True) + def test_multiple_languages(language_server: SyncLanguageServer) -> None: + # This test will run once for each language + pass + ``` + + """ + if not hasattr(request, "param"): + raise ValueError("Language parameter must be provided via pytest.mark.parametrize") + + language = request.param + with start_default_ls_context(language) as ls: + yield ls + + +@contextmanager +def project_context(language: Language, repo_root_override: str | None = None) -> Iterator[Project]: + """Context manager that creates a Project for the specified language and ensures proper cleanup.""" + project = _create_default_project(language, repo_root_override) + try: + yield project + finally: + project.shutdown(timeout=5) + + +@pytest.fixture(scope="module") +def project(request: LanguageParamRequest, repo_root_override: str | None = None) -> Iterator[Project]: + """Create a Project for the specified language. + + This fixture requires a language parameter via pytest.mark.parametrize: + + Example: + ``` + @pytest.mark.parametrize("project", [Language.PYTHON], indirect=True) + def test_python_project(project: Project) -> None: + # Use the Python project to test something + pass + ``` + + You can also test multiple languages in a single test: + ``` + @pytest.mark.parametrize("project", [Language.PYTHON, Language.TYPESCRIPT], indirect=True) + def test_multiple_languages(project: SyncLanguageServer) -> None: + # This test will run once for each language + pass + ``` + + """ + if not hasattr(request, "param"): + raise ValueError("Language parameter must be provided via pytest.mark.parametrize") + language = request.param + with project_context(language, repo_root_override) as project: + yield project + + +@contextmanager +def project_with_ls_context(language: Language, repo_root_override: str | None = None) -> Iterator[Project]: + """Context manager that creates a Project with an active language server for the specified language.""" + with project_context(language, repo_root_override) as project: + project.create_language_server_manager() + yield project + + +@pytest.fixture(scope="module") +def project_with_ls(request: LanguageParamRequest) -> Iterator[Project]: + if not hasattr(request, "param"): + raise ValueError("Language parameter must be provided via pytest.mark.parametrize") + language = request.param + with project_with_ls_context(language) as project: + yield project + + +is_ci = os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true" +""" +Flag indicating whether the tests are running in the GitHub CI environment. +""" + +is_windows = platform.system() == "Windows" +is_macos = platform.system() == "Darwin" + + +_LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = { + Language.ADA: [pytest.mark.ada], + Language.CLOJURE: [pytest.mark.clojure], + Language.CPP: [pytest.mark.cpp], + Language.CPP_CCLS: [pytest.mark.cpp], + Language.CUE: [pytest.mark.cue], + Language.CSHARP: [pytest.mark.csharp], + Language.FSHARP: [pytest.mark.fsharp], + Language.GO: [pytest.mark.go], + Language.HAXE: [pytest.mark.haxe], + Language.JAVA: [pytest.mark.java], + Language.KOTLIN: [pytest.mark.kotlin], + Language.LEAN4: [pytest.mark.lean4], + Language.LATEX: [pytest.mark.latex], + Language.MSL: [pytest.mark.msl], + Language.PHP: [pytest.mark.php], + Language.PHP_PHPACTOR: [pytest.mark.php], + Language.PHP_PHPANTOM: [pytest.mark.php], + Language.POWERSHELL: [pytest.mark.powershell], + Language.PYTHON: [pytest.mark.python], + Language.PYTHON_JEDI: [pytest.mark.python], + Language.PYTHON_TY: [pytest.mark.python], + Language.PYTHON_PYREFLY: [pytest.mark.python], + Language.RUST: [pytest.mark.rust], + Language.TYPESCRIPT: [pytest.mark.typescript], + Language.BSL: [pytest.mark.bsl], + Language.SVELTE: [pytest.mark.svelte], + Language.ANGULAR: [pytest.mark.angular], + Language.HTML: [pytest.mark.html], + Language.SCSS: [pytest.mark.scss], +} + + +def get_pytest_markers(language: Language) -> list[MarkDecorator | Mark]: + """Pytest markers for a language. + + Returns the primary language marker plus the central enablement skip derived from + ``language_tests_enabled()`` -- so per-language availability/reliability lives in exactly one + place (``_determine_disabled_languages``) instead of being duplicated per marker or per test file. + """ + return [ + *_LANGUAGE_PYTEST_MARKERS[language], + pytest.mark.skipif(not language_tests_enabled(language), reason=f"{language.value} tests are disabled in this environment"), + ] + + +def _is_perl_language_server_available() -> bool: + """ + Whether Perl::LanguageServer is installed. + + Perl itself ships with most base systems, so checking for the ``perl`` binary is not enough; + we verify that the ``Perl::LanguageServer`` module can be loaded -- which is exactly what the + Perl language server launcher requires to start. + """ + if _sh.which("perl") is None: + return False + try: + return subprocess.run(["perl", "-MPerl::LanguageServer", "-e", "1"], capture_output=True, timeout=30, check=False).returncode == 0 + except (OSError, subprocess.SubprocessError): + return False + + +def _is_matlab_available() -> bool: + """Whether a MATLAB installation can be located (env var or a known install path).""" + if os.environ.get("MATLAB_PATH") is not None: + return True + return any( + os.path.exists(p) + for p in ( + "/Applications/MATLAB_R2024b.app", + "/Applications/MATLAB_R2025b.app", + "/Volumes/S1/Applications/MATLAB_R2024b.app", + "/Volumes/S1/Applications/MATLAB_R2025b.app", + ) + ) + + +def _is_r_language_server_available() -> bool: + """Whether R *and* its ``languageserver`` package are installed. + + The R binary alone is not enough -- the language server runs as ``R -e "languageserver::run()"``, + which fails (RuntimeError) if the package is missing -- so check the package, not just ``which("R")``. + """ + if _sh.which("R") is None: + return False + try: + return ( + subprocess.run( + ["R", "--vanilla", "-e", 'quit(status = as.integer(!requireNamespace("languageserver", quietly = TRUE)))'], + capture_output=True, + timeout=60, + check=False, + ).returncode + == 0 + ) + except (OSError, subprocess.SubprocessError): + return False + + +def _is_ocaml_lsp_available() -> bool: + """Whether opam *and* the ``ocaml-lsp-server`` (``ocamllsp``) are installed. + + opam alone is not enough -- the language server is launched via ``opam exec -- ocamllsp`` and + raises if the package is missing -- so verify ocamllsp resolves in the active switch. + """ + if _sh.which("opam") is None: + return False + try: + return ( + subprocess.run( + ["opam", "exec", "--", "ocamllsp", "--version"], + capture_output=True, + timeout=60, + check=False, + ).returncode + == 0 + ) + except (OSError, subprocess.SubprocessError): + return False + + +def _determine_disabled_languages() -> list[Language]: + """ + Determine which language tests are disabled in the current environment. + + Every language falls into exactly ONE of the categories below; a language that is not appended + here is **category 4 (enabled everywhere)**, e.g. python, typescript, go, java, kotlin-locally. + + 1. ALWAYS DISABLED -- flaky/broken; not worth running anywhere. + 2. DISABLED OFF-CI when a precondition (toolchain/LS) is missing, but EXPECTED ON CI -- guarded + with ``and not is_ci`` so a missing tool *on CI* fails loudly (catches a CI setup regression) + instead of silently skipping. + 3. DISABLED WHEREVER the precondition is missing, INCLUDING on CI -- the precondition may or may + not be provided on CI (e.g. via the maximal container, see Dockerfile.maximal); if it isn't, + the tests just skip gracefully rather than fail. + 4. ENABLED EVERYWHERE -- not listed in this function at all. + 5. DISABLED ONLY ON CI (resource/stability reasons) even though the precondition holds locally. + """ + result: list[Language] = [] + + # === 1. Always disabled (flaky / broken everywhere) === + result.append(Language.BSL) # 1C:Enterprise; niche and the tests are slow and flaky + result.append(Language.FSHARP) # F# language server is currently unreliable + + # === 2. Disabled off-CI if the precondition is missing; expected to be present on CI === + if _sh.which("terraform") is None and not is_ci: + result.append(Language.TERRAFORM) + if _sh.which("regal") is None and not is_ci: + result.append(Language.REGO) + if _sh.which("elm") is None and not is_ci: + result.append(Language.ELM) + + # === 3. Disabled wherever the precondition is missing (including on CI) === + # 3a. Platform precondition: these language servers have no native Windows support. + if is_windows: + result.append(Language.ANSIBLE) # ansible-language-server has no native Windows support + if not is_macos: + result.append(Language.SWIFT) # swiftly toolchain is only set up on the macOS native batch + # 3b. Toolchain / language-server availability (the LS/compiler must be on PATH or installed). + if _sh.which("clangd") is None: + result.append(Language.CPP) + if _sh.which("ccls") is None or is_windows: # no recent ccls binary is available for Windows + result.append(Language.CPP_CCLS) + if _sh.which("php") is None: + result.append(Language.PHP_PHPACTOR) + result.append(Language.PHP_PHPANTOM) + if not is_clojure_cli_available(): + result.append(Language.CLOJURE) + if _sh.which("verible-verilog-ls") is None: + result.append(Language.SYSTEMVERILOG) + if not _is_matlab_available(): + result.append(Language.MATLAB) + if ERLANG_LS_UNAVAILABLE: # no Erlang-OTP / no rebar3 / Windows -- see test/solidlsp/erlang + result.append(Language.ERLANG) + if EXPERT_UNAVAILABLE: # Elixir not installed -- see test/solidlsp/elixir + result.append(Language.ELIXIR) + if _sh.which("lean") is None: + result.append(Language.LEAN4) + if _sh.which("crystalline") is None: + result.append(Language.CRYSTAL) + if _sh.which("julia") is None: # LanguageServer.jl is auto-installed by the LS when julia is present + result.append(Language.JULIA) + if _sh.which("nixd") is None: + result.append(Language.NIX) + if _sh.which("haskell-language-server-wrapper") is None: + result.append(Language.HASKELL) + if not _is_r_language_server_available(): # `which("R")` isn't enough -- needs the languageserver package + result.append(Language.R) + if not _is_ocaml_lsp_available(): # opam alone isn't enough -- needs the ocaml-lsp-server package + result.append(Language.OCAML) + if not _is_perl_language_server_available(): # perl ships with the OS; the LS module is the real signal + result.append(Language.PERL) + + # === 4. Enabled everywhere: every language NOT listed in this function (python, go, java, ...) === + + # === 5. Disabled only on CI (works locally; too unstable/costly on the CI runners) === + if is_ci: + result.append(Language.KOTLIN) # IntelliJ-based Kotlin LSP crashes on JVM restart under CI memory limits + + return result + + +_disabled_languages = _determine_disabled_languages() + + +def language_tests_enabled(language: Language) -> bool: + """ + Check if tests for the given language are enabled in the current environment. + + :param language: the language to check + :return: True if tests for the language are enabled, False otherwise + """ + return language not in _disabled_languages + + +def language_supports_implementation(language: Language) -> bool: + return language.supports_implementation_request() + + +def languages_supporting_implementation(*languages: Language) -> list[Language]: + return [language for language in languages if language_supports_implementation(language)] + + +_VERIFIED_IMPLEMENTATION_LANGUAGES = { + Language.ANGULAR, + Language.CSHARP, + Language.GO, + Language.JAVA, + Language.RUST, + Language.TYPESCRIPT, +} + + +def language_has_verified_implementation_support(language: Language) -> bool: + """ + True only for languages where the server advertises implementation support and + the repo fixtures contain a verified working go-to-implementation scenario. + """ + return language in _VERIFIED_IMPLEMENTATION_LANGUAGES and language_supports_implementation(language) + + +def find_identifier_position(file_path: Path, identifier: str) -> tuple[int, int] | None: + pattern = re.compile(r"\b" + re.escape(identifier) + r"\b") + with file_path.open(encoding="utf-8") as f: + for line_idx, line in enumerate(f): + match = pattern.search(line) + if match: + return line_idx, match.start() + return None + + +def find_identifier_pos( + file_path: Path, + identifier: str, + occurrence_index: int = 0, + column_offset: int = 0, +) -> tuple[int, int] | None: + if occurrence_index < 0: + raise ValueError("occurrence_index must be non-negative") + if column_offset < 0: + raise ValueError("column_offset must be non-negative") + + pattern = re.compile(r"\b" + re.escape(identifier) + r"\b") + current_index = 0 + with file_path.open(encoding="utf-8") as f: + for line_idx, line in enumerate(f): + for match in pattern.finditer(line): + if current_index == occurrence_index: + return line_idx, match.start() + column_offset + current_index += 1 + return None diff --git a/test/resources/repos/ada/test_repo/.gitignore b/test/resources/repos/ada/test_repo/.gitignore new file mode 100644 index 0000000..5aff2b5 --- /dev/null +++ b/test/resources/repos/ada/test_repo/.gitignore @@ -0,0 +1,3 @@ +obj/ +*.ali +*.o diff --git a/test/resources/repos/ada/test_repo/default.gpr b/test/resources/repos/ada/test_repo/default.gpr new file mode 100644 index 0000000..07dbd6e --- /dev/null +++ b/test/resources/repos/ada/test_repo/default.gpr @@ -0,0 +1,7 @@ +project Default is + + for Source_Dirs use ("src"); + for Object_Dir use "obj"; + for Main use ("main.adb"); + +end Default; diff --git a/test/resources/repos/ada/test_repo/src/diagnostics_sample.adb b/test/resources/repos/ada/test_repo/src/diagnostics_sample.adb new file mode 100644 index 0000000..eb6c70b --- /dev/null +++ b/test/resources/repos/ada/test_repo/src/diagnostics_sample.adb @@ -0,0 +1,6 @@ +-- Intentionally invalid Ada — exercises the LSP diagnostics path. +-- The procedure body is missing the trailing `end Diagnostics_Sample;` token, +-- which libadalang flags as a syntax error regardless of project setup. +procedure Diagnostics_Sample is +begin + null diff --git a/test/resources/repos/ada/test_repo/src/helper.adb b/test/resources/repos/ada/test_repo/src/helper.adb new file mode 100644 index 0000000..52e818f --- /dev/null +++ b/test/resources/repos/ada/test_repo/src/helper.adb @@ -0,0 +1,9 @@ +package body Helper is + + function Greet (Name : String) return String is + Prefix : constant String := "Hello, "; + begin + return Prefix & Name & "!"; + end Greet; + +end Helper; diff --git a/test/resources/repos/ada/test_repo/src/helper.ads b/test/resources/repos/ada/test_repo/src/helper.ads new file mode 100644 index 0000000..63b9bf6 --- /dev/null +++ b/test/resources/repos/ada/test_repo/src/helper.ads @@ -0,0 +1,7 @@ +package Helper is + + type Greeting_Style is (Friendly, Formal); + + function Greet (Name : String) return String; + +end Helper; diff --git a/test/resources/repos/ada/test_repo/src/main.adb b/test/resources/repos/ada/test_repo/src/main.adb new file mode 100644 index 0000000..2cd3487 --- /dev/null +++ b/test/resources/repos/ada/test_repo/src/main.adb @@ -0,0 +1,8 @@ +with Ada.Text_IO; +with Helper; + +procedure Main is + Greeting : constant String := Helper.Greet ("Ada"); +begin + Ada.Text_IO.Put_Line (Greeting); +end Main; diff --git a/test/resources/repos/al/test_repo/app.json b/test/resources/repos/al/test_repo/app.json new file mode 100644 index 0000000..d797c8d --- /dev/null +++ b/test/resources/repos/al/test_repo/app.json @@ -0,0 +1,31 @@ +{ + "id": "00000001-0000-0000-0000-000000000001", + "name": "Test AL Project", + "publisher": "Serena Test Publisher", + "version": "1.0.0.0", + "brief": "Test project for AL Language Server in Serena", + "description": "This project contains AL code samples for testing language server features", + "privacyStatement": "", + "EULA": "", + "help": "", + "url": "https://github.com/oraios/serena", + "logo": "", + "dependencies": [], + "screenshots": [], + "platform": "1.0.0.0", + "application": "26.0.0.0", + "idRanges": [ + { + "from": 50000, + "to": 50100 + } + ], + "resourceExposurePolicy": { + "allowDebugging": true, + "allowDownloadingSource": true, + "includeSourceInSymbolFile": true + }, + "runtime": "15.0", + "features": ["NoImplicitWith"], + "target": "Cloud" +} \ No newline at end of file diff --git a/test/resources/repos/al/test_repo/src/Codeunits/CustomerMgt.Codeunit.al b/test/resources/repos/al/test_repo/src/Codeunits/CustomerMgt.Codeunit.al new file mode 100644 index 0000000..d7c8f46 --- /dev/null +++ b/test/resources/repos/al/test_repo/src/Codeunits/CustomerMgt.Codeunit.al @@ -0,0 +1,199 @@ +codeunit 50000 CustomerMgt +{ + Permissions = tabledata "TEST Customer" = rimd; + + trigger OnRun() + begin + Message('Customer Management Codeunit'); + end; + + procedure CreateNewCustomer() + var + Customer: Record "TEST Customer"; + CustomerCard: Page "TEST Customer Card"; + begin + Customer.Init(); + Customer.Insert(true); + + CustomerCard.SetRecord(Customer); + CustomerCard.Run(); + end; + + procedure CreateCustomer(CustomerNo: Code[20]; CustomerName: Text[100]; CustomerType: Enum CustomerType): Boolean + var + Customer: Record "TEST Customer"; + begin + if Customer.Get(CustomerNo) then + exit(false); + + Customer.Init(); + Customer."No." := CustomerNo; + Customer.Name := CustomerName; + Customer."Customer Type" := CustomerType; + Customer.UpdateSearchName(); + Customer.Insert(true); + + exit(true); + end; + + procedure AssistEdit(var Customer: Record "TEST Customer"): Boolean + var + NoSeriesMgt: Codeunit NoSeriesManagement; + begin + with Customer do begin + if NoSeriesMgt.SelectSeries(GetNoSeriesCode(), '', "No. Series") then begin + NoSeriesMgt.SetSeries("No."); + exit(true); + end; + end; + exit(false); + end; + + procedure TestNoSeries() + var + SalesSetup: Record "Sales & Receivables Setup"; + begin + SalesSetup.Get(); + SalesSetup.TestField("Customer Nos."); + end; + + procedure InitNo(var Customer: Record "TEST Customer") + var + NoSeriesMgt: Codeunit NoSeriesManagement; + begin + TestNoSeries(); + NoSeriesMgt.InitSeries(GetNoSeriesCode(), Customer."No. Series", 0D, Customer."No.", Customer."No. Series"); + end; + + procedure GetNoSeriesCode(): Code[20] + var + SalesSetup: Record "Sales & Receivables Setup"; + begin + SalesSetup.Get(); + exit(SalesSetup."Customer Nos."); + end; + + procedure CalculateTotalBalance(): Decimal + var + Customer: Record "TEST Customer"; + TotalBalance: Decimal; + begin + TotalBalance := 0; + + if Customer.FindSet() then + repeat + Customer.CalcFields(Balance); + TotalBalance += Customer.Balance; + until Customer.Next() = 0; + + exit(TotalBalance); + end; + + procedure GetCustomerCount(CustomerType: Enum CustomerType): Integer + var + Customer: Record "TEST Customer"; + begin + Customer.SetRange("Customer Type", CustomerType); + exit(Customer.Count()); + end; + + procedure BlockCustomersOverCreditLimit() + var + Customer: Record "TEST Customer"; + BlockedCount: Integer; + begin + BlockedCount := 0; + + if Customer.FindSet() then + repeat + Customer.CalcFields(Balance); + if (Customer."Credit Limit" > 0) and (Customer.Balance > Customer."Credit Limit") then begin + Customer.Blocked := true; + Customer.Modify(true); + BlockedCount += 1; + end; + until Customer.Next() = 0; + + if BlockedCount > 0 then + Message('%1 customers blocked due to credit limit exceeded', BlockedCount); + end; + + procedure GetPaymentProcessor(): Interface IPaymentProcessor + var + PaymentProcessorImpl: Codeunit PaymentProcessorImpl; + begin + exit(PaymentProcessorImpl); + end; + + procedure SendCustomerStatement(CustomerNo: Code[20]) + var + Customer: Record "TEST Customer"; + ReportSelections: Record "Report Selections"; + begin + if not Customer.Get(CustomerNo) then + Error('Customer %1 not found', CustomerNo); + + Customer.SetRecFilter(); + ReportSelections.SetRange(Usage, ReportSelections.Usage::"C.Statement"); + ReportSelections.PrintForCust(ReportSelections.Usage::"C.Statement", Customer, 1); + end; + + procedure ValidateEmail(Email: Text[80]): Boolean + var + MailMgt: Codeunit "Mail Management"; + begin + exit(MailMgt.CheckValidEmailAddress(Email)); + end; + + procedure MergeCustomers(FromCustomerNo: Code[20]; ToCustomerNo: Code[20]) + var + FromCustomer: Record "TEST Customer"; + ToCustomer: Record "TEST Customer"; + CustomerLedgerEntry: Record "Cust. Ledger Entry"; + begin + if not FromCustomer.Get(FromCustomerNo) then + Error('Source customer %1 not found', FromCustomerNo); + + if not ToCustomer.Get(ToCustomerNo) then + Error('Target customer %1 not found', ToCustomerNo); + + // Transfer ledger entries + CustomerLedgerEntry.SetRange("Customer No.", FromCustomerNo); + if CustomerLedgerEntry.FindSet() then + repeat + CustomerLedgerEntry."Customer No." := ToCustomerNo; + CustomerLedgerEntry.Modify(); + until CustomerLedgerEntry.Next() = 0; + + // Delete source customer + FromCustomer.Delete(true); + + Message('Customer %1 merged into %2', FromCustomerNo, ToCustomerNo); + end; + + [EventSubscriber(ObjectType::Table, Database::"TEST Customer", OnAfterInsertEvent, '', true, true)] + local procedure OnAfterInsertCustomer(var Rec: Record "TEST Customer") + begin + LogCustomerChange(Rec, 'INSERT'); + end; + + [EventSubscriber(ObjectType::Table, Database::"TEST Customer", OnAfterModifyEvent, '', true, true)] + local procedure OnAfterModifyCustomer(var Rec: Record "TEST Customer") + begin + LogCustomerChange(Rec, 'MODIFY'); + end; + + local procedure LogCustomerChange(Customer: Record "TEST Customer"; ChangeType: Text[10]) + var + ChangeLogEntry: Record "Change Log Entry"; + begin + // Log customer changes for audit purposes + ChangeLogEntry.Init(); + ChangeLogEntry."Table No." := Database::"TEST Customer"; + ChangeLogEntry."Primary Key Field 1 Value" := Customer."No."; + ChangeLogEntry."Type of Change" := ChangeLogEntry."Type of Change"::Modification; + ChangeLogEntry."User ID" := UserId; + ChangeLogEntry."Date and Time" := CurrentDateTime; + if ChangeLogEntry.Insert() then; + end; +} \ No newline at end of file diff --git a/test/resources/repos/al/test_repo/src/Codeunits/PaymentProcessorImpl.Codeunit.al b/test/resources/repos/al/test_repo/src/Codeunits/PaymentProcessorImpl.Codeunit.al new file mode 100644 index 0000000..16be0a0 --- /dev/null +++ b/test/resources/repos/al/test_repo/src/Codeunits/PaymentProcessorImpl.Codeunit.al @@ -0,0 +1,73 @@ +codeunit 50001 PaymentProcessorImpl implements IPaymentProcessor +{ + procedure ProcessPayment(Customer: Record "TEST Customer"): Boolean + var + //PaymentEntry: Record "Payment Entry"; + begin + // Implementation of payment processing + Customer.CalcFields(Balance); + + if Customer.Balance <= 0 then + exit(false); + + // PaymentEntry.Init(); + // PaymentEntry."Customer No." := Customer."No."; + // PaymentEntry.Amount := Customer.Balance; + // PaymentEntry."Payment Date" := Today(); + // PaymentEntry.Status := PaymentEntry.Status::Processed; + + // if PaymentEntry.Insert(true) then begin + // Message('Payment processed for customer %1', Customer.Name); + // exit(true); + // end; + + exit(false); + end; + + procedure ValidatePaymentMethod(PaymentMethodCode: Code[10]): Boolean + var + PaymentMethod: Record "Payment Method"; + begin + if PaymentMethodCode = '' then + exit(false); + + exit(PaymentMethod.Get(PaymentMethodCode)); + end; + + procedure GetTransactionFee(Amount: Decimal): Decimal + var + FeePercentage: Decimal; + MinimumFee: Decimal; + begin + FeePercentage := 2.9; // 2.9% transaction fee + MinimumFee := 0.30; // Minimum fee + + exit(Maximum(Amount * FeePercentage / 100, MinimumFee)); + end; + + procedure RefundPayment(TransactionID: Text[50]): Boolean + var + // PaymentEntry: Record "Payment Entry"; + begin + // PaymentEntry.SetRange("Transaction ID", TransactionID); + + // if PaymentEntry.FindFirst() then begin + // PaymentEntry.Status := PaymentEntry.Status::Refunded; + // PaymentEntry."Refund Date" := Today(); + // PaymentEntry.Modify(true); + + // Message('Payment refunded for transaction %1', TransactionID); + // exit(true); + // end; + + Error('Transaction %1 not found', TransactionID); + end; + + local procedure Maximum(Value1: Decimal; Value2: Decimal): Decimal + begin + if Value1 > Value2 then + exit(Value1) + else + exit(Value2); + end; +} \ No newline at end of file diff --git a/test/resources/repos/al/test_repo/src/Enums/CustomerType.Enum.al b/test/resources/repos/al/test_repo/src/Enums/CustomerType.Enum.al new file mode 100644 index 0000000..2eaee49 --- /dev/null +++ b/test/resources/repos/al/test_repo/src/Enums/CustomerType.Enum.al @@ -0,0 +1,35 @@ +enum 50000 CustomerType +{ + Extensible = true; + Caption = 'Customer Type'; + + value(0; "") + { + Caption = ''; + } + + value(1; Regular) + { + Caption = 'Regular'; + } + + value(2; Premium) + { + Caption = 'Premium'; + } + + value(3; VIP) + { + Caption = 'VIP'; + } + + value(4; Corporate) + { + Caption = 'Corporate'; + } + + value(5; Government) + { + Caption = 'Government'; + } +} \ No newline at end of file diff --git a/test/resources/repos/al/test_repo/src/Interfaces/IPaymentProcessor.Interface.al b/test/resources/repos/al/test_repo/src/Interfaces/IPaymentProcessor.Interface.al new file mode 100644 index 0000000..d518846 --- /dev/null +++ b/test/resources/repos/al/test_repo/src/Interfaces/IPaymentProcessor.Interface.al @@ -0,0 +1,7 @@ +interface IPaymentProcessor +{ + procedure ProcessPayment(Customer: Record "TEST Customer"): Boolean; + procedure ValidatePaymentMethod(PaymentMethodCode: Code[10]): Boolean; + procedure GetTransactionFee(Amount: Decimal): Decimal; + procedure RefundPayment(TransactionID: Text[50]): Boolean; +} \ No newline at end of file diff --git a/test/resources/repos/al/test_repo/src/Pages/CustomerCard.Page.al b/test/resources/repos/al/test_repo/src/Pages/CustomerCard.Page.al new file mode 100644 index 0000000..3f266ef --- /dev/null +++ b/test/resources/repos/al/test_repo/src/Pages/CustomerCard.Page.al @@ -0,0 +1,271 @@ +page 50001 "TEST Customer Card" +{ + Caption = 'Customer Card'; + PageType = Card; + SourceTable = "TEST Customer"; + RefreshOnActivate = true; + + layout + { + area(Content) + { + group(General) + { + Caption = 'General'; + + field("No."; Rec."No.") + { + ApplicationArea = All; + ToolTip = 'Specifies the customer number.'; + + trigger OnAssistEdit() + begin + if CustomerMgt.AssistEdit(Rec) then + CurrPage.Update(); + end; + } + + field(Name; Rec.Name) + { + ApplicationArea = All; + ToolTip = 'Specifies the customer name.'; + ShowMandatory = true; + } + + field("Search Name"; Rec."Search Name") + { + ApplicationArea = All; + ToolTip = 'Specifies the search name.'; + Visible = false; + } + + field("Customer Type"; Rec."Customer Type") + { + ApplicationArea = All; + ToolTip = 'Specifies the type of customer.'; + } + + field(Blocked; Rec.Blocked) + { + ApplicationArea = All; + ToolTip = 'Specifies if the customer is blocked.'; + } + + field("Last Date Modified"; Rec."Last Date Modified") + { + ApplicationArea = All; + ToolTip = 'Specifies when the customer was last modified.'; + Editable = false; + } + } + + group(AddressAndContact) + { + Caption = 'Address & Contact'; + + field(Address; Rec.Address) + { + ApplicationArea = All; + ToolTip = 'Specifies the customer address.'; + } + + field("Address 2"; Rec."Address 2") + { + ApplicationArea = All; + ToolTip = 'Specifies additional address information.'; + } + + field(City; Rec.City) + { + ApplicationArea = All; + ToolTip = 'Specifies the city.'; + } + + field("Phone No."; Rec."Phone No.") + { + ApplicationArea = All; + ToolTip = 'Specifies the phone number.'; + } + + field("E-Mail"; Rec."E-Mail") + { + ApplicationArea = All; + ToolTip = 'Specifies the email address.'; + ExtendedDatatype = EMail; + } + } + + group(Invoicing) + { + Caption = 'Invoicing'; + + field("Credit Limit"; Rec."Credit Limit") + { + ApplicationArea = All; + ToolTip = 'Specifies the credit limit.'; + } + + field(Balance; Rec.Balance) + { + ApplicationArea = All; + ToolTip = 'Specifies the customer balance.'; + DrillDownPageId = "Customer Ledger Entries"; + } + + field("Payment Terms Code"; Rec."Payment Terms Code") + { + ApplicationArea = All; + ToolTip = 'Specifies the payment terms.'; + } + + field("Currency Code"; Rec."Currency Code") + { + ApplicationArea = All; + ToolTip = 'Specifies the currency code.'; + } + } + } + + area(FactBoxes) + { + part(CustomerPicture; "Customer Picture") + { + ApplicationArea = All; + SubPageLink = "No." = field("No."); + } + + systempart(Links; Links) + { + ApplicationArea = RecordLinks; + } + + systempart(Notes; Notes) + { + ApplicationArea = Notes; + } + } + } + + actions + { + area(Navigation) + { + group(Customer) + { + Caption = '&Customer'; + + action(LedgerEntries) + { + ApplicationArea = All; + Caption = 'Ledger E&ntries'; + Image = CustomerLedger; + RunObject = page "Customer Ledger Entries"; + RunPageLink = "Customer No." = field("No."); + RunPageView = sorting("Customer No."); + ShortcutKey = 'Ctrl+F7'; + ToolTip = 'View the history of transactions for the customer.'; + } + + action(Statistics) + { + ApplicationArea = All; + Caption = 'Statistics'; + Image = Statistics; + RunObject = page "Customer Statistics"; + RunPageLink = "No." = field("No."); + ShortcutKey = 'F7'; + ToolTip = 'View statistical information about the customer.'; + } + } + } + + area(Processing) + { + group(Functions) + { + Caption = 'F&unctions'; + + action(CheckCreditLimit) + { + ApplicationArea = All; + Caption = 'Check Credit Limit'; + Image = Check; + ToolTip = 'Check if the customer has exceeded their credit limit.'; + + trigger OnAction() + begin + Rec.CheckCreditLimit(); + end; + } + + action(ProcessPayment) + { + ApplicationArea = All; + Caption = 'Process Payment'; + Image = Payment; + ToolTip = 'Process a payment for this customer.'; + + trigger OnAction() + var + PaymentProcessor: Interface IPaymentProcessor; + begin + PaymentProcessor := CustomerMgt.GetPaymentProcessor(); + PaymentProcessor.ProcessPayment(Rec); + end; + } + } + } + + area(Promoted) + { + group(Category_Process) + { + Caption = 'Process'; + + actionref(CheckCreditLimit_Promoted; CheckCreditLimit) + { + } + + actionref(ProcessPayment_Promoted; ProcessPayment) + { + } + } + + group(Category_Customer) + { + Caption = 'Customer'; + + actionref(Statistics_Promoted; Statistics) + { + } + + actionref(LedgerEntries_Promoted; LedgerEntries) + { + } + } + } + } + + var + CustomerMgt: Codeunit CustomerMgt; + + trigger OnOpenPage() + begin + Rec.SetRange("Customer Type"); + end; + + trigger OnAfterGetRecord() + begin + CheckCreditStatus(); + end; + + local procedure CheckCreditStatus() + begin + if Rec."Credit Limit" = 0 then + exit; + + Rec.CalcFields(Balance); + if Rec.Balance > Rec."Credit Limit" then + Message('Warning: Customer has exceeded credit limit'); + end; +} \ No newline at end of file diff --git a/test/resources/repos/al/test_repo/src/Pages/CustomerList.Page.al b/test/resources/repos/al/test_repo/src/Pages/CustomerList.Page.al new file mode 100644 index 0000000..15b5046 --- /dev/null +++ b/test/resources/repos/al/test_repo/src/Pages/CustomerList.Page.al @@ -0,0 +1,205 @@ +page 50002 "TEST Customer List" +{ + Caption = 'Customer List'; + PageType = List; + ApplicationArea = All; + UsageCategory = Lists; + SourceTable = "TEST Customer"; + CardPageId = "TEST Customer Card"; + Editable = false; + + layout + { + area(Content) + { + repeater(Group) + { + field("No."; Rec."No.") + { + ApplicationArea = All; + ToolTip = 'Specifies the customer number.'; + } + + field(Name; Rec.Name) + { + ApplicationArea = All; + ToolTip = 'Specifies the customer name.'; + } + + field(City; Rec.City) + { + ApplicationArea = All; + ToolTip = 'Specifies the city.'; + } + + field("Customer Type"; Rec."Customer Type") + { + ApplicationArea = All; + ToolTip = 'Specifies the type of customer.'; + } + + field("Phone No."; Rec."Phone No.") + { + ApplicationArea = All; + ToolTip = 'Specifies the phone number.'; + } + + field("E-Mail"; Rec."E-Mail") + { + ApplicationArea = All; + ToolTip = 'Specifies the email address.'; + } + + field(Balance; Rec.Balance) + { + ApplicationArea = All; + ToolTip = 'Specifies the customer balance.'; + StyleExpr = BalanceStyleExpr; + } + + field("Credit Limit"; Rec."Credit Limit") + { + ApplicationArea = All; + ToolTip = 'Specifies the credit limit.'; + } + + field(Blocked; Rec.Blocked) + { + ApplicationArea = All; + ToolTip = 'Specifies if the customer is blocked.'; + } + } + } + + area(FactBoxes) + { + systempart(Links; Links) + { + ApplicationArea = RecordLinks; + } + + systempart(Notes; Notes) + { + ApplicationArea = Notes; + } + } + } + + actions + { + area(Processing) + { + action(NewCustomer) + { + ApplicationArea = All; + Caption = 'New'; + Image = NewCustomer; + ToolTip = 'Create a new customer.'; + + trigger OnAction() + begin + CustomerMgt.CreateNewCustomer(); + end; + } + + action(ExportToExcel) + { + ApplicationArea = All; + Caption = 'Export to Excel'; + Image = ExportToExcel; + ToolTip = 'Export the customer list to Excel.'; + + trigger OnAction() + begin + ExportCustomersToExcel(); + end; + } + } + + area(Navigation) + { + action(ViewStatistics) + { + ApplicationArea = All; + Caption = 'Statistics'; + Image = Statistics; + RunObject = page "Customer Statistics"; + RunPageLink = "No." = field("No."); + ToolTip = 'View customer statistics.'; + } + } + + area(Promoted) + { + group(Category_New) + { + Caption = 'New'; + + actionref(NewCustomer_Promoted; NewCustomer) + { + } + } + + group(Category_Process) + { + Caption = 'Process'; + + actionref(ExportToExcel_Promoted; ExportToExcel) + { + } + } + } + } + + trigger OnAfterGetRecord() + begin + SetBalanceStyle(); + end; + + var + CustomerMgt: Codeunit CustomerMgt; + BalanceStyleExpr: Text; + + local procedure SetBalanceStyle() + begin + BalanceStyleExpr := ''; + + Rec.CalcFields(Balance); + if (Rec."Credit Limit" <> 0) and (Rec.Balance > Rec."Credit Limit") then + BalanceStyleExpr := 'Unfavorable'; + end; + + local procedure ExportCustomersToExcel() + var + ExcelBuffer: Record "Excel Buffer" temporary; + RowNo: Integer; + begin + ExcelBuffer.Reset(); + ExcelBuffer.DeleteAll(); + + // Add headers + RowNo := 1; + ExcelBuffer.AddColumn('Customer No.', false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text); + ExcelBuffer.AddColumn('Name', false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text); + ExcelBuffer.AddColumn('City', false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text); + ExcelBuffer.AddColumn('Balance', false, '', false, false, false, '', ExcelBuffer."Cell Type"::Number); + ExcelBuffer.NewRow(); + + // Add data + if rec.FindSet() then + repeat + RowNo += 1; + rec.CalcFields(Balance); + ExcelBuffer.AddColumn(rec."No.", false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text); + ExcelBuffer.AddColumn(rec.Name, false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text); + ExcelBuffer.AddColumn(rec.City, false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text); + ExcelBuffer.AddColumn(rec.Balance, false, '', false, false, false, '', ExcelBuffer."Cell Type"::Number); + ExcelBuffer.NewRow(); + until rec.Next() = 0; + + ExcelBuffer.CreateNewBook('Customers'); + ExcelBuffer.WriteSheet('Customer List', CompanyName, UserId); + ExcelBuffer.CloseBook(); + ExcelBuffer.OpenExcel(); + end; +} \ No newline at end of file diff --git a/test/resources/repos/al/test_repo/src/TableExtensions/Item.TableExt.al b/test/resources/repos/al/test_repo/src/TableExtensions/Item.TableExt.al new file mode 100644 index 0000000..3d9eeef --- /dev/null +++ b/test/resources/repos/al/test_repo/src/TableExtensions/Item.TableExt.al @@ -0,0 +1,79 @@ +tableextension 50000 ItemExt extends Item +{ + fields + { + field(50000; "Customer No."; Code[20]) + { + Caption = 'Preferred Customer No.'; + DataClassification = CustomerContent; + TableRelation = "TEST Customer"; + + trigger OnValidate() + var + Customer: Record "TEST Customer"; + begin + if "Customer No." <> '' then begin + Customer.Get("Customer No."); + "Customer Name" := Customer.Name; + end else + "Customer Name" := ''; + end; + } + + field(50001; "Customer Name"; Text[100]) + { + Caption = 'Preferred Customer Name'; + DataClassification = CustomerContent; + Editable = false; + } + + field(50002; "Special Discount %"; Decimal) + { + Caption = 'Special Discount %'; + DataClassification = CustomerContent; + MinValue = 0; + MaxValue = 100; + } + + field(50003; "Last Sale Date"; Date) + { + Caption = 'Last Sale Date'; + DataClassification = CustomerContent; + Editable = false; + } + + field(50004; "Total Sales Qty"; Decimal) + { + Caption = 'Total Sales Quantity'; + FieldClass = FlowField; + CalcFormula = sum("Sales Line".Quantity where("No." = field("No."), + Type = const(Item))); + Editable = false; + } + } + + keys + { + key(CustomerKey; "Customer No.") + { + } + } + + procedure UpdateLastSaleDate() + begin + "Last Sale Date" := Today(); + Modify(); + end; + + procedure GetSpecialPrice(Customer: Record "TEST Customer"): Decimal + var + BasePrice: Decimal; + begin + BasePrice := "Unit Price"; + + if "Customer No." = Customer."No." then + BasePrice := BasePrice * (1 - "Special Discount %" / 100); + + exit(BasePrice); + end; +} \ No newline at end of file diff --git a/test/resources/repos/al/test_repo/src/Tables/Customer.Table.al b/test/resources/repos/al/test_repo/src/Tables/Customer.Table.al new file mode 100644 index 0000000..a6c9043 --- /dev/null +++ b/test/resources/repos/al/test_repo/src/Tables/Customer.Table.al @@ -0,0 +1,210 @@ +table 50000 "TEST Customer" +{ + Caption = 'Customer'; + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) + { + Caption = 'No.'; + DataClassification = CustomerContent; + + trigger OnValidate() + begin + if "No." <> xRec."No." then begin + CustomerMgt.TestNoSeries(); + "No. Series" := ''; + end; + end; + } + + field(2; Name; Text[100]) + { + Caption = 'Name'; + DataClassification = CustomerContent; + + trigger OnValidate() + begin + if Name <> xRec.Name then + UpdateSearchName(); + end; + } + + field(3; "Search Name"; Code[100]) + { + Caption = 'Search Name'; + DataClassification = CustomerContent; + } + + field(4; Address; Text[100]) + { + Caption = 'Address'; + DataClassification = CustomerContent; + } + + field(5; "Address 2"; Text[50]) + { + Caption = 'Address 2'; + DataClassification = CustomerContent; + } + + field(6; City; Text[30]) + { + Caption = 'City'; + DataClassification = CustomerContent; + } + + field(7; "Phone No."; Text[30]) + { + Caption = 'Phone No.'; + DataClassification = CustomerContent; + } + + field(8; "E-Mail"; Text[80]) + { + Caption = 'E-Mail'; + DataClassification = CustomerContent; + + trigger OnValidate() + var + MailMgt: Codeunit "Mail Management"; + begin + MailMgt.CheckValidEmailAddresses("E-Mail"); + end; + } + + field(10; "Customer Type"; Enum CustomerType) + { + Caption = 'Customer Type'; + DataClassification = CustomerContent; + } + + field(11; Balance; Decimal) + { + Caption = 'Balance'; + Editable = false; + FieldClass = FlowField; + CalcFormula = sum("Cust. Ledger Entry".Amount where("Customer No." = field("No."))); + } + + field(12; "Credit Limit"; Decimal) + { + Caption = 'Credit Limit'; + DataClassification = CustomerContent; + } + + field(13; Blocked; Boolean) + { + Caption = 'Blocked'; + DataClassification = CustomerContent; + } + + field(14; "Last Date Modified"; Date) + { + Caption = 'Last Date Modified'; + DataClassification = CustomerContent; + Editable = false; + } + + field(15; "No. Series"; Code[20]) + { + Caption = 'No. Series'; + DataClassification = CustomerContent; + } + + field(20; "Payment Terms Code"; Code[10]) + { + Caption = 'Payment Terms Code'; + DataClassification = CustomerContent; + TableRelation = "Payment Terms"; + } + + field(21; "Currency Code"; Code[10]) + { + Caption = 'Currency Code'; + DataClassification = CustomerContent; + TableRelation = Currency; + } + } + + keys + { + key(PK; "No.") + { + Clustered = true; + } + + key(SearchName; "Search Name") + { + } + + key(CustomerType; "Customer Type", City) + { + } + } + + fieldgroups + { + fieldgroup(DropDown; "No.", Name, City) + { + } + + fieldgroup(Brick; "No.", Name, Balance) + { + } + } + + trigger OnInsert() + begin + if "No." = '' then begin + CustomerMgt.TestNoSeries(); + CustomerMgt.InitNo(Rec); + end; + + "Last Date Modified" := Today(); + end; + + trigger OnModify() + begin + "Last Date Modified" := Today(); + end; + + trigger OnDelete() + var + CustomerLedgerEntry: Record "Cust. Ledger Entry"; + begin + CustomerLedgerEntry.SetRange("Customer No.", "No."); + if not CustomerLedgerEntry.IsEmpty() then + Error('Cannot delete customer %1 with ledger entries', "No."); + end; + + trigger OnRename() + begin + "Last Date Modified" := Today(); + end; + + var + CustomerMgt: Codeunit CustomerMgt; + + procedure UpdateSearchName() + begin + "Search Name" := UpperCase(Name); + end; + + procedure CheckCreditLimit() + var + CreditLimitExceeded: Boolean; + begin + CalcFields(Balance); + CreditLimitExceeded := (Balance > "Credit Limit") and ("Credit Limit" <> 0); + + if CreditLimitExceeded then + Message('Credit limit exceeded for customer %1', "No."); + end; + + procedure GetDisplayName(): Text + begin + exit(Name + ' (' + "No." + ')'); + end; +} \ No newline at end of file diff --git a/test/resources/repos/angular/test_repo/.gitignore b/test/resources/repos/angular/test_repo/.gitignore new file mode 100644 index 0000000..11431c9 --- /dev/null +++ b/test/resources/repos/angular/test_repo/.gitignore @@ -0,0 +1,5 @@ +node_modules +.angular +dist +out-tsc +.angular-install.lock diff --git a/test/resources/repos/angular/test_repo/angular.json b/test/resources/repos/angular/test_repo/angular.json new file mode 100644 index 0000000..337e060 --- /dev/null +++ b/test/resources/repos/angular/test_repo/angular.json @@ -0,0 +1,24 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "serena-test-app": { + "projectType": "application", + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/serena-test-app", + "index": "src/index.html", + "browser": "src/main.ts", + "tsConfig": "tsconfig.app.json" + } + } + } + } + } +} diff --git a/test/resources/repos/angular/test_repo/package-lock.json b/test/resources/repos/angular/test_repo/package-lock.json new file mode 100644 index 0000000..046eab7 --- /dev/null +++ b/test/resources/repos/angular/test_repo/package-lock.json @@ -0,0 +1,134 @@ +{ + "name": "serena-angular-test-repo", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "serena-angular-test-repo", + "version": "0.0.0", + "dependencies": { + "@angular/common": "^21.0.0", + "@angular/compiler": "^21.0.0", + "@angular/core": "^21.0.0", + "@angular/platform-browser": "^21.0.0", + "rxjs": "~7.8.0", + "tslib": "^2.6.0", + "zone.js": "~0.15.0" + }, + "devDependencies": { + "typescript": "~5.9.0" + } + }, + "node_modules/@angular/common": { + "version": "21.2.10", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.10.tgz", + "integrity": "sha512-WLyi/CRLtgALg2mmaqIuKuPnE4i+8PGt/uuz26pVqx+ASh28/TWr5KSCAMomgxEc8kt4OE7lopoQsTihrQCfEw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/core": "21.2.10", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/compiler": { + "version": "21.2.10", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.10.tgz", + "integrity": "sha512-IrgdFuzzD7NTK3WQaSfowjAPxPbnTqsgR92NsOs5ZaWu3RgLl21dHThNc0BK1KwVwppLUSWmD4qePbcLW71VzQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@angular/core": { + "version": "21.2.10", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.10.tgz", + "integrity": "sha512-uxH+mbPiCE7rInWKYOPe9Ytas97+mFM6FhFORoN234yBK3b8he+iDuxX6dsbhEFCxhRmfS6hLxe7BdLY6U6kIA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/compiler": "21.2.10", + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.15.0 || ~0.16.0" + }, + "peerDependenciesMeta": { + "@angular/compiler": { + "optional": true + }, + "zone.js": { + "optional": true + } + } + }, + "node_modules/@angular/platform-browser": { + "version": "21.2.10", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.10.tgz", + "integrity": "sha512-5WMoHGU8BOV3eO9h3vGMIUDPf+3SHis7+X2dHKMtKfFBUtiO8m/lq2x3PzkkKj1782i7KYt92EqPHuADd/eWOw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/animations": "21.2.10", + "@angular/common": "21.2.10", + "@angular/core": "21.2.10" + }, + "peerDependenciesMeta": { + "@angular/animations": { + "optional": true + } + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/zone.js": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", + "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", + "license": "MIT" + } + } +} diff --git a/test/resources/repos/angular/test_repo/package.json b/test/resources/repos/angular/test_repo/package.json new file mode 100644 index 0000000..09e227d --- /dev/null +++ b/test/resources/repos/angular/test_repo/package.json @@ -0,0 +1,18 @@ +{ + "name": "serena-angular-test-repo", + "version": "0.0.0", + "private": true, + "description": "Minimal Angular workspace fixture used by Serena language-server tests.", + "dependencies": { + "@angular/common": "^21.0.0", + "@angular/compiler": "^21.0.0", + "@angular/core": "^21.0.0", + "@angular/platform-browser": "^21.0.0", + "rxjs": "~7.8.0", + "tslib": "^2.6.0", + "zone.js": "~0.15.0" + }, + "devDependencies": { + "typescript": "~5.9.0" + } +} diff --git a/test/resources/repos/angular/test_repo/src/app/app.component.html b/test/resources/repos/angular/test_repo/src/app/app.component.html new file mode 100644 index 0000000..b8498db --- /dev/null +++ b/test/resources/repos/angular/test_repo/src/app/app.component.html @@ -0,0 +1,21 @@ +
+

{{ title() | exclaim }}

+

{{ greeting() }}

+ + + + @if (items().length > 0) { +
    + @for (item of items(); track item) { + + } +
+ } @else { +

No items.

+ } +
diff --git a/test/resources/repos/angular/test_repo/src/app/app.component.ts b/test/resources/repos/angular/test_repo/src/app/app.component.ts new file mode 100644 index 0000000..25f2212 --- /dev/null +++ b/test/resources/repos/angular/test_repo/src/app/app.component.ts @@ -0,0 +1,26 @@ +import { Component, signal } from '@angular/core'; +import { GreetingService } from './greeting.service'; +import { ItemCardComponent } from './item-card.component'; +import { ExclaimPipe } from './exclaim.pipe'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [ItemCardComponent, ExclaimPipe], + templateUrl: './app.component.html', +}) +export class AppComponent { + readonly title = signal('Serena Angular Test'); + readonly userName = signal(''); + readonly items = signal(['alpha', 'beta', 'gamma']); + + constructor(private readonly greetings: GreetingService) {} + + greeting(): string { + return this.greetings.greet(this.userName() || undefined); + } + + setName(name: string): void { + this.userName.set(name); + } +} diff --git a/test/resources/repos/angular/test_repo/src/app/diagnostics_sample.html b/test/resources/repos/angular/test_repo/src/app/diagnostics_sample.html new file mode 100644 index 0000000..c38be8d --- /dev/null +++ b/test/resources/repos/angular/test_repo/src/app/diagnostics_sample.html @@ -0,0 +1,6 @@ + +
+

{{ undefinedSignal() }}

+
diff --git a/test/resources/repos/angular/test_repo/src/app/diagnostics_sample.ts b/test/resources/repos/angular/test_repo/src/app/diagnostics_sample.ts new file mode 100644 index 0000000..1858394 --- /dev/null +++ b/test/resources/repos/angular/test_repo/src/app/diagnostics_sample.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; + +// Two diagnostic sources in one fixture: +// * the type error on ``count`` is reported by tsserver via the Angular plugin +// when diagnosing this .ts file directly. +// * the ``undefinedSignal()`` reference in diagnostics_sample.html is reported +// by ngserver when diagnosing the template attached via ``templateUrl``. +@Component({ + selector: 'app-diagnostics-sample', + standalone: true, + templateUrl: './diagnostics_sample.html', +}) +export class DiagnosticsSampleComponent { + readonly count: number = 'not-a-number'; +} diff --git a/test/resources/repos/angular/test_repo/src/app/exclaim.pipe.ts b/test/resources/repos/angular/test_repo/src/app/exclaim.pipe.ts new file mode 100644 index 0000000..38e09f6 --- /dev/null +++ b/test/resources/repos/angular/test_repo/src/app/exclaim.pipe.ts @@ -0,0 +1,11 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'exclaim', + standalone: true, +}) +export class ExclaimPipe implements PipeTransform { + transform(value: string, count: number = 1): string { + return value + '!'.repeat(count); + } +} diff --git a/test/resources/repos/angular/test_repo/src/app/greeter.interface.ts b/test/resources/repos/angular/test_repo/src/app/greeter.interface.ts new file mode 100644 index 0000000..5850224 --- /dev/null +++ b/test/resources/repos/angular/test_repo/src/app/greeter.interface.ts @@ -0,0 +1,3 @@ +export interface Greeter { + greet(name?: string): string; +} diff --git a/test/resources/repos/angular/test_repo/src/app/greeting.service.ts b/test/resources/repos/angular/test_repo/src/app/greeting.service.ts new file mode 100644 index 0000000..86cdbf7 --- /dev/null +++ b/test/resources/repos/angular/test_repo/src/app/greeting.service.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@angular/core'; +import { Greeter } from './greeter.interface'; + +@Injectable({ providedIn: 'root' }) +export class GreetingService implements Greeter { + private readonly defaultName = 'World'; + + greet(name?: string): string { + return `Hello, ${name ?? this.defaultName}!`; + } +} diff --git a/test/resources/repos/angular/test_repo/src/app/item-card.component.ts b/test/resources/repos/angular/test_repo/src/app/item-card.component.ts new file mode 100644 index 0000000..7af9f2a --- /dev/null +++ b/test/resources/repos/angular/test_repo/src/app/item-card.component.ts @@ -0,0 +1,18 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-item-card', + standalone: true, + template: ` +
  • + {{ label }} +
  • + `, +}) +export class ItemCardComponent { + @Input() label: string = ''; + + formatLabel(): string { + return this.label.trim(); + } +} diff --git a/test/resources/repos/angular/test_repo/src/index.html b/test/resources/repos/angular/test_repo/src/index.html new file mode 100644 index 0000000..e7cf87d --- /dev/null +++ b/test/resources/repos/angular/test_repo/src/index.html @@ -0,0 +1,10 @@ + + + + + Serena Angular Test + + + + + diff --git a/test/resources/repos/angular/test_repo/src/main.ts b/test/resources/repos/angular/test_repo/src/main.ts new file mode 100644 index 0000000..31c5da4 --- /dev/null +++ b/test/resources/repos/angular/test_repo/src/main.ts @@ -0,0 +1,4 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent).catch((err) => console.error(err)); diff --git a/test/resources/repos/angular/test_repo/tsconfig.app.json b/test/resources/repos/angular/test_repo/tsconfig.app.json new file mode 100644 index 0000000..e228136 --- /dev/null +++ b/test/resources/repos/angular/test_repo/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.html"] +} diff --git a/test/resources/repos/angular/test_repo/tsconfig.json b/test/resources/repos/angular/test_repo/tsconfig.json new file mode 100644 index 0000000..ca72349 --- /dev/null +++ b/test/resources/repos/angular/test_repo/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/test/resources/repos/ansible/test_repo/inventory/hosts.yml b/test/resources/repos/ansible/test_repo/inventory/hosts.yml new file mode 100644 index 0000000..616cd91 --- /dev/null +++ b/test/resources/repos/ansible/test_repo/inventory/hosts.yml @@ -0,0 +1,13 @@ +--- +all: + children: + webservers: + hosts: + web1.example.com: + ansible_host: 192.168.1.10 + web2.example.com: + ansible_host: 192.168.1.11 + dbservers: + hosts: + db1.example.com: + ansible_host: 192.168.1.20 diff --git a/test/resources/repos/ansible/test_repo/playbook.yml b/test/resources/repos/ansible/test_repo/playbook.yml new file mode 100644 index 0000000..37a4e97 --- /dev/null +++ b/test/resources/repos/ansible/test_repo/playbook.yml @@ -0,0 +1,31 @@ +--- +- name: Configure web servers + hosts: webservers + become: true + vars: + http_port: 80 + max_connections: 100 + + tasks: + - name: Install nginx + ansible.builtin.package: + name: nginx + state: present + + - name: Start nginx service + ansible.builtin.service: + name: nginx + state: started + enabled: true + + - name: Copy config file + ansible.builtin.template: + src: nginx.conf.j2 + dest: /etc/nginx/nginx.conf + notify: Restart nginx + + handlers: + - name: Restart nginx + ansible.builtin.service: + name: nginx + state: restarted diff --git a/test/resources/repos/ansible/test_repo/roles/common/defaults/main.yml b/test/resources/repos/ansible/test_repo/roles/common/defaults/main.yml new file mode 100644 index 0000000..83723e3 --- /dev/null +++ b/test/resources/repos/ansible/test_repo/roles/common/defaults/main.yml @@ -0,0 +1,3 @@ +--- +nginx_listen_port: 80 +deploy_user: deploy diff --git a/test/resources/repos/ansible/test_repo/roles/common/handlers/main.yml b/test/resources/repos/ansible/test_repo/roles/common/handlers/main.yml new file mode 100644 index 0000000..aa567e7 --- /dev/null +++ b/test/resources/repos/ansible/test_repo/roles/common/handlers/main.yml @@ -0,0 +1,4 @@ +--- +- name: Restart common services + ansible.builtin.debug: + msg: "Restarting common services" diff --git a/test/resources/repos/ansible/test_repo/roles/common/tasks/main.yml b/test/resources/repos/ansible/test_repo/roles/common/tasks/main.yml new file mode 100644 index 0000000..5ce8cea --- /dev/null +++ b/test/resources/repos/ansible/test_repo/roles/common/tasks/main.yml @@ -0,0 +1,19 @@ +--- +- name: Update package cache + ansible.builtin.package: + update_cache: true + +- name: Install common packages + ansible.builtin.package: + name: "{{ item }}" + state: present + loop: + - curl + - git + - vim + +- name: Create deploy user + ansible.builtin.user: + name: deploy + state: present + shell: /bin/bash diff --git a/test/resources/repos/bash/test_repo/config.sh b/test/resources/repos/bash/test_repo/config.sh new file mode 100644 index 0000000..440867e --- /dev/null +++ b/test/resources/repos/bash/test_repo/config.sh @@ -0,0 +1,91 @@ +#!/bin/bash + +# Configuration script for project setup + +# Environment variables +export PROJECT_NAME="bash-test-project" +export PROJECT_VERSION="1.0.0" +export LOG_LEVEL="INFO" +export CONFIG_DIR="./config" + +# Default settings +DEFAULT_TIMEOUT=30 +DEFAULT_RETRIES=3 +DEFAULT_PORT=8080 + +# Configuration arrays +declare -A ENVIRONMENTS=( + ["dev"]="development" + ["prod"]="production" + ["test"]="testing" +) + +declare -A DATABASE_CONFIGS=( + ["host"]="localhost" + ["port"]="5432" + ["name"]="myapp_db" + ["user"]="dbuser" +) + +# Function to load configuration +load_config() { + local env="${1:-dev}" + local config_file="${CONFIG_DIR}/${env}.conf" + + if [[ -f "$config_file" ]]; then + echo "Loading configuration from $config_file" + source "$config_file" + else + echo "Warning: Configuration file $config_file not found, using defaults" + fi +} + +# Function to validate configuration +validate_config() { + local errors=0 + + if [[ -z "$PROJECT_NAME" ]]; then + echo "Error: PROJECT_NAME is not set" >&2 + ((errors++)) + fi + + if [[ -z "$PROJECT_VERSION" ]]; then + echo "Error: PROJECT_VERSION is not set" >&2 + ((errors++)) + fi + + if [[ $DEFAULT_PORT -lt 1024 || $DEFAULT_PORT -gt 65535 ]]; then + echo "Error: Invalid port number $DEFAULT_PORT" >&2 + ((errors++)) + fi + + return $errors +} + +# Function to print configuration +print_config() { + echo "=== Current Configuration ===" + echo "Project Name: $PROJECT_NAME" + echo "Version: $PROJECT_VERSION" + echo "Log Level: $LOG_LEVEL" + echo "Default Port: $DEFAULT_PORT" + echo "Default Timeout: $DEFAULT_TIMEOUT" + echo "Default Retries: $DEFAULT_RETRIES" + + echo "\n=== Environments ===" + for env in "${!ENVIRONMENTS[@]}"; do + echo " $env: ${ENVIRONMENTS[$env]}" + done + + echo "\n=== Database Configuration ===" + for key in "${!DATABASE_CONFIGS[@]}"; do + echo " $key: ${DATABASE_CONFIGS[$key]}" + done +} + +# Initialize configuration if this script is run directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + load_config "$1" + validate_config + print_config +fi diff --git a/test/resources/repos/bash/test_repo/diagnostics_sample.sh b/test/resources/repos/bash/test_repo/diagnostics_sample.sh new file mode 100644 index 0000000..050a8a9 --- /dev/null +++ b/test/resources/repos/bash/test_repo/diagnostics_sample.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +if then + echo "$missingGreeting" +fi diff --git a/test/resources/repos/bash/test_repo/main.sh b/test/resources/repos/bash/test_repo/main.sh new file mode 100644 index 0000000..56d2e72 --- /dev/null +++ b/test/resources/repos/bash/test_repo/main.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +# Main script demonstrating various bash features + +# Global variables +readonly SCRIPT_NAME="Main Script" +COUNTER=0 +declare -a ITEMS=("item1" "item2" "item3") + +# Function definitions +function greet_user() { + local username="$1" + local greeting_type="${2:-default}" + + case "$greeting_type" in + "formal") + echo "Good day, ${username}!" + ;; + "casual") + echo "Hey ${username}!" + ;; + *) + echo "Hello, ${username}!" + ;; + esac +} + +function process_items() { + local -n items_ref=$1 + local operation="$2" + + for item in "${items_ref[@]}"; do + case "$operation" in + "count") + ((COUNTER++)) + echo "Processing item $COUNTER: $item" + ;; + "uppercase") + echo "${item^^}" + ;; + *) + echo "Unknown operation: $operation" + return 1 + ;; + esac + done +} + +# Main execution +main() { + echo "Starting $SCRIPT_NAME" + + if [[ $# -eq 0 ]]; then + echo "Usage: $0 [greeting_type]" + exit 1 + fi + + local user="$1" + local greeting="${2:-default}" + + greet_user "$user" "$greeting" + + echo "Processing items..." + process_items ITEMS "count" + + echo "Script completed successfully" +} + +# Run main function with all arguments +main "$@" diff --git a/test/resources/repos/bash/test_repo/utils.sh b/test/resources/repos/bash/test_repo/utils.sh new file mode 100644 index 0000000..77fc8fe --- /dev/null +++ b/test/resources/repos/bash/test_repo/utils.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +# Utility functions for bash scripting + +# String manipulation functions +function to_uppercase() { + echo "${1^^}" +} + +function to_lowercase() { + echo "${1,,}" +} + +function trim_whitespace() { + local var="$1" + var="${var#"${var%%[![:space:]]*}"}" + var="${var%"${var##*[![:space:]]}"}" + echo "$var" +} + +# File operations +function backup_file() { + local file="$1" + local backup_dir="${2:-./backups}" + + if [[ ! -f "$file" ]]; then + echo "Error: File '$file' does not exist" >&2 + return 1 + fi + + mkdir -p "$backup_dir" + cp "$file" "${backup_dir}/$(basename "$file").$(date +%Y%m%d_%H%M%S).bak" + echo "Backup created for $file" +} + +# Array operations +function contains_element() { + local element="$1" + shift + local array=("$@") + + for item in "${array[@]}"; do + if [[ "$item" == "$element" ]]; then + return 0 + fi + done + return 1 +} + +# Logging functions +function log_message() { + local level="$1" + local message="$2" + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + + case "$level" in + "ERROR") + echo "[$timestamp] ERROR: $message" >&2 + ;; + "WARN") + echo "[$timestamp] WARN: $message" >&2 + ;; + "INFO") + echo "[$timestamp] INFO: $message" + ;; + "DEBUG") + [[ "${DEBUG:-false}" == "true" ]] && echo "[$timestamp] DEBUG: $message" + ;; + *) + echo "[$timestamp] $message" + ;; + esac +} + +# Validation functions +function is_valid_email() { + local email="$1" + [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]] +} + +function is_number() { + [[ $1 =~ ^[0-9]+$ ]] +} diff --git a/test/resources/repos/bsl/test_repo/.bsl-language-server.json b/test/resources/repos/bsl/test_repo/.bsl-language-server.json new file mode 100644 index 0000000..45c1c84 --- /dev/null +++ b/test/resources/repos/bsl/test_repo/.bsl-language-server.json @@ -0,0 +1,5 @@ +{ + "language": "ru", + "diagnosticLanguage": "ru", + "computeDiagnostics": "onSave" +} \ No newline at end of file diff --git a/test/resources/repos/bsl/test_repo/.gitkeep b/test/resources/repos/bsl/test_repo/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/resources/repos/bsl/test_repo/CommonModule.bsl b/test/resources/repos/bsl/test_repo/CommonModule.bsl new file mode 100644 index 0000000..ce5b6b9 --- /dev/null +++ b/test/resources/repos/bsl/test_repo/CommonModule.bsl @@ -0,0 +1,14 @@ +// CommonModule.bsl - common module with reusable procedures + +Процедура ВывестиСообщение(Текст) Экспорт + Сообщить(Текст); +КонецПроцедуры + +Функция ПолучитьПриветствие(Имя) Экспорт + Возврат "Привет, " + Имя + "!"; +КонецФункции + +Процедура ВызватьПриветствие() Экспорт + Сообщение = ПолучитьПриветствие("Мир"); + ВывестиСообщение(Сообщение); +КонецПроцедуры diff --git a/test/resources/repos/bsl/test_repo/ObjectModule.bsl b/test/resources/repos/bsl/test_repo/ObjectModule.bsl new file mode 100644 index 0000000..88cc693 --- /dev/null +++ b/test/resources/repos/bsl/test_repo/ObjectModule.bsl @@ -0,0 +1,12 @@ +// ObjectModule.bsl - object module example + +Перем ВнутреннееСостояние; + +Процедура Инициализировать() Экспорт + ВнутреннееСостояние = "ready"; + ВывестиСообщение("Объект инициализирован"); +КонецПроцедуры + +Функция ПолучитьСостояние() Экспорт + Возврат ВнутреннееСостояние; +КонецФункции diff --git a/test/resources/repos/bsl/test_repo/diagnostics_sample.bsl b/test/resources/repos/bsl/test_repo/diagnostics_sample.bsl new file mode 100644 index 0000000..3d0ba31 --- /dev/null +++ b/test/resources/repos/bsl/test_repo/diagnostics_sample.bsl @@ -0,0 +1,6 @@ +// diagnostics_sample.bsl — намеренно сломанный файл для проверки диагностик BSL LS. +// Содержит синтаксическую ошибку (незакрытая строка), которую bsl-language-server гарантированно флагнет. + +Процедура СломаннаяПроцедура() Экспорт + Текст = "незакрытая строка; +КонецПроцедуры diff --git a/test/resources/repos/bsl/test_repo/src/CommonModules/ОбщийМодуль1.xml b/test/resources/repos/bsl/test_repo/src/CommonModules/ОбщийМодуль1.xml new file mode 100644 index 0000000..cc693c5 --- /dev/null +++ b/test/resources/repos/bsl/test_repo/src/CommonModules/ОбщийМодуль1.xml @@ -0,0 +1,18 @@ + + + + + ОбщийМодуль1 + + + false + false + true + true + false + true + false + DontUse + + + \ No newline at end of file diff --git a/test/resources/repos/bsl/test_repo/src/CommonModules/ОбщийМодуль1/Ext/Module.bsl b/test/resources/repos/bsl/test_repo/src/CommonModules/ОбщийМодуль1/Ext/Module.bsl new file mode 100644 index 0000000..c17ba30 --- /dev/null +++ b/test/resources/repos/bsl/test_repo/src/CommonModules/ОбщийМодуль1/Ext/Module.bsl @@ -0,0 +1,5 @@ +// CommonModule1 — shared helpers called from other modules. + +Процедура ВывестиСообщение(Текст) Экспорт + Сообщить(Текст); +КонецПроцедуры diff --git a/test/resources/repos/bsl/test_repo/src/CommonModules/ОбщийМодуль2.xml b/test/resources/repos/bsl/test_repo/src/CommonModules/ОбщийМодуль2.xml new file mode 100644 index 0000000..6c53984 --- /dev/null +++ b/test/resources/repos/bsl/test_repo/src/CommonModules/ОбщийМодуль2.xml @@ -0,0 +1,18 @@ + + + + + ОбщийМодуль2 + + + false + false + true + true + false + true + false + DontUse + + + \ No newline at end of file diff --git a/test/resources/repos/bsl/test_repo/src/CommonModules/ОбщийМодуль2/Ext/Module.bsl b/test/resources/repos/bsl/test_repo/src/CommonModules/ОбщийМодуль2/Ext/Module.bsl new file mode 100644 index 0000000..923fb56 --- /dev/null +++ b/test/resources/repos/bsl/test_repo/src/CommonModules/ОбщийМодуль2/Ext/Module.bsl @@ -0,0 +1,5 @@ +// CommonModule2 — exercises a cross-module call into CommonModule1. + +Процедура ПозватьМодуль1() Экспорт + ОбщийМодуль1.ВывестиСообщение("Hello from CommonModule2"); +КонецПроцедуры diff --git a/test/resources/repos/bsl/test_repo/src/ConfigDumpInfo.xml b/test/resources/repos/bsl/test_repo/src/ConfigDumpInfo.xml new file mode 100644 index 0000000..899c546 --- /dev/null +++ b/test/resources/repos/bsl/test_repo/src/ConfigDumpInfo.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/test/resources/repos/bsl/test_repo/src/Configuration.xml b/test/resources/repos/bsl/test_repo/src/Configuration.xml new file mode 100644 index 0000000..0ce6040 --- /dev/null +++ b/test/resources/repos/bsl/test_repo/src/Configuration.xml @@ -0,0 +1,252 @@ + + + + + + 9cd510cd-abfc-11d4-9434-004095e12fc7 + f2bbb9be-74c9-40ac-a945-4b03d2624a80 + + + 9fcd25a0-4822-11d4-9414-008048da11f9 + 74c2c4b9-ca2e-4c29-9ac1-c9a686dffd48 + + + e3687481-0a87-462c-a166-9f34594f9bba + e6d84959-ef42-417d-b585-89b178e2ebe4 + + + 9de14907-ec23-4a07-96f0-85521cb6b53b + bb344884-c3a2-4197-ad5d-7e6c391de24f + + + 51f2d5d8-ea4d-4064-8892-82951750031e + 4c2a3610-e746-47e5-a127-f6ef59fba16d + + + e68182ea-4237-4383-967f-90c1e3370bc7 + f94ef4a2-9b6b-4ef7-8fc1-b69a351798cd + + + fb282519-d103-4dd3-bc12-cb271d631dfc + d1f9e184-8cf0-47a9-a487-22516bb66c8c + + + + Конфигурация + + + + Version8_3_27 + ManagedApplication + + PlatformApplication + + Russian + + + + + false + false + false + + + + + + + + + + + + + + + + + + + + + + Biometrics + true + + + Location + false + + + BackgroundLocation + false + + + BluetoothPrinters + false + + + WiFiPrinters + false + + + Contacts + false + + + Calendars + false + + + PushNotifications + false + + + LocalNotifications + false + + + InAppPurchases + false + + + PersonalComputerFileExchange + false + + + Ads + false + + + NumberDialing + false + + + CallProcessing + false + + + CallLog + false + + + AutoSendSMS + false + + + ReceiveSMS + false + + + SMSLog + false + + + Camera + false + + + Microphone + false + + + MusicLibrary + false + + + PictureAndVideoLibraries + false + + + AudioPlaybackAndVibration + false + + + BackgroundAudioPlaybackAndVibration + false + + + InstallPackages + false + + + OSBackup + true + + + ApplicationUsageStatistics + false + + + BarcodeScanning + false + + + BackgroundAudioRecording + false + + + AllFilesAccess + false + + + Videoconferences + false + + + NFC + false + + + DocumentScanning + false + + + SpeechToText + false + + + Geofences + false + + + IncomingShareRequests + false + + + AllIncomingShareRequestsTypesProcessing + false + + + TextToSpeech + false + + + + + + Normal + + + Language.Русский + + + + + + Managed + NotAutoFree + DontUse + DontUse + Taxi + DontUse + Version8_3_27 + + + + Русский + ОбщийМодуль1 + ОбщийМодуль2 + + + \ No newline at end of file diff --git a/test/resources/repos/bsl/test_repo/src/Languages/Русский.xml b/test/resources/repos/bsl/test_repo/src/Languages/Русский.xml new file mode 100644 index 0000000..6665f2c --- /dev/null +++ b/test/resources/repos/bsl/test_repo/src/Languages/Русский.xml @@ -0,0 +1,16 @@ + + + + + Русский + + + ru + Русский + + + + ru + + + \ No newline at end of file diff --git a/test/resources/repos/clojure/test_repo/deps.edn b/test/resources/repos/clojure/test_repo/deps.edn new file mode 100644 index 0000000..d068035 --- /dev/null +++ b/test/resources/repos/clojure/test_repo/deps.edn @@ -0,0 +1,5 @@ +{:paths ["src"] + :deps {org.clojure/clojure {:mvn/version "1.11.1"}} + :aliases + {:test {:extra-paths ["test"] + :extra-deps {org.clojure/test.check {:mvn/version "1.1.1"}}}}} diff --git a/test/resources/repos/clojure/test_repo/src/test_app/core.clj b/test/resources/repos/clojure/test_repo/src/test_app/core.clj new file mode 100644 index 0000000..20f5e00 --- /dev/null +++ b/test/resources/repos/clojure/test_repo/src/test_app/core.clj @@ -0,0 +1,23 @@ +(ns test-app.core) + +(defn greet + "A simple greeting function" + [name] + (str "Hello, " name "!")) + +(defn add + "Adds two numbers" + [a b] + (+ a b)) + +(defn multiply + "Multiplies two numbers" + [a b] + (* a b)) + +(defn -main + "Main entry point" + [& args] + (println (greet "World")) + (println "2 + 3 =" (add 2 3)) + (println "4 * 5 =" (multiply 4 5))) diff --git a/test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj b/test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj new file mode 100644 index 0000000..ecc0c79 --- /dev/null +++ b/test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj @@ -0,0 +1,9 @@ +(ns test-app.diagnostics-sample) + +(defn broken-factory [] + missing-greeting) + +(defn broken-consumer [] + (let [value (broken-factory)] + (println value) + missing-consumer-value)) diff --git a/test/resources/repos/clojure/test_repo/src/test_app/extra.clj b/test/resources/repos/clojure/test_repo/src/test_app/extra.clj new file mode 100644 index 0000000..fb58bd5 --- /dev/null +++ b/test/resources/repos/clojure/test_repo/src/test_app/extra.clj @@ -0,0 +1,12 @@ +(ns test-app.extra + (:require [test-app.core :as core])) + +(defn double-product + "Computes 2 * a * b by calling core/multiply twice." + [a b] + (core/multiply 2 (core/multiply a b))) + +(defn triple-product + "Computes a * b * c by calling core/multiply twice." + [a b c] + (core/multiply a (core/multiply b c))) diff --git a/test/resources/repos/clojure/test_repo/src/test_app/utils.clj b/test/resources/repos/clojure/test_repo/src/test_app/utils.clj new file mode 100644 index 0000000..868bcb5 --- /dev/null +++ b/test/resources/repos/clojure/test_repo/src/test_app/utils.clj @@ -0,0 +1,17 @@ +(ns test-app.utils + (:require [test-app.core :as core])) + +(defn calculate-area + "Calculates the area of a rectangle" + [width height] + (core/multiply width height)) + +(defn format-greeting + "Formats a greeting message" + [name] + (str "Welcome, " (core/greet name))) + +(defn sum-list + "Sums a list of numbers" + [numbers] + (reduce core/add 0 numbers)) diff --git a/test/resources/repos/clojure/test_repo/sub_module/deps.edn b/test/resources/repos/clojure/test_repo/sub_module/deps.edn new file mode 100644 index 0000000..c189704 --- /dev/null +++ b/test/resources/repos/clojure/test_repo/sub_module/deps.edn @@ -0,0 +1,2 @@ +{:paths ["src"] + :deps {test-app/test-app {:local/root "../"}}} diff --git a/test/resources/repos/clojure/test_repo/sub_module/src/sub_module_app/consumer.clj b/test/resources/repos/clojure/test_repo/sub_module/src/sub_module_app/consumer.clj new file mode 100644 index 0000000..6c67bb5 --- /dev/null +++ b/test/resources/repos/clojure/test_repo/sub_module/src/sub_module_app/consumer.clj @@ -0,0 +1,15 @@ +(ns sub-module-app.consumer + "A consumer of test-app.core that lives in a sibling module with its own deps.edn, + mimicking the multi-module layout of real-world Clojure projects (e.g. penpot's + common/, frontend/, backend/ split). The bug being reproduced: references to + symbols defined in test-app.core are missed for files in sibling modules until + those files are explicitly opened/indexed via find_symbol or get_symbols_overview." + (:require [test-app.core :as core])) + +(defn quadruple-product + [a b] + (core/multiply 4 (core/multiply a b))) + +(defn quintuple-product + [a b] + (core/multiply 5 (core/multiply a b))) diff --git a/test/resources/repos/cpp/test_repo/C/a.c b/test/resources/repos/cpp/test_repo/C/a.c new file mode 100644 index 0000000..fe5fbea --- /dev/null +++ b/test/resources/repos/cpp/test_repo/C/a.c @@ -0,0 +1,7 @@ +#include "b.h" + +int main() +{ + int x = add(3, 4); + return x; +} diff --git a/test/resources/repos/cpp/test_repo/C/b.h b/test/resources/repos/cpp/test_repo/C/b.h new file mode 100644 index 0000000..e404974 --- /dev/null +++ b/test/resources/repos/cpp/test_repo/C/b.h @@ -0,0 +1,3 @@ +#pragma once + +int add(int a, int b); diff --git a/test/resources/repos/cpp/test_repo/CUDA/a.cu b/test/resources/repos/cpp/test_repo/CUDA/a.cu new file mode 100644 index 0000000..e32d79f --- /dev/null +++ b/test/resources/repos/cpp/test_repo/CUDA/a.cu @@ -0,0 +1,16 @@ +#include +#include + +__global__ auto +cuda_hello() -> void +{ + printf("Hello World from GPU!\n"); +} + +auto +main() -> int +{ + cuda_hello<<<1, 1>>>(); + cudaDeviceSynchronize(); + return EXIT_SUCCESS; +} diff --git a/test/resources/repos/cpp/test_repo/CXX20/a.cpp b/test/resources/repos/cpp/test_repo/CXX20/a.cpp new file mode 100644 index 0000000..79d8603 --- /dev/null +++ b/test/resources/repos/cpp/test_repo/CXX20/a.cpp @@ -0,0 +1,9 @@ +#include + +import b; + +auto +main() noexcept -> int +{ + return EXIT_SUCCESS; +} diff --git a/test/resources/repos/cpp/test_repo/CXX20/b.cpp b/test/resources/repos/cpp/test_repo/CXX20/b.cpp new file mode 100644 index 0000000..1b95a67 --- /dev/null +++ b/test/resources/repos/cpp/test_repo/CXX20/b.cpp @@ -0,0 +1,17 @@ +module; + +#include + +module b; + +auto +add(auto x, auto y) -> decltype(x + y) +{ + return x + y; +} + +auto +add_f32(float x, float y) -> float +{ + return add(x, y); +} diff --git a/test/resources/repos/cpp/test_repo/CXX20/b.cppm b/test/resources/repos/cpp/test_repo/CXX20/b.cppm new file mode 100644 index 0000000..d1e7dcb --- /dev/null +++ b/test/resources/repos/cpp/test_repo/CXX20/b.cppm @@ -0,0 +1,16 @@ +module; + +#include + +export module b; + +export auto +add(auto x, auto y) -> decltype(x + y); + +export auto +add_f32(float x, float y) -> float; + +auto +add_u32(std::int32_t x, std::int32_t y) -> std::int32_t { + return x + y; +} diff --git a/test/resources/repos/cpp/test_repo/HIP/a.hip b/test/resources/repos/cpp/test_repo/HIP/a.hip new file mode 100644 index 0000000..910b987 --- /dev/null +++ b/test/resources/repos/cpp/test_repo/HIP/a.hip @@ -0,0 +1,10 @@ +// -*- mode: C -*- + +#include + +__global__ +void add_vectors(const float* A, const float* B, float* C) +{ + auto const idx = blockIdx.x * blockDim.x + threadIdx.x; + C[idx] = A[idx] + B[idx]; +} diff --git a/test/resources/repos/cpp/test_repo/Objective-C/a.m b/test/resources/repos/cpp/test_repo/Objective-C/a.m new file mode 100644 index 0000000..6487dfb --- /dev/null +++ b/test/resources/repos/cpp/test_repo/Objective-C/a.m @@ -0,0 +1,7 @@ +@interface Hello +- (void)hello; +@end + +int main(int argc, const char * argv[]) { + return 0; +} diff --git a/test/resources/repos/cpp/test_repo/OpenCL/a.cl b/test/resources/repos/cpp/test_repo/OpenCL/a.cl new file mode 100644 index 0000000..78fe1a6 --- /dev/null +++ b/test/resources/repos/cpp/test_repo/OpenCL/a.cl @@ -0,0 +1,8 @@ +// -*- mode: C -*- + +__kernel +void add_vectors(__global const float *A, __global const float *B, __global float *C) +{ + int idx = get_global_id(0); + C[idx] = A[idx] + B[idx]; +} diff --git a/test/resources/repos/cpp/test_repo/a.cpp b/test/resources/repos/cpp/test_repo/a.cpp new file mode 100644 index 0000000..7e5aebb --- /dev/null +++ b/test/resources/repos/cpp/test_repo/a.cpp @@ -0,0 +1,6 @@ +#include "b.hpp" + +int main() { + int x = add(3, 4); + return x; +} diff --git a/test/resources/repos/cpp/test_repo/b.cpp b/test/resources/repos/cpp/test_repo/b.cpp new file mode 100644 index 0000000..27b9373 --- /dev/null +++ b/test/resources/repos/cpp/test_repo/b.cpp @@ -0,0 +1,5 @@ +#include "b.hpp" + +int add(int a, int b) { + return a + b; +} diff --git a/test/resources/repos/cpp/test_repo/b.hpp b/test/resources/repos/cpp/test_repo/b.hpp new file mode 100644 index 0000000..e404974 --- /dev/null +++ b/test/resources/repos/cpp/test_repo/b.hpp @@ -0,0 +1,3 @@ +#pragma once + +int add(int a, int b); diff --git a/test/resources/repos/cpp/test_repo/compile_commands.json b/test/resources/repos/cpp/test_repo/compile_commands.json new file mode 100644 index 0000000..3ecc0e7 --- /dev/null +++ b/test/resources/repos/cpp/test_repo/compile_commands.json @@ -0,0 +1,52 @@ +[ + { + "directory": ".", + "command": "g++ -std=c++17 -I . -c a.cpp", + "file": "a.cpp" + }, + { + "directory": ".", + "command": "g++ -std=c++17 -I . -c b.cpp", + "file": "b.cpp" + }, + { + "directory": ".", + "command": "g++ -std=c++17 -I . -c diagnostics_sample.cpp", + "file": "diagnostics_sample.cpp" + }, + { + "directory": "C", + "command": "clang -std=gnu23 -c a.c", + "file": "a.c" + }, + { + "directory": "CUDA", + "command": "nvcc -std=c++20 -c a.cu", + "file": "a.cu" + }, + { + "directory": "CXX20", + "command": "clang++ -std=gnu++20 -fmodule-file=b=b.pcm -c a.cpp", + "file": "a.cpp" + }, + { + "directory": "CXX20", + "command": "clang++ -std=gnu++20 -fmodule-file=b=b.pcm -c b.cpp", + "file": "b.cpp" + }, + { + "directory": "CXX20", + "command": "clang++ -std=gnu++20 -c b.cppm", + "file": "b.cppm" + }, + { + "directory": "OpenCL", + "command": "clang -std=cl3.0 -c a.cl", + "file": "a.cl" + }, + { + "directory": "HIP", + "command": "hipcc -c a.hip", + "file": "a.hip" + } +] diff --git a/test/resources/repos/cpp/test_repo/diagnostics_sample.cpp b/test/resources/repos/cpp/test_repo/diagnostics_sample.cpp new file mode 100644 index 0000000..78075fc --- /dev/null +++ b/test/resources/repos/cpp/test_repo/diagnostics_sample.cpp @@ -0,0 +1,8 @@ +int brokenFactory() { + return missingGreeting; +} + +int brokenConsumer() { + int value = brokenFactory(); + return value + missingConsumerValue; +} diff --git a/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityActor.generated.h b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityActor.generated.h new file mode 100644 index 0000000..dfe4e11 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityActor.generated.h @@ -0,0 +1,9 @@ +/*=========================================================================== + Generated code exported from UnrealHeaderTool. + DO NOT modify this manually! Edit the corresponding .h files instead! +===========================================================================*/ +// Test stub: trimmed to what the fixture needs. +// DECLARE_FUNCTION(execOnAbilityInput) +#pragma once + +class AAbilityActor; diff --git a/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityComponent.gen.cpp b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityComponent.gen.cpp new file mode 100644 index 0000000..f63150c --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityComponent.gen.cpp @@ -0,0 +1,22 @@ +/*=========================================================================== + Generated code exported from UnrealHeaderTool. + DO NOT modify this manually! Edit the corresponding .h files instead! +===========================================================================*/ +// Test stub: repeats the hand-written identifiers (as real thunks do) so a +// text search would surface this file; symbol-level tools must not. +// +// void UAbilityComponent::execTriggerAbility(...) { TriggerAbility(AbilityName); } +// void UAbilityComponent::execGetRemainingCooldown(...) { GetRemainingCooldown(AbilityName); } +// void AAbilityActor::execOnAbilityInput(...) { OnAbilityInput(AbilityName); } + +namespace UnrealReflectionStub +{ + const char* GeneratedSymbols[] = { + "UAbilityComponent", + "TriggerAbility", + "GetRemainingCooldown", + "AAbilityActor", + "OnAbilityInput", + "FAbilityInfo", + }; +} diff --git a/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityComponent.generated.h b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityComponent.generated.h new file mode 100644 index 0000000..6b67bfc --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityComponent.generated.h @@ -0,0 +1,10 @@ +/*=========================================================================== + Generated code exported from UnrealHeaderTool. + DO NOT modify this manually! Edit the corresponding .h files instead! +===========================================================================*/ +// Test stub: trimmed to what the fixture needs. +// DECLARE_FUNCTION(execTriggerAbility) +// DECLARE_FUNCTION(execGetRemainingCooldown) +#pragma once + +class UAbilityComponent; diff --git a/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityTypes.generated.h b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityTypes.generated.h new file mode 100644 index 0000000..94f7304 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityTypes.generated.h @@ -0,0 +1,8 @@ +/*=========================================================================== + Generated code exported from UnrealHeaderTool. + DO NOT modify this manually! Edit the corresponding .h files instead! +===========================================================================*/ +// Test stub: trimmed to what the fixture needs. +#pragma once + +struct FAbilityInfo; diff --git a/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/Damageable.generated.h b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/Damageable.generated.h new file mode 100644 index 0000000..d8925f2 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/Damageable.generated.h @@ -0,0 +1,10 @@ +/*=========================================================================== + Generated code exported from UnrealHeaderTool. + DO NOT modify this manually! Edit the corresponding .h files instead! +===========================================================================*/ +// Test stub: trimmed to what the fixture needs. +// DECLARE_FUNCTION(execReceiveDamage) +#pragma once + +class UDamageable; +class IDamageable; diff --git a/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/GameCharacter.generated.h b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/GameCharacter.generated.h new file mode 100644 index 0000000..312e057 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/GameCharacter.generated.h @@ -0,0 +1,10 @@ +/*=========================================================================== + Generated code exported from UnrealHeaderTool. + DO NOT modify this manually! Edit the corresponding .h files instead! +===========================================================================*/ +// Test stub: trimmed to what the fixture needs. +// DECLARE_FUNCTION(execHeal) +// DECLARE_FUNCTION(execReceiveDamage) +#pragma once + +class AGameCharacter; diff --git a/test/resources/repos/cpp/ue_test_repo/MyGame.uproject b/test/resources/repos/cpp/ue_test_repo/MyGame.uproject new file mode 100644 index 0000000..c0a6c1f --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/MyGame.uproject @@ -0,0 +1,4 @@ +{ + "FileVersion": 3, + "EngineAssociation": "5.7" +} diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityActor.cpp b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityActor.cpp new file mode 100644 index 0000000..8ca27d9 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityActor.cpp @@ -0,0 +1,18 @@ +#include "AbilityActor.h" + +void AAbilityActor::BeginPlay() +{ + AActor::BeginPlay(); + if (AbilityComponent) + { + AbilityComponent->TriggerAbility(FName("Dash")); + } +} + +void AAbilityActor::OnAbilityInput(const FName& AbilityName) +{ + if (AbilityComponent) + { + AbilityComponent->TriggerAbility(AbilityName); + } +} diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityActor.h b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityActor.h new file mode 100644 index 0000000..a728131 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityActor.h @@ -0,0 +1,24 @@ +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include "AbilityComponent.h" +#include "AbilityActor.generated.h" + +UCLASS(Blueprintable) +class TESTGAME_API AAbilityActor : public AActor +{ + GENERATED_BODY() + +public: + virtual void BeginPlay() override; + + UFUNCTION(BlueprintCallable, Category = "Input") + void OnAbilityInput(const FName& AbilityName); + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Components") + TObjectPtr AbilityComponent = nullptr; + + UPROPERTY(EditDefaultsOnly, Category = "Abilities") + TSubclassOf ComponentClass; +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityComponent.cpp b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityComponent.cpp new file mode 100644 index 0000000..81cab7c --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityComponent.cpp @@ -0,0 +1,16 @@ +#include "AbilityComponent.h" + +void UAbilityComponent::TriggerAbility(const FName& AbilityName) +{ + if (!ActiveCooldowns.Contains(AbilityName)) + { + ActiveCooldowns.Add(AbilityName, 1.0f); + } + State = EAbilityState::Active; + OnAbilityTriggered.Broadcast(AbilityName); +} + +float UAbilityComponent::GetRemainingCooldown(const FName& AbilityName) const +{ + return 0.0f; +} diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityComponent.h b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityComponent.h new file mode 100644 index 0000000..0216ac4 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityComponent.h @@ -0,0 +1,40 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Components/ActorComponent.h" +#include "AbilityTypes.h" +#include "AbilityComponent.generated.h" + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnAbilityTriggered, const FName&, AbilityName); +DECLARE_MULTICAST_DELEGATE_OneParam(FOnCooldownExpired, const FName&); + +UCLASS(ClassGroup = (Custom), meta = (BlueprintSpawnableComponent)) +class TESTGAME_API UAbilityComponent : public UActorComponent +{ + GENERATED_BODY() + +public: + UFUNCTION(BlueprintCallable, Category = "Abilities") + void TriggerAbility(const FName& AbilityName); + + UFUNCTION(BlueprintPure, Category = "Abilities") + float GetRemainingCooldown(const FName& AbilityName) const; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Abilities") + TArray Abilities; + + UPROPERTY(VisibleAnywhere, Category = "Abilities") + TMap ActiveCooldowns; + + UPROPERTY(BlueprintAssignable, Category = "Abilities") + FOnAbilityTriggered OnAbilityTriggered; + + UPROPERTY(EditAnywhere, Category = "Abilities") + EAbilityState State = EAbilityState::Idle; + + UPROPERTY(EditAnywhere, Category = "Abilities") + TSet UnlockedAbilities; + + // Non-dynamic delegates are not reflected and cannot be UPROPERTYs. + FOnCooldownExpired OnCooldownExpired; +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityTypes.h b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityTypes.h new file mode 100644 index 0000000..a694e25 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityTypes.h @@ -0,0 +1,24 @@ +#pragma once + +#include "CoreMinimal.h" +#include "AbilityTypes.generated.h" + +UENUM(BlueprintType) +enum class EAbilityState : uint8 +{ + Idle, + Active UMETA(DisplayName = "Active (in use)"), + Cooldown, +}; + +USTRUCT(BlueprintType) +struct TESTGAME_API FAbilityInfo +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite) + FName AbilityName; + + UPROPERTY(EditAnywhere, BlueprintReadWrite) + float CooldownSeconds = 1.0f; +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/Damageable.h b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/Damageable.h new file mode 100644 index 0000000..825c1d8 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/Damageable.h @@ -0,0 +1,20 @@ +#pragma once + +#include "CoreMinimal.h" +#include "UObject/Interface.h" +#include "Damageable.generated.h" + +UINTERFACE(MinimalAPI, Blueprintable) +class UDamageable : public UInterface +{ + GENERATED_BODY() +}; + +class TESTGAME_API IDamageable +{ + GENERATED_BODY() + +public: + UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category = "Damage") + virtual void ReceiveDamage(float Amount) = 0; +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/GameCharacter.cpp b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/GameCharacter.cpp new file mode 100644 index 0000000..f50b129 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/GameCharacter.cpp @@ -0,0 +1,29 @@ +#include "GameCharacter.h" +#include "TestGameLog.h" + +AGameCharacter::AGameCharacter() +{ + Abilities = CreateDefaultSubobject(FName(TEXT("Abilities"))); +} + +void AGameCharacter::ReceiveDamage(float Amount) +{ + check(Amount >= 0.0f); + Health -= Amount; + LastDamageAmount = Amount; + UE_LOG(LogTestGame, Warning, TEXT("Received %f damage"), Amount); + + if (AActor* Target = CurrentTarget.Get()) + { + if (AGameCharacter* OtherCharacter = Cast(Target)) + { + OtherCharacter->Heal(Amount); + } + } +} + +void AGameCharacter::Heal(float& Amount) +{ + Health += Amount; + Amount = 0.0f; +} diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/GameCharacter.h b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/GameCharacter.h new file mode 100644 index 0000000..892a42e --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/GameCharacter.h @@ -0,0 +1,39 @@ +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Character.h" +#include "AbilityComponent.h" +#include "Damageable.h" +#include "GameCharacter.generated.h" + +UCLASS(Blueprintable) +class TESTGAME_API AGameCharacter : public ACharacter, public IDamageable +{ + GENERATED_BODY() + +public: + AGameCharacter(); + + virtual void ReceiveDamage(float Amount) override; + + UFUNCTION(BlueprintCallable, Category = "Health") + void Heal(UPARAM(ref) float& Amount); + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Spawn") + FVector SpawnOffset = FVector(0.0, 0.0, 100.0); + + UPROPERTY(EditAnywhere, Category = "Abilities") + TObjectPtr Abilities; + + UPROPERTY(VisibleAnywhere, Category = "Targeting") + TWeakObjectPtr CurrentTarget; + + UPROPERTY(EditAnywhere, Category = "Loadout") + TSoftObjectPtr FallbackLoadout; + +private: + // Non-reflected runtime state: smart pointers and optionals are not UPROPERTYs. + TSharedPtr PendingAbility; + TOptional LastDamageAmount; + float Health = 100.0f; +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/TestGameLog.h b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/TestGameLog.h new file mode 100644 index 0000000..10caade --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/TestGameLog.h @@ -0,0 +1,5 @@ +#pragma once + +#include "CoreMinimal.h" + +DECLARE_LOG_CATEGORY_EXTERN(LogTestGame, Log, All); diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/TestGameModule.cpp b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/TestGameModule.cpp new file mode 100644 index 0000000..4bd6a5d --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/TestGameModule.cpp @@ -0,0 +1,6 @@ +#include "TestGameLog.h" +#include "Modules/ModuleManager.h" + +DEFINE_LOG_CATEGORY(LogTestGame) + +IMPLEMENT_PRIMARY_GAME_MODULE(FDefaultGameModuleImpl, TestGame, "TestGame"); diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Components/ActorComponent.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Components/ActorComponent.h new file mode 100644 index 0000000..b728d77 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Components/ActorComponent.h @@ -0,0 +1,10 @@ +#pragma once + +#include "UObject/Object.h" + +class UActorComponent : public UObject +{ +public: + virtual void BeginPlay() {} + virtual void TickComponent(float DeltaTime) {} +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Array.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Array.h new file mode 100644 index 0000000..408385f --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Array.h @@ -0,0 +1,11 @@ +// Minimal TArray stand-in: just enough surface for the fixture sources to parse. +#pragma once + +template +class TArray +{ +public: + void Add(const ElementType& Item) {} + int Num() const { return 0; } + ElementType* GetData() { return nullptr; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Map.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Map.h new file mode 100644 index 0000000..10c835f --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Map.h @@ -0,0 +1,12 @@ +// Minimal TMap stand-in: just enough surface for the fixture sources to parse. +#pragma once + +template +class TMap +{ +public: + ValueType& Add(const KeyType& Key, const ValueType& Value) { static ValueType V; return V; } + ValueType* Find(const KeyType& Key) { return nullptr; } + bool Contains(const KeyType& Key) const { return false; } + int Num() const { return 0; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Set.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Set.h new file mode 100644 index 0000000..a30afa3 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Set.h @@ -0,0 +1,11 @@ +// Minimal TSet stand-in: just enough surface for the fixture sources to parse. +#pragma once + +template +class TSet +{ +public: + void Add(const ElementType& Item) {} + bool Contains(const ElementType& Item) const { return false; } + int Num() const { return 0; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/CoreMinimal.h b/test/resources/repos/cpp/ue_test_repo/Stubs/CoreMinimal.h new file mode 100644 index 0000000..1821175 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/CoreMinimal.h @@ -0,0 +1,20 @@ +// Stand-in for Unreal's umbrella header. +#pragma once + +#include "CoreTypes.h" +#include "Containers/Array.h" +#include "Containers/Map.h" +#include "Containers/Set.h" +#include "Delegates/DelegateCombinations.h" +#include "Logging/LogMacros.h" +#include "Math/MathFwd.h" +#include "Misc/Optional.h" +#include "Templates/SharedPointer.h" +#include "Templates/SubclassOf.h" +#include "Templates/UniquePtr.h" +#include "UObject/Object.h" +#include "UObject/ObjectMacros.h" +#include "UObject/ObjectPtr.h" +#include "UObject/SoftObjectPtr.h" +#include "UObject/UObjectGlobals.h" +#include "UObject/WeakObjectPtr.h" diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/CoreTypes.h b/test/resources/repos/cpp/ue_test_repo/Stubs/CoreTypes.h new file mode 100644 index 0000000..275a291 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/CoreTypes.h @@ -0,0 +1,41 @@ +// Minimal stand-ins for core Unreal types and ubiquitous utility macros. +// Real homes in UE: TEXT/FORCEINLINE in Misc/, assertions in Misc/AssertionMacros.h, +// UE_DEPRECATED in Misc/CoreMiscDefines.h; folded together here for stub brevity. +#pragma once + +class FName +{ +public: + FName() = default; + FName(const char* InName) {} + bool operator==(const FName& Other) const { return true; } + bool operator<(const FName& Other) const { return false; } +}; + +class FString +{ +public: + FString() = default; + FString(const char* InStr) {} +}; + +class FText +{ +public: + FText() = default; + static FText FromString(const FString& InString) { return FText(); } +}; + +using int32 = int; +using uint32 = unsigned int; +using uint8 = unsigned char; + +#define TEXT(x) x +#define FORCEINLINE inline +#define UE_DEPRECATED(Version, Message) + +#define check(expr) +#define checkf(expr, format, ...) +#define verify(expr) +#define ensure(expr) (!!(expr)) +#define ensureMsgf(expr, format, ...) (!!(expr)) diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Delegates/DelegateCombinations.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Delegates/DelegateCombinations.h new file mode 100644 index 0000000..5b1fc2b --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Delegates/DelegateCombinations.h @@ -0,0 +1,81 @@ +// Stub of UE's delegate declaration macros (Delegates/DelegateCombinations.h). +// Unlike the empty annotation macros, these manufacture a class at the expansion +// site. The stubs preserve exactly that property for every delegate family: +// single-cast, multicast, events, and the dynamic (reflection-visible) variants. +#pragma once + +#define DECLARE_DELEGATE(DelegateName) \ + class DelegateName \ + { \ + public: \ + void Execute() {} \ + bool IsBound() const { return false; } \ + }; + +#define DECLARE_DELEGATE_OneParam(DelegateName, Param1Type) \ + class DelegateName \ + { \ + public: \ + void Execute(Param1Type) {} \ + bool IsBound() const { return false; } \ + }; + +#define DECLARE_DELEGATE_RetVal(ReturnType, DelegateName) \ + class DelegateName \ + { \ + public: \ + ReturnType Execute() { return ReturnType(); } \ + bool IsBound() const { return false; } \ + }; + +#define DECLARE_MULTICAST_DELEGATE(DelegateName) \ + class DelegateName \ + { \ + public: \ + void Broadcast() {} \ + }; + +#define DECLARE_MULTICAST_DELEGATE_OneParam(DelegateName, Param1Type) \ + class DelegateName \ + { \ + public: \ + void Broadcast(Param1Type) {} \ + }; + +#define DECLARE_EVENT(OwningType, EventName) \ + class EventName \ + { \ + public: \ + void Broadcast() {} \ + }; + +#define DECLARE_DYNAMIC_DELEGATE(DelegateName) \ + class DelegateName \ + { \ + public: \ + void ExecuteIfBound() {} \ + }; + +#define DECLARE_DYNAMIC_MULTICAST_DELEGATE(DelegateName) \ + class DelegateName \ + { \ + public: \ + void Broadcast() {} \ + void AddDynamic(void* Object, void* Func) {} \ + }; + +#define DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(DelegateName, Param1Type, Param1Name) \ + class DelegateName \ + { \ + public: \ + void Broadcast(Param1Type Param1Name) {} \ + void AddDynamic(void* Object, void* Func) {} \ + }; + +#define DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(DelegateName, Param1Type, Param1Name, Param2Type, Param2Name) \ + class DelegateName \ + { \ + public: \ + void Broadcast(Param1Type Param1Name, Param2Type Param2Name) {} \ + void AddDynamic(void* Object, void* Func) {} \ + }; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Engine/World.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Engine/World.h new file mode 100644 index 0000000..9da8b5d --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Engine/World.h @@ -0,0 +1,9 @@ +#pragma once + +#include "UObject/Object.h" + +class UWorld : public UObject +{ +public: + float GetTimeSeconds() const { return 0.0f; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Actor.h b/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Actor.h new file mode 100644 index 0000000..80c4536 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Actor.h @@ -0,0 +1,13 @@ +#pragma once + +#include "UObject/Object.h" + +class UWorld; + +class AActor : public UObject +{ +public: + virtual void BeginPlay() {} + virtual void Tick(float DeltaSeconds) {} + UWorld* GetWorld() const { return nullptr; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Character.h b/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Character.h new file mode 100644 index 0000000..014799c --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Character.h @@ -0,0 +1,9 @@ +#pragma once + +#include "GameFramework/Pawn.h" + +class ACharacter : public APawn +{ +public: + virtual void Jump() {} +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Pawn.h b/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Pawn.h new file mode 100644 index 0000000..4007abc --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Pawn.h @@ -0,0 +1,9 @@ +#pragma once + +#include "GameFramework/Actor.h" + +class APawn : public AActor +{ +public: + virtual void PossessedBy(AActor* NewController) {} +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Logging/LogMacros.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Logging/LogMacros.h new file mode 100644 index 0000000..fd77461 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Logging/LogMacros.h @@ -0,0 +1,13 @@ +// Stub of UE's log category macros (Logging/LogMacros.h). The DECLARE/DEFINE pair +// manufactures a category object symbol; UE_LOG itself expands to nothing relevant. +#pragma once + +#define DECLARE_LOG_CATEGORY_EXTERN(CategoryName, DefaultVerbosity, CompileTimeVerbosity) \ + struct FLogCategory##CategoryName \ + { \ + }; \ + extern FLogCategory##CategoryName CategoryName; + +#define DEFINE_LOG_CATEGORY(CategoryName) FLogCategory##CategoryName CategoryName; + +#define UE_LOG(CategoryName, Verbosity, Format, ...) diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Math/MathFwd.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Math/MathFwd.h new file mode 100644 index 0000000..5c6aede --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Math/MathFwd.h @@ -0,0 +1,25 @@ +// Minimal stand-ins for UE math types. +#pragma once + +struct FVector +{ + double X = 0.0; + double Y = 0.0; + double Z = 0.0; + + FVector() = default; + FVector(double InX, double InY, double InZ) : X(InX), Y(InY), Z(InZ) {} +}; + +struct FRotator +{ + double Pitch = 0.0; + double Yaw = 0.0; + double Roll = 0.0; +}; + +struct FTransform +{ + FVector GetLocation() const { return FVector(); } + FRotator Rotator() const { return FRotator(); } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Misc/Optional.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Misc/Optional.h new file mode 100644 index 0000000..a49794b --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Misc/Optional.h @@ -0,0 +1,12 @@ +// Minimal TOptional stand-in. +#pragma once + +template +class TOptional +{ +public: + TOptional() = default; + TOptional(const T& InValue) {} + bool IsSet() const { return false; } + const T& GetValue() const { static T Value; return Value; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Modules/ModuleManager.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Modules/ModuleManager.h new file mode 100644 index 0000000..20aa630 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Modules/ModuleManager.h @@ -0,0 +1,9 @@ +// Stub of UE's module boilerplate macros (Modules/ModuleManager.h). +#pragma once + +class FDefaultGameModuleImpl +{ +}; + +#define IMPLEMENT_PRIMARY_GAME_MODULE(ModuleImplClass, ModuleName, GameName) +#define IMPLEMENT_MODULE(ModuleImplClass, ModuleName) diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/SharedPointer.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/SharedPointer.h new file mode 100644 index 0000000..3eaf59a --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/SharedPointer.h @@ -0,0 +1,31 @@ +// Minimal stand-ins for UE's non-UObject smart pointers. +#pragma once + +template +class TSharedPtr +{ +public: + TSharedPtr() = default; + T* Get() const { return nullptr; } + bool IsValid() const { return false; } +}; + +template +class TSharedRef +{ +public: + T& Get() const { static T Instance; return Instance; } +}; + +template +class TWeakPtr +{ +public: + TSharedPtr Pin() const { return TSharedPtr(); } +}; + +template +TSharedRef MakeShared(ArgTypes&&... Args) +{ + return TSharedRef(); +} diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/SubclassOf.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/SubclassOf.h new file mode 100644 index 0000000..f7b84e1 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/SubclassOf.h @@ -0,0 +1,9 @@ +// Minimal TSubclassOf stand-in. +#pragma once + +template +class TSubclassOf +{ +public: + TSubclassOf() = default; +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/UniquePtr.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/UniquePtr.h new file mode 100644 index 0000000..c86daf7 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/UniquePtr.h @@ -0,0 +1,17 @@ +// Minimal TUniquePtr stand-in. +#pragma once + +template +class TUniquePtr +{ +public: + TUniquePtr() = default; + T* Get() const { return nullptr; } + bool IsValid() const { return false; } +}; + +template +TUniquePtr MakeUnique(ArgTypes&&... Args) +{ + return TUniquePtr(); +} diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/Interface.h b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/Interface.h new file mode 100644 index 0000000..091afce --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/Interface.h @@ -0,0 +1,8 @@ +#pragma once + +#include "UObject/Object.h" + +// Base class for the U-side of UE's interface pattern (UINTERFACE). +class UInterface : public UObject +{ +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/Object.h b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/Object.h new file mode 100644 index 0000000..ba30f34 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/Object.h @@ -0,0 +1,15 @@ +#pragma once + +#include "CoreTypes.h" + +class UObject +{ +public: + virtual ~UObject() = default; + + template + T* CreateDefaultSubobject(const FName& SubobjectName) + { + return nullptr; + } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/ObjectMacros.h b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/ObjectMacros.h new file mode 100644 index 0000000..56a46b1 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/ObjectMacros.h @@ -0,0 +1,20 @@ +// Stub mirroring Unreal Engine 5.7 ObjectMacros.h (Engine/Source/Runtime/CoreUObject/ +// Public/UObject/ObjectMacros.h, lines 744-776). The annotation macros below are +// faithful: in real UE compilation they always expand to nothing (only UnrealHeaderTool +// parses their arguments). GENERATED_BODY is simplified: the real macro pastes +// declarations from the per-class *.generated.h via __LINE__ token concatenation. +#pragma once + +#define UPROPERTY(...) +#define UFUNCTION(...) +#define UPARAM(...) +#define USTRUCT(...) +#define UMETA(...) +#define UENUM(...) +#define UCLASS(...) +#define UINTERFACE(...) +#define UDELEGATE(...) + +#define GENERATED_BODY(...) public: +#define GENERATED_USTRUCT_BODY(...) public: +#define GENERATED_UCLASS_BODY(...) public: diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/ObjectPtr.h b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/ObjectPtr.h new file mode 100644 index 0000000..e6b1503 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/ObjectPtr.h @@ -0,0 +1,13 @@ +// Minimal TObjectPtr stand-in (UE5's idiomatic UPROPERTY pointer wrapper). +#pragma once + +template +class TObjectPtr +{ +public: + TObjectPtr() = default; + TObjectPtr(T* InPtr) {} + T* Get() const { return nullptr; } + T* operator->() const { return nullptr; } + explicit operator bool() const { return false; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/SoftObjectPtr.h b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/SoftObjectPtr.h new file mode 100644 index 0000000..609443d --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/SoftObjectPtr.h @@ -0,0 +1,18 @@ +// Minimal stand-ins for UE's soft (lazy-loadable) object references. +#pragma once + +template +class TSoftObjectPtr +{ +public: + TSoftObjectPtr() = default; + T* LoadSynchronous() const { return nullptr; } + bool IsValid() const { return false; } +}; + +template +class TSoftClassPtr +{ +public: + TSoftClassPtr() = default; +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/UObjectGlobals.h b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/UObjectGlobals.h new file mode 100644 index 0000000..4a86f20 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/UObjectGlobals.h @@ -0,0 +1,16 @@ +// Minimal stand-ins for UObject global helpers (Cast, NewObject). +#pragma once + +class UObject; + +template +T* Cast(UObject* Object) +{ + return nullptr; +} + +template +T* NewObject(UObject* Outer = nullptr) +{ + return nullptr; +} diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/WeakObjectPtr.h b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/WeakObjectPtr.h new file mode 100644 index 0000000..e45451a --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/WeakObjectPtr.h @@ -0,0 +1,12 @@ +// Minimal TWeakObjectPtr stand-in. +#pragma once + +template +class TWeakObjectPtr +{ +public: + TWeakObjectPtr() = default; + TWeakObjectPtr(T* InPtr) {} + T* Get() const { return nullptr; } + bool IsValid() const { return false; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/compile_commands.json b/test/resources/repos/cpp/ue_test_repo/compile_commands.json new file mode 100644 index 0000000..3009849 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/compile_commands.json @@ -0,0 +1,22 @@ +[ + { + "directory": ".", + "command": "clang++ -std=c++20 -DTESTGAME_API= -I Stubs -I Source/TestGame -I Intermediate/TestGame/UHT -c Source/TestGame/AbilityComponent.cpp", + "file": "Source/TestGame/AbilityComponent.cpp" + }, + { + "directory": ".", + "command": "clang++ -std=c++20 -DTESTGAME_API= -I Stubs -I Source/TestGame -I Intermediate/TestGame/UHT -c Source/TestGame/AbilityActor.cpp", + "file": "Source/TestGame/AbilityActor.cpp" + }, + { + "directory": ".", + "command": "clang++ -std=c++20 -DTESTGAME_API= -I Stubs -I Source/TestGame -I Intermediate/TestGame/UHT -c Source/TestGame/GameCharacter.cpp", + "file": "Source/TestGame/GameCharacter.cpp" + }, + { + "directory": ".", + "command": "clang++ -std=c++20 -DTESTGAME_API= -I Stubs -I Source/TestGame -I Intermediate/TestGame/UHT -c Source/TestGame/TestGameModule.cpp", + "file": "Source/TestGame/TestGameModule.cpp" + } +] diff --git a/test/resources/repos/crystal/test_repo/shard.yml b/test/resources/repos/crystal/test_repo/shard.yml new file mode 100644 index 0000000..3ac87e1 --- /dev/null +++ b/test/resources/repos/crystal/test_repo/shard.yml @@ -0,0 +1,8 @@ +name: test_repo +version: 0.1.0 + +targets: + test_repo: + main: src/main.cr + +crystal: ">= 1.0.0" diff --git a/test/resources/repos/crystal/test_repo/src/diagnostics_sample.cr b/test/resources/repos/crystal/test_repo/src/diagnostics_sample.cr new file mode 100644 index 0000000..b408a20 --- /dev/null +++ b/test/resources/repos/crystal/test_repo/src/diagnostics_sample.cr @@ -0,0 +1,5 @@ +def broken_factory : String + missingGreeting +end + +broken_consumer = missingConsumerValue diff --git a/test/resources/repos/crystal/test_repo/src/main.cr b/test/resources/repos/crystal/test_repo/src/main.cr new file mode 100644 index 0000000..d50955b --- /dev/null +++ b/test/resources/repos/crystal/test_repo/src/main.cr @@ -0,0 +1,43 @@ +require "./utils" + +class Calculator + def add(a : Int32, b : Int32) : Int32 + a + b + end + + def multiply(a : Int32, b : Int32) : Int32 + a * b + end +end + +struct User + getter name : String + getter age : Int32 + + def initialize(@name : String, @age : Int32) + end + + def greet : String + "Hello, my name is #{name} and I am #{age} years old." + end + + def adult? : Bool + age >= 18 + end +end + +module Status + Active = 0 + Inactive = 1 + Pending = 2 +end + +calculator = Calculator.new +result = calculator.add(5, 3) +puts "Result: #{result}" + +user = User.new("Alice", 30) +puts user.greet + +area = Utils.calculate_area(5.0) +puts "Circle area: #{area}" diff --git a/test/resources/repos/crystal/test_repo/src/utils.cr b/test/resources/repos/crystal/test_repo/src/utils.cr new file mode 100644 index 0000000..6816425 --- /dev/null +++ b/test/resources/repos/crystal/test_repo/src/utils.cr @@ -0,0 +1,9 @@ +module Utils + def self.format_date(time : Time) : String + time.to_s("%Y-%m-%d") + end + + def self.calculate_area(radius : Float64) : Float64 + Math::PI * radius * radius + end +end diff --git a/test/resources/repos/csharp/test_repo/.gitignore b/test/resources/repos/csharp/test_repo/.gitignore new file mode 100644 index 0000000..c494397 --- /dev/null +++ b/test/resources/repos/csharp/test_repo/.gitignore @@ -0,0 +1,37 @@ +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio temporary files +.vs/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# Files built by Visual Studio +*.user +*.userosscache +*.sln.docstates + +# Build results +*.dll +*.exe +*.pdb + +# NuGet +*.nupkg +*.snupkg +packages/ \ No newline at end of file diff --git a/test/resources/repos/csharp/test_repo/DiagnosticsSample.cs b/test/resources/repos/csharp/test_repo/DiagnosticsSample.cs new file mode 100644 index 0000000..1356e26 --- /dev/null +++ b/test/resources/repos/csharp/test_repo/DiagnosticsSample.cs @@ -0,0 +1,19 @@ +using System; + +namespace TestProject +{ + public static class DiagnosticsSample + { + public static string BrokenFactory() + { + return missingGreeting; + } + + public static void BrokenConsumer() + { + string value = BrokenFactory(); + Console.WriteLine(value); + Console.WriteLine(missingConsumerValue); + } + } +} diff --git a/test/resources/repos/csharp/test_repo/Models/Person.cs b/test/resources/repos/csharp/test_repo/Models/Person.cs new file mode 100644 index 0000000..db10188 --- /dev/null +++ b/test/resources/repos/csharp/test_repo/Models/Person.cs @@ -0,0 +1,34 @@ +using TestProject; + +namespace TestProject.Models +{ + public class Person + { + public string Name { get; set; } + public int Age { get; set; } + public string Email { get; set; } + + public Person(string name, int age, string email) + { + Name = name; + Age = age; + Email = email; + } + + public override string ToString() + { + return $"{Name} ({Age}) - {Email}"; + } + + public bool IsAdult() + { + return Age >= 18; + } + + public int CalculateYearsUntilRetirement() + { + var calculator = new Calculator(); + return calculator.Subtract(65, Age); + } + } +} \ No newline at end of file diff --git a/test/resources/repos/csharp/test_repo/Program.cs b/test/resources/repos/csharp/test_repo/Program.cs new file mode 100644 index 0000000..4bcf0f6 --- /dev/null +++ b/test/resources/repos/csharp/test_repo/Program.cs @@ -0,0 +1,47 @@ +using System; +using TestProject.Services; + +namespace TestProject +{ + class Program + { + static void Main(string[] args) + { + Console.WriteLine("Hello, World!"); + + var calculator = new Calculator(); + int result = calculator.Add(5, 3); + Console.WriteLine($"5 + 3 = {result}"); + + IGreeter greeter = new ConsoleGreeter(); + Console.WriteLine(greeter.FormatGreeting("World")); + } + } + + public class Calculator + { + public int Add(int a, int b) + { + return a + b; + } + + public int Subtract(int a, int b) + { + return a - b; + } + + public int Multiply(int a, int b) + { + return a * b; + } + + public double Divide(int a, int b) + { + if (b == 0) + { + throw new DivideByZeroException("Cannot divide by zero"); + } + return (double)a / b; + } + } +} diff --git a/test/resources/repos/csharp/test_repo/Services/ConsoleGreeter.cs b/test/resources/repos/csharp/test_repo/Services/ConsoleGreeter.cs new file mode 100644 index 0000000..d80d8ba --- /dev/null +++ b/test/resources/repos/csharp/test_repo/Services/ConsoleGreeter.cs @@ -0,0 +1,10 @@ +namespace TestProject.Services +{ + public class ConsoleGreeter : IGreeter + { + public string FormatGreeting(string name) + { + return $"Hello, {name}!"; + } + } +} diff --git a/test/resources/repos/csharp/test_repo/Services/IGreeter.cs b/test/resources/repos/csharp/test_repo/Services/IGreeter.cs new file mode 100644 index 0000000..2f64ca0 --- /dev/null +++ b/test/resources/repos/csharp/test_repo/Services/IGreeter.cs @@ -0,0 +1,7 @@ +namespace TestProject.Services +{ + public interface IGreeter + { + string FormatGreeting(string name); + } +} diff --git a/test/resources/repos/csharp/test_repo/TestProject.csproj b/test/resources/repos/csharp/test_repo/TestProject.csproj new file mode 100644 index 0000000..c28018d --- /dev/null +++ b/test/resources/repos/csharp/test_repo/TestProject.csproj @@ -0,0 +1,10 @@ + + + + Exe + net8.0 + enable + enable + + + \ No newline at end of file diff --git a/test/resources/repos/csharp/test_repo/serena.sln b/test/resources/repos/csharp/test_repo/serena.sln new file mode 100644 index 0000000..ef7a131 --- /dev/null +++ b/test/resources/repos/csharp/test_repo/serena.sln @@ -0,0 +1,38 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F956-CE84-757C-A364CCF449FC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resources", "resources", "{EF7103B4-4C75-1E6D-A485-A154A88D107A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "repos", "repos", "{E2326EEF-E677-6A44-0935-7677816F09E7}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "csharp", "csharp", "{C21E6CE7-177A-86D9-040F-A317F18B6DBF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestProject", "test\resources\repos\csharp\test_repo\TestProject.csproj", "{A4D04E18-760A-73F9-3303-0542F6298C84}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A4D04E18-760A-73F9-3303-0542F6298C84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A4D04E18-760A-73F9-3303-0542F6298C84}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A4D04E18-760A-73F9-3303-0542F6298C84}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A4D04E18-760A-73F9-3303-0542F6298C84}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {EF7103B4-4C75-1E6D-A485-A154A88D107A} = {0C88DD14-F956-CE84-757C-A364CCF449FC} + {E2326EEF-E677-6A44-0935-7677816F09E7} = {EF7103B4-4C75-1E6D-A485-A154A88D107A} + {C21E6CE7-177A-86D9-040F-A317F18B6DBF} = {E2326EEF-E677-6A44-0935-7677816F09E7} + {A4D04E18-760A-73F9-3303-0542F6298C84} = {C21E6CE7-177A-86D9-040F-A317F18B6DBF} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {BDCA748E-D888-4BAF-BF24-DAC683113BFC} + EndGlobalSection +EndGlobal diff --git a/test/resources/repos/cue/test_repo/.gitignore b/test/resources/repos/cue/test_repo/.gitignore new file mode 100644 index 0000000..a85a3ed --- /dev/null +++ b/test/resources/repos/cue/test_repo/.gitignore @@ -0,0 +1,2 @@ +cue.mod/gen/ +cue.mod/pkg/ diff --git a/test/resources/repos/cue/test_repo/cue.mod/module.cue b/test/resources/repos/cue/test_repo/cue.mod/module.cue new file mode 100644 index 0000000..6d828f8 --- /dev/null +++ b/test/resources/repos/cue/test_repo/cue.mod/module.cue @@ -0,0 +1,4 @@ +module: "example.com/testrepo" +language: { + version: "v0.16.0" +} diff --git a/test/resources/repos/cue/test_repo/lib.cue b/test/resources/repos/cue/test_repo/lib.cue new file mode 100644 index 0000000..a3ccc4c --- /dev/null +++ b/test/resources/repos/cue/test_repo/lib.cue @@ -0,0 +1,18 @@ +package testrepo + +// #BuildGreeting is a template/closure: given a #Person under `for_`, it produces a #Greeting +// under `result`. Used by main.cue to build the canonical greeting. +#BuildGreeting: { + for_: #Person + result: #Greeting & { + recipient: for_ + message: "Hello, \(for_.name)!" + } +} + +// locales enumerates the locales the greeter supports; defaultLocale (schema.cue) must be one of them. +locales: [...string] & [ + "en-US", + "en-GB", + "de-DE", +] diff --git a/test/resources/repos/cue/test_repo/main.cue b/test/resources/repos/cue/test_repo/main.cue new file mode 100644 index 0000000..79e4749 --- /dev/null +++ b/test/resources/repos/cue/test_repo/main.cue @@ -0,0 +1,14 @@ +package testrepo + +// alice is a concrete #Person value; used to build the canonical greeting below. +alice: #Person & { + name: "Alice" + age: 30 + email: "alice@example.com" +} + +// greetingForAlice references both #Greeting (from schema.cue) and #BuildGreeting (from lib.cue). +greetingForAlice: (#BuildGreeting & {for_: alice}).result + +// locale pins the output locale; validated against locales defined in lib.cue. +locale: defaultLocale diff --git a/test/resources/repos/cue/test_repo/schema.cue b/test/resources/repos/cue/test_repo/schema.cue new file mode 100644 index 0000000..91c0413 --- /dev/null +++ b/test/resources/repos/cue/test_repo/schema.cue @@ -0,0 +1,17 @@ +package testrepo + +// #Person is the schema for a person record used by main.cue and lib.cue. +#Person: { + name!: string + age?: int & >=0 + email: string +} + +// #Greeting describes a greeting message addressed to a #Person. +#Greeting: { + recipient: #Person + message: string +} + +// defaultLocale is the fallback locale used when no locale is set on a greeting. +defaultLocale: "en-US" diff --git a/test/resources/repos/dart/test_repo/.gitignore b/test/resources/repos/dart/test_repo/.gitignore new file mode 100644 index 0000000..a594830 --- /dev/null +++ b/test/resources/repos/dart/test_repo/.gitignore @@ -0,0 +1,23 @@ +# Files and directories created by pub +.dart_tool/ +.packages +pubspec.lock +build/ + +# If you're building an application, you may want to check-in your pubspec.lock +# pubspec.lock + +# Directory created by dartdoc +doc/api/ + +# dotenv environment variables file +.env* + +# Avoid committing generated Javascript files +*.dart.js +*.info.json # Produced by the --dump-info flag. +*.js # When generated by dart2js. Don't specify *.js if your + # project includes source files written in JavaScript. +*.js_ +*.js.deps +*.js.map \ No newline at end of file diff --git a/test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart b/test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart new file mode 100644 index 0000000..d309d9f --- /dev/null +++ b/test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart @@ -0,0 +1,3 @@ +String brokenFactory() { + return 'broken' +} diff --git a/test/resources/repos/dart/test_repo/lib/helper.dart b/test/resources/repos/dart/test_repo/lib/helper.dart new file mode 100644 index 0000000..b5732b6 --- /dev/null +++ b/test/resources/repos/dart/test_repo/lib/helper.dart @@ -0,0 +1,48 @@ +/// Helper functions for mathematical operations +library helper; + +/// Performs subtraction of two integers +int subtract(int a, int b) { + return a - b; +} + +/// Multiplies two numbers +double multiply(double x, double y) { + return x * y; +} + +/// A utility class for mathematical operations +class MathHelper { + /// Calculates the power of a number + static double power(double base, int exponent) { + double result = 1.0; + for (int i = 0; i < exponent; i++) { + result *= base; + } + return result; + } + + /// Calculates the square root approximation + double sqrt(double number) { + if (number < 0) { + throw ArgumentError('Cannot calculate square root of negative number'); + } + + double guess = number / 2; + for (int i = 0; i < 10; i++) { + guess = (guess + number / guess) / 2; + } + return guess; + } +} + +/// An enumeration for operation types +enum OperationType { + addition, + subtraction, + multiplication, + division +} + +/// A record for representing mathematical operations +typedef Operation = ({OperationType type, double operand1, double operand2}); \ No newline at end of file diff --git a/test/resources/repos/dart/test_repo/lib/main.dart b/test/resources/repos/dart/test_repo/lib/main.dart new file mode 100644 index 0000000..1d84c2e --- /dev/null +++ b/test/resources/repos/dart/test_repo/lib/main.dart @@ -0,0 +1,130 @@ +import 'helper.dart'; + +/// Main calculator class demonstrating various Dart features +class Calculator { + /// Private field for storing history + final List _history = []; + + /// Getter for accessing calculation history + List get history => List.unmodifiable(_history); + + /// Adds two integers and records the operation + int add(int a, int b) { + final result = a + b; + _history.add('$a + $b = $result'); + return result; + } + + /// Subtracts two integers using the helper function + int performSubtract(int a, int b) { + final result = subtract(a, b); // Reference to imported function + _history.add('$a - $b = $result'); + return result; + } + + /// Multiplies two numbers using helper class + double performMultiply(double x, double y) { + final result = multiply(x, y); // Reference to imported function + _history.add('$x * $y = $result'); + return result; + } + + /// Divides two numbers with error handling + double divide(double dividend, double divisor) { + if (divisor == 0) { + throw ArgumentError('Division by zero is not allowed'); + } + final result = dividend / divisor; + _history.add('$dividend / $divisor = $result'); + return result; + } + + /// Clears the calculation history + void clearHistory() { + _history.clear(); + } + + /// Performs a complex calculation using multiple operations + double complexCalculation(double a, double b, double c) { + final step1 = add(a.toInt(), b.toInt()).toDouble(); // Reference to add method + final step2 = MathHelper.power(step1, 2); // Reference to helper class method + final result = step2 + c; + _history.add('Complex: ($a + $b)^2 + $c = $result'); + return result; + } +} + +/// A mixin for providing logging functionality +mixin LoggerMixin { + void log(String message) { + print('[${DateTime.now()}] $message'); + } +} + +/// An abstract class for defining calculators +abstract class CalculatorInterface { + int add(int a, int b); + int subtract(int a, int b); +} + +/// Advanced calculator that extends Calculator and uses mixin +class AdvancedCalculator extends Calculator + with LoggerMixin + implements CalculatorInterface { + + /// Performs scientific calculation with logging + double scientificOperation(Operation operation) { + log('Performing ${operation.type} operation'); + + switch (operation.type) { + case OperationType.addition: + return operation.operand1 + operation.operand2; + case OperationType.subtraction: + return operation.operand1 - operation.operand2; + case OperationType.multiplication: + return operation.operand1 * operation.operand2; + case OperationType.division: + if (operation.operand2 == 0) { + throw ArgumentError('Division by zero'); + } + return operation.operand1 / operation.operand2; + } + } + + /// Factory constructor for creating calculators + factory AdvancedCalculator.withLogger() { + return AdvancedCalculator(); + } +} + +/// Program entry point +class Program { + /// Main method demonstrating calculator usage + static void main(List args) { + final calc = Calculator(); + final result1 = calc.add(5, 3); // Reference to add method + final result2 = calc.performSubtract(10, 4); // Reference to performSubtract method + final result3 = calc.performMultiply(2.5, 4.0); // Reference to performMultiply method + + print('Addition result: $result1'); + print('Subtraction result: $result2'); + print('Multiplication result: $result3'); + + // Using advanced calculator + final advancedCalc = AdvancedCalculator.withLogger(); + final operation = ( + type: OperationType.multiplication, + operand1: 3.14, + operand2: 2.0 + ); + + final scientificResult = advancedCalc.scientificOperation(operation); + print('Scientific result: $scientificResult'); + + // Display calculation history + print('Calculation History:'); + for (final entry in calc.history) { + print(' $entry'); + } + } +} \ No newline at end of file diff --git a/test/resources/repos/dart/test_repo/lib/models.dart b/test/resources/repos/dart/test_repo/lib/models.dart new file mode 100644 index 0000000..135fd81 --- /dev/null +++ b/test/resources/repos/dart/test_repo/lib/models.dart @@ -0,0 +1,174 @@ +/// Data models for the test application +library models; + +/// Represents a user in the system +class User { + /// User's unique identifier + final int id; + + /// User's name + final String name; + + /// User's email address + final String email; + + /// User's age + int _age; + + /// Constructor for creating a User + User(this.id, this.name, this.email, this._age); + + /// Named constructor for creating a user with default values + User.withDefaults(this.id, this.name) + : email = '$name@example.com', + _age = 0; + + /// Getter for user's age + int get age => _age; + + /// Setter for user's age with validation + set age(int value) { + if (value < 0 || value > 150) { + throw ArgumentError('Age must be between 0 and 150'); + } + _age = value; + } + + /// Checks if the user is an adult + bool get isAdult => _age >= 18; + + /// Returns a string representation of the user + @override + String toString() { + return 'User(id: $id, name: $name, email: $email, age: $_age)'; + } + + /// Checks equality based on user ID + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is User && other.id == id; + } + + @override + int get hashCode => id.hashCode; +} + +/// Represents a product in an e-commerce system +class Product { + final String id; + final String name; + final double price; + final String category; + int _stock; + + Product(this.id, this.name, this.price, this.category, this._stock); + + /// Getter for current stock + int get stock => _stock; + + /// Reduces stock by the specified amount + bool reduceStock(int amount) { + if (amount <= 0) { + throw ArgumentError('Amount must be positive'); + } + if (_stock >= amount) { + _stock -= amount; + return true; + } + return false; + } + + /// Increases stock by the specified amount + void increaseStock(int amount) { + if (amount <= 0) { + throw ArgumentError('Amount must be positive'); + } + _stock += amount; + } + + /// Checks if the product is in stock + bool get isInStock => _stock > 0; + + /// Calculates the total value of stock + double get totalStockValue => _stock * price; +} + +/// An abstract base class for different types of accounts +abstract class Account { + final String accountNumber; + double _balance; + + Account(this.accountNumber, this._balance); + + double get balance => _balance; + + /// Abstract method that must be implemented by subclasses + bool withdraw(double amount); + + /// Deposits money into the account + void deposit(double amount) { + if (amount <= 0) { + throw ArgumentError('Deposit amount must be positive'); + } + _balance += amount; + } +} + +/// A savings account implementation +class SavingsAccount extends Account { + final double interestRate; + + SavingsAccount(super.accountNumber, super.balance, this.interestRate); + + @override + bool withdraw(double amount) { + if (amount <= 0) { + throw ArgumentError('Withdrawal amount must be positive'); + } + if (_balance >= amount) { + _balance -= amount; + return true; + } + return false; + } + + /// Applies interest to the account balance + void applyInterest() { + _balance += _balance * interestRate / 100; + } +} + +/// Generic container class demonstrating type parameters +class Container { + final List _items = []; + + /// Adds an item to the container + void add(T item) { + _items.add(item); + } + + /// Removes an item from the container + bool remove(T item) { + return _items.remove(item); + } + + /// Gets all items in the container + List get items => List.unmodifiable(_items); + + /// Gets the number of items + int get length => _items.length; + + /// Checks if the container is empty + bool get isEmpty => _items.isEmpty; + + /// Finds the first item matching the predicate + T? find(bool Function(T) predicate) { + for (final item in _items) { + if (predicate(item)) { + return item; + } + } + return null; + } +} \ No newline at end of file diff --git a/test/resources/repos/dart/test_repo/pubspec.yaml b/test/resources/repos/dart/test_repo/pubspec.yaml new file mode 100644 index 0000000..ea23362 --- /dev/null +++ b/test/resources/repos/dart/test_repo/pubspec.yaml @@ -0,0 +1,11 @@ +name: test_repo +description: A test repository for Serena Dart language server testing +version: 1.0.0 + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + +dev_dependencies: + lints: ^3.0.0 \ No newline at end of file diff --git a/test/resources/repos/elixir/test_repo/.gitignore b/test/resources/repos/elixir/test_repo/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/test/resources/repos/elixir/test_repo/lib/diagnostics_sample.ex b/test/resources/repos/elixir/test_repo/lib/diagnostics_sample.ex new file mode 100644 index 0000000..af51043 --- /dev/null +++ b/test/resources/repos/elixir/test_repo/lib/diagnostics_sample.ex @@ -0,0 +1,10 @@ +defmodule DiagnosticsSample do + def broken_factory do + missing_greeting + end + + def broken_consumer do + value = broken_factory() + {value, missing_consumer_value} + end +end diff --git a/test/resources/repos/elixir/test_repo/lib/examples.ex b/test/resources/repos/elixir/test_repo/lib/examples.ex new file mode 100644 index 0000000..57c2728 --- /dev/null +++ b/test/resources/repos/elixir/test_repo/lib/examples.ex @@ -0,0 +1,211 @@ +defmodule TestRepo.Examples do + @moduledoc """ + Examples module demonstrating usage of models and services. + Similar to Python's examples directory, this shows how different modules work together. + """ + + alias TestRepo.Models.{User, Item} + alias TestRepo.Services.{UserService, ItemService, OrderService} + + defmodule UserManagement do + @doc """ + Creates a complete user workflow example. + """ + def run_user_example do + # Start user service + {:ok, user_service} = UserService.start_link() + + # Create users + {:ok, alice} = UserService.create_user(user_service, "1", "Alice", "alice@example.com", ["admin"]) + {:ok, bob} = UserService.create_user(user_service, "2", "Bob", "bob@example.com", ["user"]) + + # Get users + {:ok, retrieved_alice} = UserService.get_user(user_service, "1") + + # List all users + all_users = UserService.list_users(user_service) + + # Clean up + GenServer.stop(user_service) + + %{ + created_alice: alice, + created_bob: bob, + retrieved_alice: retrieved_alice, + all_users: all_users + } + end + + @doc """ + Demonstrates user role management. + """ + def manage_user_roles do + user = User.new("role_user", "Role User", "role@example.com") + + # Add roles + user_with_admin = User.add_role(user, "admin") + user_with_multiple = User.add_role(user_with_admin, "moderator") + + # Check roles + has_admin = User.has_role?(user_with_multiple, "admin") + has_guest = User.has_role?(user_with_multiple, "guest") + + %{ + original_user: user, + user_with_roles: user_with_multiple, + has_admin: has_admin, + has_guest: has_guest + } + end + end + + defmodule ShoppingExample do + @doc """ + Creates a complete shopping workflow. + """ + def run_shopping_example do + # Create user and items + user = User.new("customer1", "Customer One", "customer@example.com") + item1 = Item.new("widget1", "Super Widget", 19.99, "electronics") + item2 = Item.new("gadget1", "Cool Gadget", 29.99, "electronics") + + # Create order + order = OrderService.create_order("order1", user) + + # Add items to order + order_with_item1 = OrderService.add_item_to_order(order, item1) + order_with_items = OrderService.add_item_to_order(order_with_item1, item2) + + # Process the order + processed_order = OrderService.process_order(order_with_items) + completed_order = OrderService.complete_order(processed_order) + + %{ + user: user, + items: [item1, item2], + final_order: completed_order, + total_cost: completed_order.total + } + end + + @doc """ + Demonstrates item filtering and searching. + """ + def item_filtering_example do + # Start item service + {:ok, item_service} = ItemService.start_link() + + # Create various items + ItemService.create_item(item_service, "laptop", "Gaming Laptop", 1299.99, "electronics") + ItemService.create_item(item_service, "book", "Elixir Guide", 39.99, "books") + ItemService.create_item(item_service, "phone", "Smartphone", 699.99, "electronics") + ItemService.create_item(item_service, "novel", "Great Novel", 19.99, "books") + + # Get all items + all_items = ItemService.list_items(item_service) + + # Filter by category + electronics = ItemService.list_items(item_service, "electronics") + books = ItemService.list_items(item_service, "books") + + # Clean up + Agent.stop(item_service) + + %{ + all_items: all_items, + electronics: electronics, + books: books, + total_items: length(all_items), + electronics_count: length(electronics), + books_count: length(books) + } + end + end + + defmodule IntegrationExample do + @doc """ + Runs a complete e-commerce scenario. + """ + def run_full_scenario do + # Setup services + container = TestRepo.Services.create_service_container() + TestRepo.Services.setup_sample_data(container) + + # Get sample data + {:ok, sample_user} = UserService.get_user(container.user_service, TestRepo.Services.sample_user_id()) + {:ok, sample_item} = ItemService.get_item(container.item_service, TestRepo.Services.sample_item_id()) + + # Create additional items + {:ok, premium_item} = ItemService.create_item( + container.item_service, + "premium", + "Premium Product", + 99.99, + "premium" + ) + + # Create order with multiple items + order = OrderService.create_order("big_order", sample_user, [sample_item]) + order_with_premium = OrderService.add_item_to_order(order, premium_item) + + # Process through order lifecycle + processing_order = OrderService.process_order(order_with_premium) + final_order = OrderService.complete_order(processing_order) + + # Serialize everything for output + serialized_user = TestRepo.Services.serialize_model(sample_user) + serialized_order = TestRepo.Services.serialize_model(final_order) + + # Clean up + GenServer.stop(container.user_service) + Agent.stop(container.item_service) + + %{ + scenario: "full_ecommerce", + user: serialized_user, + order: serialized_order, + total_revenue: final_order.total, + items_sold: length(final_order.items) + } + end + + @doc """ + Demonstrates error handling scenarios. + """ + def error_handling_example do + {:ok, user_service} = UserService.start_link() + + # Try to create duplicate user + {:ok, _user1} = UserService.create_user(user_service, "dup", "User", "user@example.com") + duplicate_result = UserService.create_user(user_service, "dup", "Another User", "another@example.com") + + # Try to get non-existent user + missing_user_result = UserService.get_user(user_service, "nonexistent") + + # Try to delete non-existent user + delete_result = UserService.delete_user(user_service, "nonexistent") + + GenServer.stop(user_service) + + %{ + duplicate_user_error: duplicate_result, + missing_user_error: missing_user_result, + delete_missing_error: delete_result + } + end + end + + @doc """ + Main function to run all examples. + """ + def run_all_examples do + %{ + user_management: UserManagement.run_user_example(), + role_management: UserManagement.manage_user_roles(), + shopping: ShoppingExample.run_shopping_example(), + item_filtering: ShoppingExample.item_filtering_example(), + integration: IntegrationExample.run_full_scenario(), + error_handling: IntegrationExample.error_handling_example() + } + end +end \ No newline at end of file diff --git a/test/resources/repos/elixir/test_repo/lib/ignored_dir/ignored_module.ex b/test/resources/repos/elixir/test_repo/lib/ignored_dir/ignored_module.ex new file mode 100644 index 0000000..a311f1d --- /dev/null +++ b/test/resources/repos/elixir/test_repo/lib/ignored_dir/ignored_module.ex @@ -0,0 +1,23 @@ +defmodule TestRepo.IgnoredDir.IgnoredModule do + @moduledoc """ + This module is in a directory that should be ignored by the language server. + It's used for testing directory filtering functionality. + """ + + alias TestRepo.Models.User + + @doc """ + This function references the User model to test that ignored directories + don't show up in symbol references. + """ + def create_ignored_user do + User.new("ignored", "Ignored User", "ignored@example.com") + end + + @doc """ + Another function that uses models. + """ + def process_ignored_user(user) do + User.add_role(user, "ignored_role") + end +end \ No newline at end of file diff --git a/test/resources/repos/elixir/test_repo/lib/models.ex b/test/resources/repos/elixir/test_repo/lib/models.ex new file mode 100644 index 0000000..4e8a42d --- /dev/null +++ b/test/resources/repos/elixir/test_repo/lib/models.ex @@ -0,0 +1,166 @@ +defmodule TestRepo.Models do + @moduledoc """ + Models module demonstrating various Elixir patterns including structs, protocols, and behaviours. + """ + + defprotocol Serializable do + @doc "Convert model to map representation" + def to_map(model) + end + + defmodule User do + @type t :: %__MODULE__{ + id: String.t(), + name: String.t() | nil, + email: String.t(), + roles: list(String.t()) + } + + defstruct [:id, :name, :email, roles: []] + + @doc """ + Creates a new user. + + ## Examples + + iex> TestRepo.Models.User.new("1", "Alice", "alice@example.com") + %TestRepo.Models.User{id: "1", name: "Alice", email: "alice@example.com", roles: []} + + """ + def new(id, name, email, roles \\ []) do + %__MODULE__{id: id, name: name, email: email, roles: roles} + end + + @doc """ + Checks if user has a specific role. + """ + def has_role?(%__MODULE__{roles: roles}, role) do + role in roles + end + + @doc """ + Adds a role to the user. + """ + def add_role(%__MODULE__{roles: roles} = user, role) do + %{user | roles: [role | roles]} + end + end + + defmodule Item do + @type t :: %__MODULE__{ + id: String.t(), + name: String.t(), + price: float(), + category: String.t() + } + + defstruct [:id, :name, :price, :category] + + @doc """ + Creates a new item. + + ## Examples + + iex> TestRepo.Models.Item.new("1", "Widget", 19.99, "electronics") + %TestRepo.Models.Item{id: "1", name: "Widget", price: 19.99, category: "electronics"} + + """ + def new(id, name, price, category) do + %__MODULE__{id: id, name: name, price: price, category: category} + end + + @doc """ + Formats price for display. + """ + def display_price(%__MODULE__{price: price}) do + "$#{:erlang.float_to_binary(price, decimals: 2)}" + end + + @doc """ + Checks if item is in a specific category. + """ + def in_category?(%__MODULE__{category: category}, target_category) do + category == target_category + end + end + + defmodule Order do + alias TestRepo.Models.{User, Item} + + @type t :: %__MODULE__{ + id: String.t(), + user: User.t(), + items: list(Item.t()), + total: float(), + status: atom() + } + + defstruct [:id, :user, items: [], total: 0.0, status: :pending] + + @doc """ + Creates a new order. + """ + def new(id, user, items \\ []) do + total = calculate_total(items) + %__MODULE__{id: id, user: user, items: items, total: total} + end + + @doc """ + Adds an item to the order. + """ + def add_item(%__MODULE__{items: items} = order, item) do + new_items = [item | items] + %{order | items: new_items, total: calculate_total(new_items)} + end + + @doc """ + Updates order status. + """ + def update_status(%__MODULE__{} = order, status) do + %{order | status: status} + end + + defp calculate_total(items) do + Enum.reduce(items, 0.0, fn item, acc -> acc + item.price end) + end + end + + # Protocol implementations + defimpl Serializable, for: User do + def to_map(%User{id: id, name: name, email: email, roles: roles}) do + %{id: id, name: name, email: email, roles: roles} + end + end + + defimpl Serializable, for: Item do + def to_map(%Item{id: id, name: name, price: price, category: category}) do + %{id: id, name: name, price: price, category: category} + end + end + + defimpl Serializable, for: Order do + def to_map(%Order{id: id, user: user, items: items, total: total, status: status}) do + %{ + id: id, + user: Serializable.to_map(user), + items: Enum.map(items, &Serializable.to_map/1), + total: total, + status: status + } + end + end + + @doc """ + Factory function to create a sample user. + """ + def create_sample_user do + User.new("sample", "Sample User", "sample@example.com", ["user"]) + end + + @doc """ + Factory function to create a sample item. + """ + def create_sample_item do + Item.new("sample", "Sample Item", 9.99, "sample") + end +end \ No newline at end of file diff --git a/test/resources/repos/elixir/test_repo/lib/services.ex b/test/resources/repos/elixir/test_repo/lib/services.ex new file mode 100644 index 0000000..b6768a9 --- /dev/null +++ b/test/resources/repos/elixir/test_repo/lib/services.ex @@ -0,0 +1,257 @@ +defmodule TestRepo.Services do + @moduledoc """ + Services module demonstrating function usage and dependencies. + Similar to Python's services.py, this module uses the models defined in TestRepo.Models. + """ + + alias TestRepo.Models.{User, Item, Order, Serializable} + + defmodule UserService do + use GenServer + + # Client API + + @doc """ + Starts the UserService GenServer. + """ + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, %{}, opts) + end + + @doc """ + Creates a new user and stores it. + """ + def create_user(pid, id, name, email, roles \\ []) do + GenServer.call(pid, {:create_user, id, name, email, roles}) + end + + @doc """ + Gets a user by ID. + """ + def get_user(pid, id) do + GenServer.call(pid, {:get_user, id}) + end + + @doc """ + Lists all users. + """ + def list_users(pid) do + GenServer.call(pid, :list_users) + end + + @doc """ + Deletes a user by ID. + """ + def delete_user(pid, id) do + GenServer.call(pid, {:delete_user, id}) + end + + # Server callbacks + + @impl true + def init(_) do + {:ok, %{}} + end + + @impl true + def handle_call({:create_user, id, name, email, roles}, _from, users) do + if Map.has_key?(users, id) do + {:reply, {:error, "User with ID #{id} already exists"}, users} + else + user = User.new(id, name, email, roles) + new_users = Map.put(users, id, user) + {:reply, {:ok, user}, new_users} + end + end + + @impl true + def handle_call({:get_user, id}, _from, users) do + case Map.get(users, id) do + nil -> {:reply, {:error, :not_found}, users} + user -> {:reply, {:ok, user}, users} + end + end + + @impl true + def handle_call(:list_users, _from, users) do + user_list = Map.values(users) + {:reply, user_list, users} + end + + @impl true + def handle_call({:delete_user, id}, _from, users) do + if Map.has_key?(users, id) do + new_users = Map.delete(users, id) + {:reply, :ok, new_users} + else + {:reply, {:error, :not_found}, users} + end + end + end + + defmodule ItemService do + use Agent + + @doc """ + Starts the ItemService Agent. + """ + def start_link(opts \\ []) do + Agent.start_link(fn -> %{} end, opts) + end + + @doc """ + Creates a new item and stores it. + """ + def create_item(pid, id, name, price, category) do + Agent.get_and_update(pid, fn items -> + if Map.has_key?(items, id) do + {{:error, "Item with ID #{id} already exists"}, items} + else + item = Item.new(id, name, price, category) + new_items = Map.put(items, id, item) + {{:ok, item}, new_items} + end + end) + end + + @doc """ + Gets an item by ID. + """ + def get_item(pid, id) do + Agent.get(pid, fn items -> + case Map.get(items, id) do + nil -> {:error, :not_found} + item -> {:ok, item} + end + end) + end + + @doc """ + Lists all items, optionally filtered by category. + """ + def list_items(pid, category \\ nil) do + Agent.get(pid, fn items -> + item_list = Map.values(items) + + case category do + nil -> item_list + cat -> Enum.filter(item_list, &Item.in_category?(&1, cat)) + end + end) + end + + @doc """ + Deletes an item by ID. + """ + def delete_item(pid, id) do + Agent.get_and_update(pid, fn items -> + if Map.has_key?(items, id) do + new_items = Map.delete(items, id) + {:ok, new_items} + else + {{:error, :not_found}, items} + end + end) + end + end + + defmodule OrderService do + @doc """ + Creates a new order. + """ + def create_order(id, user, items \\ []) do + Order.new(id, user, items) + end + + @doc """ + Adds an item to an existing order. + """ + def add_item_to_order(order, item) do + Order.add_item(order, item) + end + + @doc """ + Updates the status of an order. + """ + def update_order_status(order, status) do + Order.update_status(order, status) + end + + @doc """ + Processes an order (changes status to :processing). + """ + def process_order(order) do + update_order_status(order, :processing) + end + + @doc """ + Completes an order (changes status to :completed). + """ + def complete_order(order) do + update_order_status(order, :completed) + end + + @doc """ + Cancels an order (changes status to :cancelled). + """ + def cancel_order(order) do + update_order_status(order, :cancelled) + end + end + + @doc """ + Factory function to create a service container. + """ + def create_service_container do + {:ok, user_service} = UserService.start_link() + {:ok, item_service} = ItemService.start_link() + + %{ + user_service: user_service, + item_service: item_service, + order_service: OrderService + } + end + + @doc """ + Helper function to serialize any model that implements the Serializable protocol. + """ + def serialize_model(model) do + Serializable.to_map(model) + end + + # Module-level variables for testing + @sample_user_id "sample_user" + @sample_item_id "sample_item" + + @doc """ + Gets the sample user ID. + """ + def sample_user_id, do: @sample_user_id + + @doc """ + Gets the sample item ID. + """ + def sample_item_id, do: @sample_item_id + + # Create some sample data at module load time + def setup_sample_data(container) do + # Create sample user + UserService.create_user( + container.user_service, + @sample_user_id, + "Sample User", + "sample@example.com", + ["user", "customer"] + ) + + # Create sample item + ItemService.create_item( + container.item_service, + @sample_item_id, + "Sample Widget", + 29.99, + "electronics" + ) + end +end \ No newline at end of file diff --git a/test/resources/repos/elixir/test_repo/lib/test_repo.ex b/test/resources/repos/elixir/test_repo/lib/test_repo.ex new file mode 100644 index 0000000..96a2fd8 --- /dev/null +++ b/test/resources/repos/elixir/test_repo/lib/test_repo.ex @@ -0,0 +1,31 @@ +defmodule TestRepo do + @moduledoc """ + Documentation for `TestRepo`. + """ + + @doc """ + Hello world. + + ## Examples + + iex> TestRepo.hello() + :world + + """ + def hello do + :world + end + + @doc """ + Adds two numbers together. + + ## Examples + + iex> TestRepo.add(2, 3) + 5 + + """ + def add(a, b) do + a + b + end +end \ No newline at end of file diff --git a/test/resources/repos/elixir/test_repo/lib/utils.ex b/test/resources/repos/elixir/test_repo/lib/utils.ex new file mode 100644 index 0000000..458823e --- /dev/null +++ b/test/resources/repos/elixir/test_repo/lib/utils.ex @@ -0,0 +1,48 @@ +defmodule TestRepo.Utils do + @moduledoc """ + Utility functions for TestRepo. + """ + + @doc """ + Converts a string to uppercase. + + ## Examples + + iex> TestRepo.Utils.upcase("hello") + "HELLO" + + """ + def upcase(string) when is_binary(string) do + String.upcase(string) + end + + @doc """ + Calculates the factorial of a number. + + ## Examples + + iex> TestRepo.Utils.factorial(5) + 120 + + """ + def factorial(0), do: 1 + def factorial(n) when n > 0 do + n * factorial(n - 1) + end + + @doc """ + Checks if a number is even. + + ## Examples + + iex> TestRepo.Utils.even?(4) + true + + iex> TestRepo.Utils.even?(3) + false + + """ + def even?(n) when is_integer(n) do + rem(n, 2) == 0 + end +end \ No newline at end of file diff --git a/test/resources/repos/elixir/test_repo/mix.exs b/test/resources/repos/elixir/test_repo/mix.exs new file mode 100644 index 0000000..76bba38 --- /dev/null +++ b/test/resources/repos/elixir/test_repo/mix.exs @@ -0,0 +1,25 @@ +defmodule TestRepo.MixProject do + use Mix.Project + + def project do + [ + app: :test_repo, + version: "0.1.0", + elixir: "~> 1.14", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [ + extra_applications: [:logger] + ] + end + + defp deps do + [ + {:credo, "~> 1.7", only: [:dev, :test], runtime: false} + ] + end +end \ No newline at end of file diff --git a/test/resources/repos/elixir/test_repo/mix.lock b/test/resources/repos/elixir/test_repo/mix.lock new file mode 100644 index 0000000..004414b --- /dev/null +++ b/test/resources/repos/elixir/test_repo/mix.lock @@ -0,0 +1,6 @@ +%{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, + "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, +} diff --git a/test/resources/repos/elixir/test_repo/scripts/build_script.ex b/test/resources/repos/elixir/test_repo/scripts/build_script.ex new file mode 100644 index 0000000..cc89dfe --- /dev/null +++ b/test/resources/repos/elixir/test_repo/scripts/build_script.ex @@ -0,0 +1,26 @@ +defmodule TestRepo.Scripts.BuildScript do + @moduledoc """ + Build script that references models. + This is in the scripts directory which should be ignored in some tests. + """ + + alias TestRepo.Models.{User, Item} + + @doc """ + Script function that creates test data. + """ + def create_test_data do + user = User.new("script_user", "Script User", "script@example.com") + item = Item.new("script_item", "Script Item", 1.0, "script") + + {user, item} + end + + @doc """ + Another script function referencing User. + """ + def cleanup_users do + # This would reference User in a real scenario + IO.puts("Cleaning up users...") + end +end \ No newline at end of file diff --git a/test/resources/repos/elixir/test_repo/test/models_test.exs b/test/resources/repos/elixir/test_repo/test/models_test.exs new file mode 100644 index 0000000..5226047 --- /dev/null +++ b/test/resources/repos/elixir/test_repo/test/models_test.exs @@ -0,0 +1,169 @@ +defmodule TestRepo.ModelsTest do + use ExUnit.Case + doctest TestRepo.Models + + alias TestRepo.Models.{User, Item, Order, Serializable} + + describe "User" do + test "creates a new user with default roles" do + user = User.new("1", "Alice", "alice@example.com") + + assert user.id == "1" + assert user.name == "Alice" + assert user.email == "alice@example.com" + assert user.roles == [] + end + + test "creates a user with specified roles" do + user = User.new("2", "Bob", "bob@example.com", ["admin", "user"]) + + assert user.roles == ["admin", "user"] + end + + test "checks if user has role" do + user = User.new("3", "Charlie", "charlie@example.com", ["admin"]) + + assert User.has_role?(user, "admin") + refute User.has_role?(user, "guest") + end + + test "adds role to user" do + user = User.new("4", "David", "david@example.com") + user_with_role = User.add_role(user, "moderator") + + assert User.has_role?(user_with_role, "moderator") + assert length(user_with_role.roles) == 1 + end + end + + describe "Item" do + test "creates a new item" do + item = Item.new("widget1", "Super Widget", 19.99, "electronics") + + assert item.id == "widget1" + assert item.name == "Super Widget" + assert item.price == 19.99 + assert item.category == "electronics" + end + + test "formats price for display" do + item = Item.new("item1", "Test Item", 29.99, "test") + + assert Item.display_price(item) == "$29.99" + end + + test "checks if item is in category" do + item = Item.new("book1", "Elixir Book", 39.99, "books") + + assert Item.in_category?(item, "books") + refute Item.in_category?(item, "electronics") + end + end + + describe "Order" do + setup do + user = User.new("customer1", "Customer", "customer@example.com") + item1 = Item.new("item1", "Item 1", 10.00, "category1") + item2 = Item.new("item2", "Item 2", 20.00, "category2") + + %{user: user, item1: item1, item2: item2} + end + + test "creates a new order", %{user: user} do + order = Order.new("order1", user) + + assert order.id == "order1" + assert order.user == user + assert order.items == [] + assert order.total == 0.0 + assert order.status == :pending + end + + test "creates order with items", %{user: user, item1: item1, item2: item2} do + order = Order.new("order2", user, [item1, item2]) + + assert length(order.items) == 2 + assert order.total == 30.0 + end + + test "adds item to order", %{user: user, item1: item1, item2: item2} do + order = Order.new("order3", user, [item1]) + order_with_item = Order.add_item(order, item2) + + assert length(order_with_item.items) == 2 + assert order_with_item.total == 30.0 + end + + test "updates order status", %{user: user} do + order = Order.new("order4", user) + processed_order = Order.update_status(order, :processing) + + assert processed_order.status == :processing + end + end + + describe "Serializable protocol" do + test "serializes User" do + user = User.new("1", "Alice", "alice@example.com", ["admin"]) + serialized = Serializable.to_map(user) + + expected = %{ + id: "1", + name: "Alice", + email: "alice@example.com", + roles: ["admin"] + } + + assert serialized == expected + end + + test "serializes Item" do + item = Item.new("widget1", "Widget", 19.99, "electronics") + serialized = Serializable.to_map(item) + + expected = %{ + id: "widget1", + name: "Widget", + price: 19.99, + category: "electronics" + } + + assert serialized == expected + end + + test "serializes Order" do + user = User.new("1", "Alice", "alice@example.com") + item = Item.new("widget1", "Widget", 19.99, "electronics") + order = Order.new("order1", user, [item]) + + serialized = Serializable.to_map(order) + + assert serialized.id == "order1" + assert serialized.total == 19.99 + assert serialized.status == :pending + assert is_map(serialized.user) + assert is_list(serialized.items) + assert length(serialized.items) == 1 + end + end + + describe "factory functions" do + test "creates sample user" do + user = TestRepo.Models.create_sample_user() + + assert user.id == "sample" + assert user.name == "Sample User" + assert user.email == "sample@example.com" + assert "user" in user.roles + end + + test "creates sample item" do + item = TestRepo.Models.create_sample_item() + + assert item.id == "sample" + assert item.name == "Sample Item" + assert item.price == 9.99 + assert item.category == "sample" + end + end +end \ No newline at end of file diff --git a/test/resources/repos/elixir/test_repo/test/test_repo_test.exs b/test/resources/repos/elixir/test_repo/test/test_repo_test.exs new file mode 100644 index 0000000..62f87c2 --- /dev/null +++ b/test/resources/repos/elixir/test_repo/test/test_repo_test.exs @@ -0,0 +1,14 @@ +defmodule TestRepoTest do + use ExUnit.Case + doctest TestRepo + + test "greets the world" do + assert TestRepo.hello() == :world + end + + test "adds numbers correctly" do + assert TestRepo.add(2, 3) == 5 + assert TestRepo.add(-1, 1) == 0 + assert TestRepo.add(0, 0) == 0 + end +end \ No newline at end of file diff --git a/test/resources/repos/elm/test_repo/DiagnosticsSample.elm b/test/resources/repos/elm/test_repo/DiagnosticsSample.elm new file mode 100644 index 0000000..2880fa7 --- /dev/null +++ b/test/resources/repos/elm/test_repo/DiagnosticsSample.elm @@ -0,0 +1,5 @@ +module DiagnosticsSample exposing (brokenFactory) + +brokenFactory : String +brokenFactory = + "unterminated diff --git a/test/resources/repos/elm/test_repo/Main.elm b/test/resources/repos/elm/test_repo/Main.elm new file mode 100644 index 0000000..76a3478 --- /dev/null +++ b/test/resources/repos/elm/test_repo/Main.elm @@ -0,0 +1,72 @@ +module Main exposing (main, greet, calculateSum) + +{-| Main module for testing Elm language server functionality. + +This module contains basic functions to test: + + - Symbol discovery + - Reference finding + - Cross-file imports + +-} + +import Browser +import Html exposing (Html, div, h1, p, text) +import Utils exposing (formatMessage, addNumbers) + + +{-| The main entry point for the application +-} +main : Program () Model Msg +main = + Browser.sandbox + { init = init + , view = view + , update = update + } + + +type alias Model = + { message : String + , count : Int + } + + +init : Model +init = + { message = greet "World" + , count = calculateSum 5 10 + } + + +type Msg + = NoOp + + +update : Msg -> Model -> Model +update msg model = + case msg of + NoOp -> + model + + +view : Model -> Html Msg +view model = + div [] + [ h1 [] [ text (formatMessage model.message) ] + , p [] [ text ("Count: " ++ String.fromInt model.count) ] + ] + + +{-| Greet someone by name +-} +greet : String -> String +greet name = + "Hello, " ++ name ++ "!" + + +{-| Calculate the sum of two numbers +-} +calculateSum : Int -> Int -> Int +calculateSum a b = + addNumbers a b diff --git a/test/resources/repos/elm/test_repo/Utils.elm b/test/resources/repos/elm/test_repo/Utils.elm new file mode 100644 index 0000000..c54b8a9 --- /dev/null +++ b/test/resources/repos/elm/test_repo/Utils.elm @@ -0,0 +1,28 @@ +module Utils exposing (formatMessage, addNumbers, multiplyNumbers) + +{-| Utility functions for the Elm test application. + +This module provides helper functions used by other modules. + +-} + + +{-| Format a message by adding brackets around it +-} +formatMessage : String -> String +formatMessage msg = + "[ " ++ msg ++ " ]" + + +{-| Add two numbers together +-} +addNumbers : Int -> Int -> Int +addNumbers x y = + x + y + + +{-| Multiply two numbers +-} +multiplyNumbers : Int -> Int -> Int +multiplyNumbers x y = + x * y diff --git a/test/resources/repos/elm/test_repo/elm.json b/test/resources/repos/elm/test_repo/elm.json new file mode 100644 index 0000000..0a668c0 --- /dev/null +++ b/test/resources/repos/elm/test_repo/elm.json @@ -0,0 +1,24 @@ +{ + "type": "application", + "source-directories": [ + "." + ], + "elm-version": "0.19.1", + "dependencies": { + "direct": { + "elm/browser": "1.0.2", + "elm/core": "1.0.5", + "elm/html": "1.0.0" + }, + "indirect": { + "elm/json": "1.1.3", + "elm/time": "1.0.0", + "elm/url": "1.0.0", + "elm/virtual-dom": "1.0.3" + } + }, + "test-dependencies": { + "direct": {}, + "indirect": {} + } +} diff --git a/test/resources/repos/erlang/test_repo/hello.erl b/test/resources/repos/erlang/test_repo/hello.erl new file mode 100644 index 0000000..34f27af --- /dev/null +++ b/test/resources/repos/erlang/test_repo/hello.erl @@ -0,0 +1,14 @@ +-module(hello). +-export([hello_world/0, greet/1, calculate_sum/2]). + +%% Simple hello world function +hello_world() -> + io:format("Hello, World!~n"). + +%% Greet a person by name +greet(Name) -> + io:format("Hello, ~s!~n", [Name]). + +%% Calculate sum of two numbers +calculate_sum(A, B) -> + A + B. \ No newline at end of file diff --git a/test/resources/repos/erlang/test_repo/ignored_dir/ignored_module.erl b/test/resources/repos/erlang/test_repo/ignored_dir/ignored_module.erl new file mode 100644 index 0000000..4f60fbd --- /dev/null +++ b/test/resources/repos/erlang/test_repo/ignored_dir/ignored_module.erl @@ -0,0 +1,8 @@ +%% This module should be ignored by tests +-module(ignored_module). + +%% This is in the ignored directory and should not be processed +-export([ignored_function/0]). + +ignored_function() -> + "This should not appear in symbol searches". \ No newline at end of file diff --git a/test/resources/repos/erlang/test_repo/include/records.hrl b/test/resources/repos/erlang/test_repo/include/records.hrl new file mode 100644 index 0000000..f0b6996 --- /dev/null +++ b/test/resources/repos/erlang/test_repo/include/records.hrl @@ -0,0 +1,38 @@ +%% Common record definitions for the test repository +-ifndef(RECORDS_HRL). +-define(RECORDS_HRL, true). + +%% User record definition +-record(user, { + id :: integer(), + name :: string(), + email :: string(), + age :: integer(), + active = true :: boolean() +}). + +%% Order record definition +-record(order, { + id :: integer(), + user_id :: integer(), + items = [] :: list(), + total :: float(), + status = pending :: pending | processing | completed | cancelled +}). + +%% Item record definition +-record(item, { + id :: integer(), + name :: string(), + price :: float(), + category :: string() +}). + +%% Configuration record +-record(config, { + database_url :: string(), + port :: integer(), + debug = false :: boolean() +}). + +-endif. \ No newline at end of file diff --git a/test/resources/repos/erlang/test_repo/include/types.hrl b/test/resources/repos/erlang/test_repo/include/types.hrl new file mode 100644 index 0000000..dec0f9d --- /dev/null +++ b/test/resources/repos/erlang/test_repo/include/types.hrl @@ -0,0 +1,20 @@ +%% Type definitions for the test repository +-ifndef(TYPES_HRL). +-define(TYPES_HRL, true). + +%% Custom types +-type user_id() :: pos_integer(). +-type email() :: string(). +-type status() :: active | inactive | suspended. +-type price() :: float(). +-type quantity() :: non_neg_integer(). + +%% Complex types +-type order_line() :: {item_id :: pos_integer(), quantity :: quantity(), price :: price()}. +-type search_result() :: {ok, list()} | {error, term()}. + +%% Callback types for behaviors +-type init_result() :: {ok, term()} | {stop, term()}. +-type handle_call_result() :: {reply, term(), term()} | {stop, term(), term()}. + +-endif. \ No newline at end of file diff --git a/test/resources/repos/erlang/test_repo/math_utils.erl b/test/resources/repos/erlang/test_repo/math_utils.erl new file mode 100644 index 0000000..3ff5861 --- /dev/null +++ b/test/resources/repos/erlang/test_repo/math_utils.erl @@ -0,0 +1,16 @@ +-module(math_utils). +-export([add/2, multiply/2, factorial/1]). + +%% Add two numbers +add(X, Y) -> + X + Y. + +%% Multiply two numbers +multiply(X, Y) -> + X * Y. + +%% Calculate factorial +factorial(0) -> + 1; +factorial(N) when N > 0 -> + N * factorial(N - 1). \ No newline at end of file diff --git a/test/resources/repos/erlang/test_repo/rebar.config b/test/resources/repos/erlang/test_repo/rebar.config new file mode 100644 index 0000000..ef8802a --- /dev/null +++ b/test/resources/repos/erlang/test_repo/rebar.config @@ -0,0 +1,33 @@ +%% Rebar3 configuration for test repository +{erl_opts, [ + debug_info, + warnings_as_errors, + warn_export_all, + warn_unused_import, + {i, "include"} +]}. + +{deps, [ + {eunit, ".*", {git, "https://github.com/richcarl/eunit.git", {tag, "2.3.6"}}} +]}. + +{profiles, [ + {test, [ + {erl_opts, [debug_info]}, + {deps, [ + {proper, "1.3.0"} + ]} + ]} +]}. + +{cover_enabled, true}. +{cover_print_enabled, true}. + +{dialyzer, [ + {warnings, [ + unmatched_returns, + error_handling, + race_conditions, + underspecs + ]} +]}. \ No newline at end of file diff --git a/test/resources/repos/erlang/test_repo/src/app.erl b/test/resources/repos/erlang/test_repo/src/app.erl new file mode 100644 index 0000000..96e4015 --- /dev/null +++ b/test/resources/repos/erlang/test_repo/src/app.erl @@ -0,0 +1,117 @@ +%% Main application module +-module(app). +-behaviour(application). +-include("../include/records.hrl"). + +%% Application callbacks +-export([ + start/2, + stop/1 +]). + +%% API exports +-export([ + start_services/0, + stop_services/0, + get_config/0, + health_check/0 +]). + +%%%=================================================================== +%%% Application callbacks +%%%=================================================================== + +-spec start(application:start_type(), term()) -> {ok, pid()} | {error, term()}. +start(_StartType, _StartArgs) -> + io:format("Starting test application~n"), + case start_services() of + ok -> + supervisor:start_link({local, app_sup}, ?MODULE, []); + {error, Reason} -> + {error, Reason} + end. + +-spec stop(term()) -> ok. +stop(_State) -> + io:format("Stopping test application~n"), + stop_services(), + ok. + +%%%=================================================================== +%%% API functions +%%%=================================================================== + +-spec start_services() -> ok | {error, term()}. +start_services() -> + try + {ok, _Pid} = services:start_link(), + io:format("Services started successfully~n"), + ok + catch + error:Reason -> + io:format("Failed to start services: ~p~n", [Reason]), + {error, Reason} + end. + +-spec stop_services() -> ok. +stop_services() -> + try + services:stop(), + io:format("Services stopped successfully~n"), + ok + catch + error:Reason -> + io:format("Error stopping services: ~p~n", [Reason]), + ok + end. + +-spec get_config() -> #config{}. +get_config() -> + #config{ + database_url = "postgresql://localhost:5432/testdb", + port = 8080, + debug = true + }. + +-spec health_check() -> {ok, #{atom() => term()}} | {error, term()}. +health_check() -> + try + Stats = services:get_statistics(), + Config = get_config(), + HealthInfo = #{ + status => healthy, + timestamp => utils:timestamp(), + config => Config, + statistics => Stats, + uptime => erlang:statistics(wall_clock) + }, + {ok, HealthInfo} + catch + error:Reason -> + {error, {health_check_failed, Reason}} + end. + +%%%=================================================================== +%%% Supervisor callbacks (simple implementation) +%%%=================================================================== + +init([]) -> + %% Simple supervisor strategy + SupFlags = #{ + strategy => one_for_one, + intensity => 5, + period => 10 + }, + + ChildSpecs = [ + #{ + id => services, + start => {services, start_link, []}, + restart => permanent, + shutdown => 5000, + type => worker, + modules => [services] + } + ], + + {ok, {SupFlags, ChildSpecs}}. \ No newline at end of file diff --git a/test/resources/repos/erlang/test_repo/src/diagnostics_sample.erl b/test/resources/repos/erlang/test_repo/src/diagnostics_sample.erl new file mode 100644 index 0000000..edb1877 --- /dev/null +++ b/test/resources/repos/erlang/test_repo/src/diagnostics_sample.erl @@ -0,0 +1,5 @@ +-module(diagnostics_sample). +-export([broken_factory/0, broken_consumer/0]). + +broken_factory() -> MissingGreeting. +broken_consumer() -> MissingConsumerValue. diff --git a/test/resources/repos/erlang/test_repo/src/models.erl b/test/resources/repos/erlang/test_repo/src/models.erl new file mode 100644 index 0000000..255ab18 --- /dev/null +++ b/test/resources/repos/erlang/test_repo/src/models.erl @@ -0,0 +1,83 @@ +%% Models module with record operations and business logic +-module(models). +-include("../include/records.hrl"). +-include("../include/types.hrl"). + +%% Export functions +-export([ + create_user/4, + update_user/2, + get_user_by_id/1, + create_order/3, + add_item_to_order/3, + calculate_order_total/1, + validate_email/1, + format_user_info/1 +]). + +%% User operations +-spec create_user(integer(), string(), email(), integer()) -> #user{}. +create_user(Id, Name, Email, Age) -> + #user{ + id = Id, + name = Name, + email = Email, + age = Age, + active = true + }. + +-spec update_user(#user{}, [{atom(), term()}]) -> #user{}. +update_user(User, Updates) -> + lists:foldl(fun update_user_field/2, User, Updates). + +-spec get_user_by_id(user_id()) -> {ok, #user{}} | {error, not_found}. +get_user_by_id(Id) -> + %% Simulate database lookup + case Id of + 1 -> {ok, create_user(1, "John Doe", "john@example.com", 30)}; + 2 -> {ok, create_user(2, "Jane Smith", "jane@example.com", 25)}; + _ -> {error, not_found} + end. + +%% Order operations +-spec create_order(integer(), user_id(), list()) -> #order{}. +create_order(Id, UserId, Items) -> + #order{ + id = Id, + user_id = UserId, + items = Items, + total = 0.0, + status = pending + }. + +-spec add_item_to_order(#order{}, #item{}, quantity()) -> #order{}. +add_item_to_order(Order, Item, Quantity) -> + NewItem = Item#item{id = Quantity}, % Store quantity in id field for simplicity + Order#order{items = [NewItem | Order#order.items]}. + +-spec calculate_order_total(#order{}) -> float(). +calculate_order_total(#order{items = Items}) -> + lists:foldl(fun(#item{price = Price, id = Qty}, Acc) -> + Acc + (Price * Qty) + end, 0.0, Items). + +%% Helper functions +-spec update_user_field({atom(), term()}, #user{}) -> #user{}. +update_user_field({name, Name}, User) -> User#user{name = Name}; +update_user_field({email, Email}, User) -> User#user{email = Email}; +update_user_field({age, Age}, User) -> User#user{age = Age}; +update_user_field({active, Active}, User) -> User#user{active = Active}; +update_user_field(_, User) -> User. + +-spec validate_email(string()) -> boolean(). +validate_email(Email) -> + string:str(Email, "@") > 0 andalso string:str(Email, ".") > 0. + +-spec format_user_info(#user{}) -> string(). +format_user_info(#user{name = Name, email = Email, age = Age, active = Active}) -> + Status = case Active of + true -> "active"; + false -> "inactive" + end, + lists:flatten(io_lib:format("~s (~s) - Age: ~w - Status: ~s", + [Name, Email, Age, Status])). \ No newline at end of file diff --git a/test/resources/repos/erlang/test_repo/src/services.erl b/test/resources/repos/erlang/test_repo/src/services.erl new file mode 100644 index 0000000..c066fd7 --- /dev/null +++ b/test/resources/repos/erlang/test_repo/src/services.erl @@ -0,0 +1,146 @@ +%% Services module implementing gen_server behavior +-module(services). +-behaviour(gen_server). +-include("../include/records.hrl"). +-include("../include/types.hrl"). + +%% API exports +-export([ + start_link/0, + stop/0, + register_user/4, + get_user/1, + create_order/2, + update_order_status/2, + get_statistics/0 +]). + +%% gen_server callbacks +-export([ + init/1, + handle_call/3, + handle_cast/2, + handle_info/2, + terminate/2, + code_change/3 +]). + +%% State record +-record(state, { + users = #{} :: map(), + orders = #{} :: map(), + next_user_id = 1 :: integer(), + next_order_id = 1 :: integer() +}). + +%%%=================================================================== +%%% API +%%%=================================================================== + +-spec start_link() -> {ok, pid()} | ignore | {error, term()}. +start_link() -> + gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). + +-spec stop() -> ok. +stop() -> + gen_server:stop(?MODULE). + +-spec register_user(string(), email(), integer(), boolean()) -> {ok, user_id()} | {error, term()}. +register_user(Name, Email, Age, Active) -> + gen_server:call(?MODULE, {register_user, Name, Email, Age, Active}). + +-spec get_user(user_id()) -> {ok, #user{}} | {error, not_found}. +get_user(UserId) -> + gen_server:call(?MODULE, {get_user, UserId}). + +-spec create_order(user_id(), list()) -> {ok, integer()} | {error, term()}. +create_order(UserId, Items) -> + gen_server:call(?MODULE, {create_order, UserId, Items}). + +-spec update_order_status(integer(), atom()) -> ok | {error, term()}. +update_order_status(OrderId, Status) -> + gen_server:call(?MODULE, {update_order_status, OrderId, Status}). + +-spec get_statistics() -> #{atom() => integer()}. +get_statistics() -> + gen_server:call(?MODULE, get_statistics). + +%%%=================================================================== +%%% gen_server callbacks +%%%=================================================================== + +-spec init([]) -> {ok, #state{}}. +init([]) -> + {ok, #state{}}. + +-spec handle_call(term(), {pid(), term()}, #state{}) -> handle_call_result(). +handle_call({register_user, Name, Email, Age, Active}, _From, State) -> + UserId = State#state.next_user_id, + User = models:create_user(UserId, Name, Email, Age), + UpdatedUser = models:update_user(User, [{active, Active}]), + NewUsers = maps:put(UserId, UpdatedUser, State#state.users), + NewState = State#state{ + users = NewUsers, + next_user_id = UserId + 1 + }, + {reply, {ok, UserId}, NewState}; + +handle_call({get_user, UserId}, _From, State) -> + case maps:get(UserId, State#state.users, not_found) of + not_found -> {reply, {error, not_found}, State}; + User -> {reply, {ok, User}, State} + end; + +handle_call({create_order, UserId, Items}, _From, State) -> + case maps:get(UserId, State#state.users, not_found) of + not_found -> + {reply, {error, user_not_found}, State}; + _User -> + OrderId = State#state.next_order_id, + Order = models:create_order(OrderId, UserId, Items), + NewOrders = maps:put(OrderId, Order, State#state.orders), + NewState = State#state{ + orders = NewOrders, + next_order_id = OrderId + 1 + }, + {reply, {ok, OrderId}, NewState} + end; + +handle_call({update_order_status, OrderId, Status}, _From, State) -> + case maps:get(OrderId, State#state.orders, not_found) of + not_found -> + {reply, {error, order_not_found}, State}; + Order -> + UpdatedOrder = Order#order{status = Status}, + NewOrders = maps:put(OrderId, UpdatedOrder, State#state.orders), + NewState = State#state{orders = NewOrders}, + {reply, ok, NewState} + end; + +handle_call(get_statistics, _From, State) -> + Stats = #{ + total_users => maps:size(State#state.users), + total_orders => maps:size(State#state.orders), + next_user_id => State#state.next_user_id, + next_order_id => State#state.next_order_id + }, + {reply, Stats, State}; + +handle_call(_Request, _From, State) -> + {reply, {error, unknown_request}, State}. + +-spec handle_cast(term(), #state{}) -> {noreply, #state{}}. +handle_cast(_Msg, State) -> + {noreply, State}. + +-spec handle_info(term(), #state{}) -> {noreply, #state{}}. +handle_info(_Info, State) -> + {noreply, State}. + +-spec terminate(term(), #state{}) -> ok. +terminate(_Reason, _State) -> + ok. + +-spec code_change(term() | {down, term()}, #state{}, term()) -> {ok, #state{}}. +code_change(_OldVsn, State, _Extra) -> + {ok, State}. \ No newline at end of file diff --git a/test/resources/repos/erlang/test_repo/src/utils.erl b/test/resources/repos/erlang/test_repo/src/utils.erl new file mode 100644 index 0000000..e97309f --- /dev/null +++ b/test/resources/repos/erlang/test_repo/src/utils.erl @@ -0,0 +1,135 @@ +%% Utility functions module +-module(utils). +-include("../include/types.hrl"). + +%% String utilities +-export([ + capitalize/1, + trim/1, + split_string/2, + format_currency/1, + validate_input/2 +]). + +%% List utilities +-export([ + find_by_id/2, + group_by/2, + partition_by/2, + safe_nth/2 +]). + +%% Math utilities +-export([ + calculate_discount/2, + round_to_decimal/2, + percentage/2 +]). + +%% Date/Time utilities +-export([ + timestamp/0, + format_datetime/1, + days_between/2 +]). + +%%%=================================================================== +%%% String utilities +%%%=================================================================== + +-spec capitalize(string()) -> string(). +capitalize([]) -> []; +capitalize([H|T]) -> [string:to_upper(H) | T]. + +-spec trim(string()) -> string(). +trim(String) -> + string:strip(string:strip(String, right), left). + +-spec split_string(string(), string()) -> [string()]. +split_string(String, Delimiter) -> + string:tokens(String, Delimiter). + +-spec format_currency(float()) -> string(). +format_currency(Amount) -> + lists:flatten(io_lib:format("$~.2f", [Amount])). + +-spec validate_input(atom(), term()) -> boolean(). +validate_input(email, Email) when is_list(Email) -> + models:validate_email(Email); +validate_input(age, Age) when is_integer(Age) -> + Age >= 0 andalso Age =< 150; +validate_input(name, Name) when is_list(Name) -> + length(Name) > 0 andalso length(Name) =< 100; +validate_input(_, _) -> + false. + +%%%=================================================================== +%%% List utilities +%%%=================================================================== + +-spec find_by_id(integer(), [tuple()]) -> {ok, tuple()} | {error, not_found}. +find_by_id(_Id, []) -> {error, not_found}; +find_by_id(Id, [H|T]) when element(2, H) =:= Id -> {ok, H}; +find_by_id(Id, [_|T]) -> find_by_id(Id, T). + +-spec group_by(fun((term()) -> term()), [term()]) -> [{term(), [term()]}]. +group_by(Fun, List) -> + Dict = lists:foldl(fun(Item, Acc) -> + Key = Fun(Item), + case lists:keyfind(Key, 1, Acc) of + {Key, Values} -> + lists:keyreplace(Key, 1, Acc, {Key, [Item|Values]}); + false -> + [{Key, [Item]}|Acc] + end + end, [], List), + [{K, lists:reverse(V)} || {K, V} <- Dict]. + +-spec partition_by(fun((term()) -> boolean()), [term()]) -> {[term()], [term()]}. +partition_by(Predicate, List) -> + lists:partition(Predicate, List). + +-spec safe_nth(integer(), [term()]) -> {ok, term()} | {error, out_of_bounds}. +safe_nth(N, List) when N > 0 andalso N =< length(List) -> + {ok, lists:nth(N, List)}; +safe_nth(_, _) -> + {error, out_of_bounds}. + +%%%=================================================================== +%%% Math utilities +%%%=================================================================== + +-spec calculate_discount(float(), float()) -> float(). +calculate_discount(Price, DiscountPercent) when DiscountPercent >= 0 andalso DiscountPercent =< 100 -> + Price * (100 - DiscountPercent) / 100. + +-spec round_to_decimal(float(), integer()) -> float(). +round_to_decimal(Number, Decimals) -> + Factor = math:pow(10, Decimals), + round(Number * Factor) / Factor. + +-spec percentage(number(), number()) -> float(). +percentage(Part, Total) when Total =/= 0 -> + (Part / Total) * 100; +percentage(_, 0) -> + 0.0. + +%%%=================================================================== +%%% Date/Time utilities +%%%=================================================================== + +-spec timestamp() -> integer(). +timestamp() -> + {MegaSecs, Secs, _MicroSecs} = os:timestamp(), + MegaSecs * 1000000 + Secs. + +-spec format_datetime(integer()) -> string(). +format_datetime(Timestamp) -> + {{Year, Month, Day}, {Hour, Minute, Second}} = + calendar:gregorian_seconds_to_datetime(Timestamp + 62167219200), + lists:flatten(io_lib:format("~4..0w-~2..0w-~2..0w ~2..0w:~2..0w:~2..0w", + [Year, Month, Day, Hour, Minute, Second])). + +-spec days_between(integer(), integer()) -> integer(). +days_between(Timestamp1, Timestamp2) -> + abs(Timestamp2 - Timestamp1) div (24 * 3600). \ No newline at end of file diff --git a/test/resources/repos/erlang/test_repo/test/models_tests.erl b/test/resources/repos/erlang/test_repo/test/models_tests.erl new file mode 100644 index 0000000..c4ce47c --- /dev/null +++ b/test/resources/repos/erlang/test_repo/test/models_tests.erl @@ -0,0 +1,77 @@ +%% Unit tests for models module +-module(models_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("../include/records.hrl"). + +%%%=================================================================== +%%% Test fixtures +%%%=================================================================== + +sample_user() -> + models:create_user(1, "John Doe", "john@example.com", 30). + +sample_order() -> + Items = [ + #item{id = 1, name = "Widget", price = 10.99, category = "tools"}, + #item{id = 2, name = "Gadget", price = 25.50, category = "electronics"} + ], + models:create_order(1, 1, Items). + +%%%=================================================================== +%%% User tests +%%%=================================================================== + +create_user_test() -> + User = models:create_user(1, "John Doe", "john@example.com", 30), + ?assertEqual(1, User#user.id), + ?assertEqual("John Doe", User#user.name), + ?assertEqual("john@example.com", User#user.email), + ?assertEqual(30, User#user.age), + ?assertEqual(true, User#user.active). + +update_user_test() -> + User = sample_user(), + UpdatedUser = models:update_user(User, [{name, "Jane Doe"}, {age, 25}]), + ?assertEqual("Jane Doe", UpdatedUser#user.name), + ?assertEqual(25, UpdatedUser#user.age), + ?assertEqual("john@example.com", UpdatedUser#user.email). % unchanged + +get_user_by_id_test() -> + ?assertEqual({ok, #user{id = 1, name = "John Doe"}}, + models:get_user_by_id(1)), + ?assertEqual({error, not_found}, models:get_user_by_id(999)). + +%%%=================================================================== +%%% Order tests +%%%=================================================================== + +create_order_test() -> + Order = models:create_order(1, 1, []), + ?assertEqual(1, Order#order.id), + ?assertEqual(1, Order#order.user_id), + ?assertEqual([], Order#order.items), + ?assertEqual(pending, Order#order.status). + +calculate_order_total_test() -> + Order = sample_order(), + Total = models:calculate_order_total(Order), + ?assertEqual(36.49, Total). % 10.99 * 1 + 25.50 * 2 + +%%%=================================================================== +%%% Validation tests +%%%=================================================================== + +validate_email_test() -> + ?assertEqual(true, models:validate_email("user@example.com")), + ?assertEqual(true, models:validate_email("test.email@domain.co.uk")), + ?assertEqual(false, models:validate_email("invalid-email")), + ?assertEqual(false, models:validate_email("@domain.com")), + ?assertEqual(false, models:validate_email("user@")). + +format_user_info_test() -> + User = sample_user(), + Info = models:format_user_info(User), + ?assert(string:str(Info, "John Doe") > 0), + ?assert(string:str(Info, "john@example.com") > 0), + ?assert(string:str(Info, "30") > 0), + ?assert(string:str(Info, "active") > 0). \ No newline at end of file diff --git a/test/resources/repos/erlang/test_repo/test/utils_tests.erl b/test/resources/repos/erlang/test_repo/test/utils_tests.erl new file mode 100644 index 0000000..042c3c9 --- /dev/null +++ b/test/resources/repos/erlang/test_repo/test/utils_tests.erl @@ -0,0 +1,85 @@ +%% Unit tests for utils module +-module(utils_tests). +-include_lib("eunit/include/eunit.hrl"). + +%%%=================================================================== +%%% String utility tests +%%%=================================================================== + +capitalize_test() -> + ?assertEqual("Hello", utils:capitalize("hello")), + ?assertEqual("Test", utils:capitalize("test")), + ?assertEqual("", utils:capitalize("")). + +trim_test() -> + ?assertEqual("hello", utils:trim(" hello ")), + ?assertEqual("test", utils:trim("test")), + ?assertEqual("", utils:trim(" ")). + +format_currency_test() -> + ?assertEqual("$10.50", utils:format_currency(10.5)), + ?assertEqual("$0.99", utils:format_currency(0.99)), + ?assertEqual("$100.00", utils:format_currency(100.0)). + +validate_input_test() -> + ?assertEqual(true, utils:validate_input(email, "test@example.com")), + ?assertEqual(false, utils:validate_input(email, "invalid")), + ?assertEqual(true, utils:validate_input(age, 25)), + ?assertEqual(false, utils:validate_input(age, -5)), + ?assertEqual(true, utils:validate_input(name, "John")), + ?assertEqual(false, utils:validate_input(name, "")). + +%%%=================================================================== +%%% List utility tests +%%%=================================================================== + +find_by_id_test() -> + Items = [{item, 1, "first"}, {item, 2, "second"}, {item, 3, "third"}], + ?assertEqual({ok, {item, 2, "second"}}, utils:find_by_id(2, Items)), + ?assertEqual({error, not_found}, utils:find_by_id(999, Items)). + +safe_nth_test() -> + List = [a, b, c, d, e], + ?assertEqual({ok, c}, utils:safe_nth(3, List)), + ?assertEqual({error, out_of_bounds}, utils:safe_nth(10, List)), + ?assertEqual({error, out_of_bounds}, utils:safe_nth(0, List)). + +%%%=================================================================== +%%% Math utility tests +%%%=================================================================== + +calculate_discount_test() -> + ?assertEqual(90.0, utils:calculate_discount(100.0, 10.0)), + ?assertEqual(75.0, utils:calculate_discount(100.0, 25.0)), + ?assertEqual(100.0, utils:calculate_discount(100.0, 0.0)). + +round_to_decimal_test() -> + ?assertEqual(10.99, utils:round_to_decimal(10.9876, 2)), + ?assertEqual(15.0, utils:round_to_decimal(15.0001, 2)), + ?assertEqual(0.33, utils:round_to_decimal(1/3, 2)). + +percentage_test() -> + ?assertEqual(50.0, utils:percentage(50, 100)), + ?assertEqual(25.0, utils:percentage(1, 4)), + ?assertEqual(0.0, utils:percentage(10, 0)). + +%%%=================================================================== +%%% Date/Time utility tests +%%%=================================================================== + +timestamp_test() -> + Timestamp = utils:timestamp(), + ?assert(is_integer(Timestamp)), + ?assert(Timestamp > 0). + +format_datetime_test() -> + % Test with a known timestamp + Formatted = utils:format_datetime(1234567890), + ?assert(is_list(Formatted)), + ?assert(length(Formatted) > 0). + +days_between_test() -> + Day1 = 1000000, + Day2 = 1000000 + (3 * 24 * 3600), % 3 days later + ?assertEqual(3, utils:days_between(Day1, Day2)), + ?assertEqual(3, utils:days_between(Day2, Day1)). \ No newline at end of file diff --git a/test/resources/repos/fortran/test_repo/diagnostics_sample.f90 b/test/resources/repos/fortran/test_repo/diagnostics_sample.f90 new file mode 100644 index 0000000..2c23e5d --- /dev/null +++ b/test/resources/repos/fortran/test_repo/diagnostics_sample.f90 @@ -0,0 +1,5 @@ +program diagnostics_sample + implicit none + integer :: duplicated_value + real :: duplicated_value +end program diagnostics_sample diff --git a/test/resources/repos/fortran/test_repo/main.f90 b/test/resources/repos/fortran/test_repo/main.f90 new file mode 100644 index 0000000..6ca2095 --- /dev/null +++ b/test/resources/repos/fortran/test_repo/main.f90 @@ -0,0 +1,16 @@ +program test_program + use math_utils + implicit none + real :: result + + ! Test addition + result = add_numbers(5.0, 3.0) + call print_result(result) + + ! Test multiplication + result = multiply_numbers(4.0, 2.0) + call print_result(result) + + print *, "All tests completed" +end program test_program + diff --git a/test/resources/repos/fortran/test_repo/modules/geometry.f90 b/test/resources/repos/fortran/test_repo/modules/geometry.f90 new file mode 100644 index 0000000..677f9e8 --- /dev/null +++ b/test/resources/repos/fortran/test_repo/modules/geometry.f90 @@ -0,0 +1,46 @@ +module geometry_types + implicit none + + ! Simple type definition + type Point2D + real :: x, y + end type Point2D + + ! Type with double colon syntax + type :: Circle + real :: radius + type(Point2D) :: center + end type Circle + + ! Type with extends (inheritance) + type, extends(Point2D) :: Point3D + real :: z + end type Point3D + + ! Named interface + interface distance + module procedure distance_2d, distance_3d + end interface distance + +contains + + function distance_2d(p1, p2) result(dist) + type(Point2D), intent(in) :: p1, p2 + real :: dist + dist = sqrt((p2%x - p1%x)**2 + (p2%y - p1%y)**2) + end function distance_2d + + function distance_3d(p1, p2) result(dist) + type(Point3D), intent(in) :: p1, p2 + real :: dist + dist = sqrt((p2%x - p1%x)**2 + (p2%y - p1%y)**2 + (p2%z - p1%z)**2) + end function distance_3d + + function circle_area(c) result(area) + type(Circle), intent(in) :: c + real :: area + real, parameter :: pi = 3.14159265359 + area = pi * c%radius**2 + end function circle_area + +end module geometry_types diff --git a/test/resources/repos/fortran/test_repo/modules/math_utils.f90 b/test/resources/repos/fortran/test_repo/modules/math_utils.f90 new file mode 100644 index 0000000..8b5707a --- /dev/null +++ b/test/resources/repos/fortran/test_repo/modules/math_utils.f90 @@ -0,0 +1,22 @@ +module math_utils + implicit none + contains + + function add_numbers(a, b) result(sum) + real, intent(in) :: a, b + real :: sum + sum = a + b + end function add_numbers + + function multiply_numbers(x, y) result(product) + real, intent(in) :: x, y + real :: product + product = x * y + end function multiply_numbers + + subroutine print_result(value) + real, intent(in) :: value + print *, "Result is:", value + end subroutine print_result +end module math_utils + diff --git a/test/resources/repos/fsharp/test_repo/.gitignore b/test/resources/repos/fsharp/test_repo/.gitignore new file mode 100644 index 0000000..975cf30 --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +*.user +.vscode/ +.ionide/ \ No newline at end of file diff --git a/test/resources/repos/fsharp/test_repo/Calculator.fs b/test/resources/repos/fsharp/test_repo/Calculator.fs new file mode 100644 index 0000000..e332945 --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/Calculator.fs @@ -0,0 +1,32 @@ +module Calculator + +/// Simple calculator functions +let add a b = a + b + +let subtract a b = a - b + +let multiply a b = a * b + +let divide a b = + if b = 0 then + failwith "Cannot divide by zero" + else + (float a) / (float b) + +/// More complex operations +let square x = x * x + +let factorial n = + if n <= 0 then 1 + else + let rec factorialHelper acc n = + if n <= 1 then acc + else factorialHelper (acc * n) (n - 1) + factorialHelper 1 n + +/// Calculator type with instance methods +type CalculatorClass() = + member this.Add(a, b) = add a b + member this.Subtract(a, b) = subtract a b + member this.Multiply(a, b) = multiply a b + member this.Divide(a, b) = divide a b \ No newline at end of file diff --git a/test/resources/repos/fsharp/test_repo/DiagnosticsSample.fs b/test/resources/repos/fsharp/test_repo/DiagnosticsSample.fs new file mode 100644 index 0000000..815b844 --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/DiagnosticsSample.fs @@ -0,0 +1,11 @@ +module DiagnosticsSample + +let brokenConsumerValue = 1 + +let brokenFactory () = + missingGreeting + +let brokenConsumer () = + let value = brokenFactory () + printfn "%A" value + missingConsumerValue diff --git a/test/resources/repos/fsharp/test_repo/Formatter.fs b/test/resources/repos/fsharp/test_repo/Formatter.fs new file mode 100644 index 0000000..52085d3 --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/Formatter.fs @@ -0,0 +1,8 @@ +module Formatter + +type IGreeter = + abstract member FormatGreeting: string -> string + +type ConsoleGreeter() = + interface IGreeter with + member _.FormatGreeting(name: string) = sprintf "Hello, %s!" name diff --git a/test/resources/repos/fsharp/test_repo/Models/Person.fs b/test/resources/repos/fsharp/test_repo/Models/Person.fs new file mode 100644 index 0000000..a296692 --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/Models/Person.fs @@ -0,0 +1,44 @@ +namespace Models + +/// Person record type +type Person = { + Name: string + Age: int + Email: string option +} + +module PersonModule = + /// Create a new person + let createPerson name age email = + { Name = name; Age = age; Email = email } + + /// Check if person is an adult + let isAdult person = + person.Age >= 18 + + /// Get display name + let getDisplayName person = + match person.Email with + | Some email -> $"{person.Name} ({email})" + | None -> person.Name + + /// Update person age + let updateAge newAge person = + { person with Age = newAge } + +/// Address type +type Address = { + Street: string + City: string + ZipCode: string + Country: string +} + +/// Employee type that extends Person concept +type Employee = { + Person: Person + EmployeeId: int + Department: string + Salary: decimal + Address: Address option +} \ No newline at end of file diff --git a/test/resources/repos/fsharp/test_repo/Program.fs b/test/resources/repos/fsharp/test_repo/Program.fs new file mode 100644 index 0000000..3305598 --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/Program.fs @@ -0,0 +1,45 @@ +module Program + +open Calculator +open Formatter +open Models +open Shapes + +[] +let main argv = + printfn "Hello, F# World!" + + // Test calculator functions + let result1 = add 5 3 + printfn "5 + 3 = %d" result1 + + let result2 = subtract 10 4 + printfn "10 - 4 = %d" result2 + + let result3 = multiply 6 7 + printfn "6 * 7 = %d" result3 + + let result4 = divide 15 3 + printfn "15 / 3 = %.2f" result4 + + // Test calculator class + let calc = CalculatorClass() + let classResult = calc.Add(20, 5) + printfn "Calculator class: 20 + 5 = %d" classResult + + let greeter: IGreeter = ConsoleGreeter() :> IGreeter + printfn "%s" (greeter.FormatGreeting("World")) + + let shape: Shape = Circle(2.0) :> Shape + printfn "Area: %.2f" (shape.Area()) + + // Test person module + let person = PersonModule.createPerson "Alice Smith" 25 (Some "alice@example.com") + printfn "Person: %s" (PersonModule.getDisplayName person) + printfn "Is adult: %b" (PersonModule.isAdult person) + + // Test factorial + let fact5 = factorial 5 + printfn "5! = %d" fact5 + + 0 // return success diff --git a/test/resources/repos/fsharp/test_repo/README.md b/test/resources/repos/fsharp/test_repo/README.md new file mode 100644 index 0000000..fdef17c --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/README.md @@ -0,0 +1,21 @@ +# F# Test Project + +This is a test F# project for testing Serena's F# language support. + +## Project Structure + +- `Program.fs` - Main program entry point +- `Calculator.fs` - Calculator functions and types +- `Models/Person.fs` - Person and Employee data models + +## Build + +```bash +dotnet build +``` + +## Run + +```bash +dotnet run +``` \ No newline at end of file diff --git a/test/resources/repos/fsharp/test_repo/Shapes.fs b/test/resources/repos/fsharp/test_repo/Shapes.fs new file mode 100644 index 0000000..d67a98a --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/Shapes.fs @@ -0,0 +1,10 @@ +module Shapes + +[] +type Shape() = + abstract member Area: unit -> float + +type Circle(radius: float) = + inherit Shape() + + override _.Area() = System.Math.PI * radius * radius diff --git a/test/resources/repos/fsharp/test_repo/TestProject.fsproj b/test/resources/repos/fsharp/test_repo/TestProject.fsproj new file mode 100644 index 0000000..e44baee --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/TestProject.fsproj @@ -0,0 +1,17 @@ + + + + Exe + net8.0 + + + + + + + + + + + + diff --git a/test/resources/repos/go/test_repo/buildtags/foo.go b/test/resources/repos/go/test_repo/buildtags/foo.go new file mode 100644 index 0000000..43df3dc --- /dev/null +++ b/test/resources/repos/go/test_repo/buildtags/foo.go @@ -0,0 +1,8 @@ +//go:build foo +// +build foo + +package buildtags + +type XFoo struct { + Value int +} diff --git a/test/resources/repos/go/test_repo/buildtags/notfoo.go b/test/resources/repos/go/test_repo/buildtags/notfoo.go new file mode 100644 index 0000000..831a63b --- /dev/null +++ b/test/resources/repos/go/test_repo/buildtags/notfoo.go @@ -0,0 +1,8 @@ +//go:build !foo +// +build !foo + +package buildtags + +type XNotFoo struct { + Value int +} diff --git a/test/resources/repos/go/test_repo/diagnostics_sample.go b/test/resources/repos/go/test_repo/diagnostics_sample.go new file mode 100644 index 0000000..15f7d6a --- /dev/null +++ b/test/resources/repos/go/test_repo/diagnostics_sample.go @@ -0,0 +1,11 @@ +package main + +func brokenFactory() string { + return missingGreeting +} + +func brokenConsumer() { + value := brokenFactory() + _ = value + _ = missingConsumerValue +} diff --git a/test/resources/repos/go/test_repo/go.mod b/test/resources/repos/go/test_repo/go.mod new file mode 100644 index 0000000..59623c2 --- /dev/null +++ b/test/resources/repos/go/test_repo/go.mod @@ -0,0 +1,3 @@ +module test_repo + +go 1.21 diff --git a/test/resources/repos/go/test_repo/main.go b/test/resources/repos/go/test_repo/main.go new file mode 100644 index 0000000..4441289 --- /dev/null +++ b/test/resources/repos/go/test_repo/main.go @@ -0,0 +1,36 @@ +package main + +import "fmt" + +func main() { + fmt.Println("Hello, Go!") + Helper() + var greeter Greeter = ConsoleGreeter{} + fmt.Println(greeter.FormatGreeting("Go")) +} + +func Helper() { + fmt.Println("Helper function called") +} + +type DemoStruct struct { + Field int +} + +func (d *DemoStruct) Value() int { + return d.Field +} + +func UsingHelper() { + Helper() +} + +type Greeter interface { + FormatGreeting(name string) string +} + +type ConsoleGreeter struct{} + +func (ConsoleGreeter) FormatGreeting(name string) string { + return "Hello, " + name + "!" +} diff --git a/test/resources/repos/groovy/test_repo/.gitignore b/test/resources/repos/groovy/test_repo/.gitignore new file mode 100644 index 0000000..3bb19ff --- /dev/null +++ b/test/resources/repos/groovy/test_repo/.gitignore @@ -0,0 +1 @@ +.gradle/ diff --git a/test/resources/repos/groovy/test_repo/build.gradle b/test/resources/repos/groovy/test_repo/build.gradle new file mode 100644 index 0000000..61a1de3 --- /dev/null +++ b/test/resources/repos/groovy/test_repo/build.gradle @@ -0,0 +1,7 @@ +plugins { + id 'groovy' +} + +repositories { + mavenCentral() +} diff --git a/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/DiagnosticsSample.groovy b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/DiagnosticsSample.groovy new file mode 100644 index 0000000..a176203 --- /dev/null +++ b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/DiagnosticsSample.groovy @@ -0,0 +1,13 @@ +package com.example + +class DiagnosticsSample { + static String brokenFactory() { + missingGreeting + } + + static void brokenConsumer() { + def value = brokenFactory() + println(value) + println(missingConsumerValue) + } +} diff --git a/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Main.groovy b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Main.groovy new file mode 100644 index 0000000..0e42fe7 --- /dev/null +++ b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Main.groovy @@ -0,0 +1,9 @@ +package com.example + +class Main { + static void main(String[] args) { + Utils.printHello() + Model model = new Model("Cascade") + println(model.name) + } +} diff --git a/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Model.groovy b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Model.groovy new file mode 100644 index 0000000..f6451ca --- /dev/null +++ b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Model.groovy @@ -0,0 +1,9 @@ +package com.example + +class Model { + String name + + Model(String name) { + this.name = name + } +} diff --git a/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/ModelUser.groovy b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/ModelUser.groovy new file mode 100644 index 0000000..bde7781 --- /dev/null +++ b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/ModelUser.groovy @@ -0,0 +1,8 @@ +package com.example + +class ModelUser { + static void main(String[] args) { + Model model = new Model("Cascade") + println(model.name) + } +} diff --git a/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Utils.groovy b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Utils.groovy new file mode 100644 index 0000000..b2cf63a --- /dev/null +++ b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Utils.groovy @@ -0,0 +1,7 @@ +package com.example + +class Utils { + static void printHello() { + println("Hello from Utils!") + } +} diff --git a/test/resources/repos/haskell/test_repo/app/Main.hs b/test/resources/repos/haskell/test_repo/app/Main.hs new file mode 100644 index 0000000..3f8dddb --- /dev/null +++ b/test/resources/repos/haskell/test_repo/app/Main.hs @@ -0,0 +1,26 @@ +module Main (main) where + +import Calculator +import Helper + +main :: IO () +main = do + let calc = Calculator "TestCalc" 1 + putStrLn $ "Using " ++ calcName calc ++ " version " ++ show (calcVersion calc) + + -- Test add function (cross-file reference) + let result1 = add 5 3 + putStrLn $ "5 + 3 = " ++ show result1 + + -- Test subtract (uses validateNumber from Helper) + let result2 = Calculator.subtract 10 4 + putStrLn $ "10 - 4 = " ++ show result2 + + -- Test calculate function + case calculate calc "multiply" 6 7 of + Just result -> putStrLn $ "6 * 7 = " ++ show result + Nothing -> putStrLn "Calculation failed" + + -- Test helper functions directly + putStrLn $ "Is 5 positive? " ++ show (isPositive 5) + putStrLn $ "Absolute of -10: " ++ show (absolute (-10)) diff --git a/test/resources/repos/haskell/test_repo/package.yaml b/test/resources/repos/haskell/test_repo/package.yaml new file mode 100644 index 0000000..f489e49 --- /dev/null +++ b/test/resources/repos/haskell/test_repo/package.yaml @@ -0,0 +1,25 @@ +name: haskell-test-repo +version: 0.1.0.0 +github: "test/haskell-test-repo" +license: BSD3 +author: "Test Author" +maintainer: "test@example.com" + +dependencies: + - base >= 4.7 && < 5 + +library: + source-dirs: src + exposed-modules: + - Calculator + - Helper + +executables: + haskell-test-repo-exe: + main: Main.hs + source-dirs: app + dependencies: + - haskell-test-repo + +default-extensions: + - OverloadedStrings diff --git a/test/resources/repos/haskell/test_repo/src/Calculator.hs b/test/resources/repos/haskell/test_repo/src/Calculator.hs new file mode 100644 index 0000000..40c1839 --- /dev/null +++ b/test/resources/repos/haskell/test_repo/src/Calculator.hs @@ -0,0 +1,43 @@ +module Calculator + ( Calculator(..) + , add + , subtract + , multiply + , divide + , calculate + ) where + +import Prelude hiding (subtract) +import Helper (validateNumber) + +-- | A simple calculator data type +data Calculator = Calculator + { calcName :: String + , calcVersion :: Int + } deriving (Show, Eq) + +-- | Add two numbers +add :: Int -> Int -> Int +add x y = validateNumber x + validateNumber y + +-- | Subtract two numbers +subtract :: Int -> Int -> Int +subtract x y = validateNumber x - validateNumber y + +-- | Multiply two numbers +multiply :: Int -> Int -> Int +multiply x y = x * y + +-- | Divide two numbers (returns Maybe to handle division by zero) +divide :: Int -> Int -> Maybe Int +divide _ 0 = Nothing +divide x y = Just (x `div` y) + +-- | Perform a calculation based on operator +calculate :: Calculator -> String -> Int -> Int -> Maybe Int +calculate calc op x y = case op of + "add" -> Just (add x y) + "subtract" -> Just (subtract x y) + "multiply" -> Just (multiply x y) + "divide" -> divide x y + _ -> Nothing diff --git a/test/resources/repos/haskell/test_repo/src/DiagnosticsSample.hs b/test/resources/repos/haskell/test_repo/src/DiagnosticsSample.hs new file mode 100644 index 0000000..e5777b1 --- /dev/null +++ b/test/resources/repos/haskell/test_repo/src/DiagnosticsSample.hs @@ -0,0 +1,7 @@ +module DiagnosticsSample where + +brokenFactory :: String +brokenFactory = missingGreeting + +brokenConsumer :: String +brokenConsumer = missingConsumerValue diff --git a/test/resources/repos/haskell/test_repo/src/Helper.hs b/test/resources/repos/haskell/test_repo/src/Helper.hs new file mode 100644 index 0000000..9fa04c3 --- /dev/null +++ b/test/resources/repos/haskell/test_repo/src/Helper.hs @@ -0,0 +1,22 @@ +module Helper + ( validateNumber + , isPositive + , isNegative + , absolute + ) where + +-- | Validate that a number is not zero (for demonstration) +validateNumber :: Int -> Int +validateNumber x = if x == 0 then error "Zero not allowed" else x + +-- | Check if a number is positive +isPositive :: Int -> Bool +isPositive x = x > 0 + +-- | Check if a number is negative +isNegative :: Int -> Bool +isNegative x = x < 0 + +-- | Get absolute value +absolute :: Int -> Int +absolute x = if isNegative x then negate x else x diff --git a/test/resources/repos/haskell/test_repo/stack.yaml b/test/resources/repos/haskell/test_repo/stack.yaml new file mode 100644 index 0000000..1ecc367 --- /dev/null +++ b/test/resources/repos/haskell/test_repo/stack.yaml @@ -0,0 +1,5 @@ +resolver: ghc-9.8.4 +system-ghc: true +install-ghc: false +packages: + - . diff --git a/test/resources/repos/haxe/test_repo/.gitignore b/test/resources/repos/haxe/test_repo/.gitignore new file mode 100644 index 0000000..e714aff --- /dev/null +++ b/test/resources/repos/haxe/test_repo/.gitignore @@ -0,0 +1,3 @@ +export/ +dump/ +bin/ diff --git a/test/resources/repos/haxe/test_repo/build.hxml b/test/resources/repos/haxe/test_repo/build.hxml new file mode 100644 index 0000000..40cd557 --- /dev/null +++ b/test/resources/repos/haxe/test_repo/build.hxml @@ -0,0 +1,3 @@ +-cp src +-main Main +--no-output diff --git a/test/resources/repos/haxe/test_repo/src/DiagnosticsSample.hx b/test/resources/repos/haxe/test_repo/src/DiagnosticsSample.hx new file mode 100644 index 0000000..3b2e2c2 --- /dev/null +++ b/test/resources/repos/haxe/test_repo/src/DiagnosticsSample.hx @@ -0,0 +1,10 @@ +class DiagnosticsSample { + static function brokenFactory():String { + return missingGreeting; + } + + static function brokenConsumer():Void { + trace(brokenFactory()); + trace(missingConsumerValue); + } +} diff --git a/test/resources/repos/haxe/test_repo/src/Main.hx b/test/resources/repos/haxe/test_repo/src/Main.hx new file mode 100644 index 0000000..a0b3252 --- /dev/null +++ b/test/resources/repos/haxe/test_repo/src/Main.hx @@ -0,0 +1,40 @@ +import utils.Helper; + +/** + * Main class for testing Haxe language server functionality. + * + * This class tests: + * - Symbol discovery (class, methods, fields) + * - Within-file references + * - Cross-file references to utils.Helper + */ +class Main { + var message:String; + var count:Int; + + public function new() { + message = greet("World"); + count = Helper.addNumbers(5, 10); + } + + /** + * Greet someone by name. + */ + public function greet(name:String):String { + return "Hello, " + name + "!"; + } + + /** + * Calculate and return the formatted result. + */ + public function calculateResult():String { + var sum = Helper.addNumbers(count, 20); + var formatted = Helper.formatMessage(message); + return formatted + " (sum: " + Std.string(sum) + ")"; + } + + public static function main() { + var app = new Main(); + Sys.println(app.calculateResult()); + } +} diff --git a/test/resources/repos/haxe/test_repo/src/utils/Helper.hx b/test/resources/repos/haxe/test_repo/src/utils/Helper.hx new file mode 100644 index 0000000..0c89950 --- /dev/null +++ b/test/resources/repos/haxe/test_repo/src/utils/Helper.hx @@ -0,0 +1,29 @@ +package utils; + +/** + * Utility functions for the Haxe test application. + * + * This class provides helper functions used by the Main class. + */ +class Helper { + /** + * Format a message by adding brackets around it. + */ + public static function formatMessage(msg:String):String { + return "[ " + msg + " ]"; + } + + /** + * Add two numbers together. + */ + public static function addNumbers(x:Int, y:Int):Int { + return x + y; + } + + /** + * Multiply two numbers. + */ + public static function multiplyNumbers(x:Int, y:Int):Int { + return x * y; + } +} diff --git a/test/resources/repos/hlsl/test_repo/common.hlsl b/test/resources/repos/hlsl/test_repo/common.hlsl new file mode 100644 index 0000000..1da49f8 --- /dev/null +++ b/test/resources/repos/hlsl/test_repo/common.hlsl @@ -0,0 +1,29 @@ +#ifndef COMMON_HLSL +#define COMMON_HLSL + +struct VertexInput +{ + float3 position : POSITION; + float3 normal : NORMAL; + float2 uv : TEXCOORD0; +}; + +struct VertexOutput +{ + float4 clipPos : SV_POSITION; + float3 worldNormal : TEXCOORD0; + float2 uv : TEXCOORD1; +}; + +float3 SafeNormalize(float3 v) +{ + float len = length(v); + return len > 0.0001 ? v / len : float3(0, 0, 0); +} + +float Remap(float value, float fromMin, float fromMax, float toMin, float toMax) +{ + return toMin + (value - fromMin) * (toMax - toMin) / (fromMax - fromMin); +} + +#endif // COMMON_HLSL diff --git a/test/resources/repos/hlsl/test_repo/compute_test.hlsl b/test/resources/repos/hlsl/test_repo/compute_test.hlsl new file mode 100644 index 0000000..f10531e --- /dev/null +++ b/test/resources/repos/hlsl/test_repo/compute_test.hlsl @@ -0,0 +1,26 @@ +#include "common.hlsl" + +RWTexture2D OutputTexture : register(u0); +Texture2D InputTexture : register(t0); + +cbuffer ComputeParams : register(b0) +{ + uint2 TextureSize; + float BlurRadius; + float _Pad; +}; + +[numthreads(8, 8, 1)] +void CSMain(uint3 id : SV_DispatchThreadID) +{ + if (id.x >= TextureSize.x || id.y >= TextureSize.y) + return; + + float4 color = InputTexture[id.xy]; + float3 remapped = float3( + Remap(color.r, 0.0, 1.0, 0.2, 0.8), + Remap(color.g, 0.0, 1.0, 0.2, 0.8), + Remap(color.b, 0.0, 1.0, 0.2, 0.8) + ); + OutputTexture[id.xy] = float4(remapped, color.a); +} diff --git a/test/resources/repos/hlsl/test_repo/diagnostics_sample.hlsl b/test/resources/repos/hlsl/test_repo/diagnostics_sample.hlsl new file mode 100644 index 0000000..ffc8075 --- /dev/null +++ b/test/resources/repos/hlsl/test_repo/diagnostics_sample.hlsl @@ -0,0 +1,3 @@ +float4 main() : SV_Target { + return missingGreeting; +} diff --git a/test/resources/repos/hlsl/test_repo/lighting.hlsl b/test/resources/repos/hlsl/test_repo/lighting.hlsl new file mode 100644 index 0000000..2334b63 --- /dev/null +++ b/test/resources/repos/hlsl/test_repo/lighting.hlsl @@ -0,0 +1,37 @@ +#ifndef LIGHTING_HLSL +#define LIGHTING_HLSL + +#include "common.hlsl" + +cbuffer LightingConstants : register(b0) +{ + float4x4 ViewProjection; + float3 LightDirection; + float LightIntensity; + float3 AmbientColor; + float _Padding; +}; + +float3 CalculateDiffuse(float3 normal, float3 lightDir, float3 albedo) +{ + float ndotl = max(dot(normal, -lightDir), 0.0); + return albedo * ndotl; +} + +float3 CalculateSpecular(float3 normal, float3 lightDir, float3 viewDir, float shininess) +{ + float3 halfVec = SafeNormalize(-lightDir + viewDir); + float ndoth = max(dot(normal, halfVec), 0.0); + return pow(ndoth, shininess); +} + +VertexOutput TransformVertex(VertexInput input) +{ + VertexOutput output; + output.clipPos = mul(ViewProjection, float4(input.position, 1.0)); + output.worldNormal = input.normal; + output.uv = input.uv; + return output; +} + +#endif // LIGHTING_HLSL diff --git a/test/resources/repos/hlsl/test_repo/terrain/terrain_sdf.hlsl b/test/resources/repos/hlsl/test_repo/terrain/terrain_sdf.hlsl new file mode 100644 index 0000000..a748a27 --- /dev/null +++ b/test/resources/repos/hlsl/test_repo/terrain/terrain_sdf.hlsl @@ -0,0 +1,33 @@ +#ifndef TERRAIN_SDF_HLSL +#define TERRAIN_SDF_HLSL + +#include "../common.hlsl" + +struct SDFBrickData +{ + float3 center; + float halfExtent; + int resolution; + float maxDistance; +}; + +float3 WorldOffset; + +float SampleSDF(float3 worldPos, SDFBrickData brick) +{ + float3 localPos = worldPos - brick.center; + float dist = length(localPos) - brick.halfExtent; + return dist; +} + +float3 CalculateGradient(float3 worldPos, SDFBrickData brick) +{ + float eps = 0.01; + float3 gradient; + gradient.x = SampleSDF(worldPos + float3(eps, 0, 0), brick) - SampleSDF(worldPos - float3(eps, 0, 0), brick); + gradient.y = SampleSDF(worldPos + float3(0, eps, 0), brick) - SampleSDF(worldPos - float3(0, eps, 0), brick); + gradient.z = SampleSDF(worldPos + float3(0, 0, eps), brick) - SampleSDF(worldPos - float3(0, 0, eps), brick); + return SafeNormalize(gradient); +} + +#endif // TERRAIN_SDF_HLSL diff --git a/test/resources/repos/html/test_repo/about.html b/test/resources/repos/html/test_repo/about.html new file mode 100644 index 0000000..4adb182 --- /dev/null +++ b/test/resources/repos/html/test_repo/about.html @@ -0,0 +1,22 @@ + + + + + About — Serena HTML Test + + + +
    +
    +
    +

    About this repo

    +

    Used by the Serena language server tests.

    +
    +
    + + diff --git a/test/resources/repos/html/test_repo/diagnostics_sample.html b/test/resources/repos/html/test_repo/diagnostics_sample.html new file mode 100644 index 0000000..1b2164f --- /dev/null +++ b/test/resources/repos/html/test_repo/diagnostics_sample.html @@ -0,0 +1,14 @@ + + + + + + Diagnostics Sample + + +
    first
    +
    second
    + + diff --git a/test/resources/repos/html/test_repo/index.html b/test/resources/repos/html/test_repo/index.html new file mode 100644 index 0000000..4691b2d --- /dev/null +++ b/test/resources/repos/html/test_repo/index.html @@ -0,0 +1,35 @@ + + + + + Serena HTML Test + + + + +
    +
    +

    Introduction

    +

    A minimal HTML test fixture used by the Serena language-server tests.

    +
    +
    +

    Features

    +
      +
    • Symbol detection
    • +
    • Element navigation
    • +
    • Cross-file links
    • +
    +
    +
    +
    +

    © Serena

    +
    + + + diff --git a/test/resources/repos/java/test_repo/pom.xml b/test/resources/repos/java/test_repo/pom.xml new file mode 100644 index 0000000..facb536 --- /dev/null +++ b/test/resources/repos/java/test_repo/pom.xml @@ -0,0 +1,46 @@ + + 4.0.0 + org.example + test_repo + 1.0-SNAPSHOT + jar + Java Test Repo + + 21 + 21 + 3.13.0 + 1.18.38 + + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven.compiler.plugin.version} + + 21 + 21 + + + org.projectlombok + lombok + ${lombok.version} + + + + + + + diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/ConsoleGreeter.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/ConsoleGreeter.java new file mode 100644 index 0000000..e8b85c9 --- /dev/null +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/ConsoleGreeter.java @@ -0,0 +1,8 @@ +package test_repo; + +public class ConsoleGreeter implements Greeter { + @Override + public String formatGreeting(String name) { + return "Hello, " + name + "!"; + } +} diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/DefaultGreeter.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/DefaultGreeter.java new file mode 100644 index 0000000..8ed25b1 --- /dev/null +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/DefaultGreeter.java @@ -0,0 +1,13 @@ +package test_repo; + +class DefaultGreeter implements LombokModel.Greeter { + @Override + public String greet() { + return "hi"; + } + + @Override + public String farewell() { + return "bye"; + } +} diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/DiagnosticsSample.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/DiagnosticsSample.java new file mode 100644 index 0000000..53f8406 --- /dev/null +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/DiagnosticsSample.java @@ -0,0 +1,13 @@ +package test_repo; + +public class DiagnosticsSample { + public static String brokenFactory() { + return missingGreeting; + } + + public static void brokenConsumer() { + String value = brokenFactory(); + System.out.println(value); + System.out.println(missingConsumerValue); + } +} diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/FluentLombokModel.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/FluentLombokModel.java new file mode 100644 index 0000000..8b44870 --- /dev/null +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/FluentLombokModel.java @@ -0,0 +1,14 @@ +package test_repo; + +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; + +/** Verifies prefix-stripped fluent accessors generated by @Accessors(fluent=true). */ +@Getter +@Setter +@Accessors(fluent = true) +public class FluentLombokModel { + private String host; + private String tag; +} diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/Greeter.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/Greeter.java new file mode 100644 index 0000000..569d500 --- /dev/null +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/Greeter.java @@ -0,0 +1,5 @@ +package test_repo; + +public interface Greeter { + String formatGreeting(String name); +} diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/LombokModel.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/LombokModel.java new file mode 100644 index 0000000..f003d51 --- /dev/null +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/LombokModel.java @@ -0,0 +1,27 @@ +package test_repo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.With; +import lombok.experimental.Delegate; + +@Data +@Builder(toBuilder = true) +@With +@AllArgsConstructor +@NoArgsConstructor +public class LombokModel { + private String name; + private int age; + + /** Lombok @Delegate forwards every method of Greeter to delegate.* (covers @Delegate generation). */ + public interface Greeter { + String greet(); + String farewell(); + } + + @Delegate + private final Greeter delegate = new DefaultGreeter(); +} diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/Main.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/Main.java new file mode 100644 index 0000000..29c0688 --- /dev/null +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/Main.java @@ -0,0 +1,15 @@ +package test_repo; + +public class Main { + public static void main(String[] args) { + Utils.printHello(); + Model model = new Model("Cascade"); + System.out.println(model.getName()); + acceptModel(model); + Greeter greeter = new ConsoleGreeter(); + System.out.println(greeter.formatGreeting("Cascade")); + } + public static void acceptModel(Model m) { + // Do nothing, just for LSP reference + } +} diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/Model.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/Model.java new file mode 100644 index 0000000..8834f3c --- /dev/null +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/Model.java @@ -0,0 +1,24 @@ +package test_repo; + +/** + * A simple model class that holds a name and provides methods to retrieve it. + */ +public class Model { + private String name; + + public Model(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public String getName(int maxChars) { + if (name.length() <= maxChars) { + return name; + } else { + return name.substring(0, maxChars) + "..."; + } + } +} diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/ModelUser.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/ModelUser.java new file mode 100644 index 0000000..9cffa9f --- /dev/null +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/ModelUser.java @@ -0,0 +1,8 @@ +package test_repo; + +public class ModelUser { + public static void main(String[] args) { + Model model = new Model("Cascade"); + System.out.println(model.getName()); + } +} diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/Utils.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/Utils.java new file mode 100644 index 0000000..107f4c8 --- /dev/null +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/Utils.java @@ -0,0 +1,7 @@ +package test_repo; + +public class Utils { + public static void printHello() { + System.out.println("Hello from Utils!"); + } +} diff --git a/test/resources/repos/json/test_repo/config.json b/test/resources/repos/json/test_repo/config.json new file mode 100644 index 0000000..78ec641 --- /dev/null +++ b/test/resources/repos/json/test_repo/config.json @@ -0,0 +1,24 @@ +{ + "app": { + "name": "test-application", + "version": "1.0.0", + "port": 8080, + "debug": true + }, + "database": { + "host": "localhost", + "port": 5432, + "name": "testdb", + "username": "admin" + }, + "logging": { + "level": "info", + "format": "json", + "outputs": ["console", "file"] + }, + "features": { + "authentication": true, + "caching": false, + "monitoring": true + } +} diff --git a/test/resources/repos/json/test_repo/data.json b/test/resources/repos/json/test_repo/data.json new file mode 100644 index 0000000..329491b --- /dev/null +++ b/test/resources/repos/json/test_repo/data.json @@ -0,0 +1,30 @@ +{ + "users": [ + { + "id": 1, + "name": "Alice", + "email": "alice@example.com", + "roles": ["admin", "user"] + }, + { + "id": 2, + "name": "Bob", + "email": "bob@example.com", + "roles": ["user"] + } + ], + "projects": [ + { + "id": 101, + "name": "Project Alpha", + "status": "active", + "tags": ["backend", "api"] + }, + { + "id": 102, + "name": "Project Beta", + "status": "planning", + "tags": ["frontend"] + } + ] +} diff --git a/test/resources/repos/json/test_repo/diagnostics_sample.json b/test/resources/repos/json/test_repo/diagnostics_sample.json new file mode 100644 index 0000000..0cc75ef --- /dev/null +++ b/test/resources/repos/json/test_repo/diagnostics_sample.json @@ -0,0 +1,4 @@ +{ + "name": "diagnostics", + "value": +} diff --git a/test/resources/repos/julia/test_repo/diagnostics_sample.jl b/test/resources/repos/julia/test_repo/diagnostics_sample.jl new file mode 100644 index 0000000..ef7e4a5 --- /dev/null +++ b/test/resources/repos/julia/test_repo/diagnostics_sample.jl @@ -0,0 +1,3 @@ +function broken_factory( + value = 1 +end diff --git a/test/resources/repos/julia/test_repo/lib/helper.jl b/test/resources/repos/julia/test_repo/lib/helper.jl new file mode 100644 index 0000000..c6a7e12 --- /dev/null +++ b/test/resources/repos/julia/test_repo/lib/helper.jl @@ -0,0 +1,5 @@ +module Helper + function say_hello() + println("Hello from the helper module!") + end +end diff --git a/test/resources/repos/julia/test_repo/main.jl b/test/resources/repos/julia/test_repo/main.jl new file mode 100644 index 0000000..fa31797 --- /dev/null +++ b/test/resources/repos/julia/test_repo/main.jl @@ -0,0 +1,13 @@ +include("lib/helper.jl") + +function calculate_sum(a, b) + return a + b +end + +function main() + result = calculate_sum(5, 3) # A within-file reference + println(result) + Helper.say_hello() # A cross-file reference +end + +main() diff --git a/test/resources/repos/kotlin/test_repo/.gitignore b/test/resources/repos/kotlin/test_repo/.gitignore new file mode 100644 index 0000000..3bb19ff --- /dev/null +++ b/test/resources/repos/kotlin/test_repo/.gitignore @@ -0,0 +1 @@ +.gradle/ diff --git a/test/resources/repos/kotlin/test_repo/build.gradle.kts b/test/resources/repos/kotlin/test_repo/build.gradle.kts new file mode 100644 index 0000000..60c1d63 --- /dev/null +++ b/test/resources/repos/kotlin/test_repo/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + kotlin("jvm") version "1.9.21" + application +} + +group = "test.serena" +version = "1.0-SNAPSHOT" + +repositories { + mavenCentral() +} \ No newline at end of file diff --git a/test/resources/repos/kotlin/test_repo/gradle/wrapper/gradle-wrapper.properties b/test/resources/repos/kotlin/test_repo/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2a84e18 --- /dev/null +++ b/test/resources/repos/kotlin/test_repo/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/test/resources/repos/kotlin/test_repo/gradlew b/test/resources/repos/kotlin/test_repo/gradlew new file mode 100644 index 0000000..ef07e01 --- /dev/null +++ b/test/resources/repos/kotlin/test_repo/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/test/resources/repos/kotlin/test_repo/gradlew.bat b/test/resources/repos/kotlin/test_repo/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/test/resources/repos/kotlin/test_repo/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/DiagnosticsSample.kt b/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/DiagnosticsSample.kt new file mode 100644 index 0000000..93f0b93 --- /dev/null +++ b/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/DiagnosticsSample.kt @@ -0,0 +1,11 @@ +package test_repo + +fun brokenFactory(): String { + return missingGreeting +} + +fun brokenConsumer() { + val value = brokenFactory() + println(value) + println(missingConsumerValue) +} diff --git a/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/Main.kt b/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/Main.kt new file mode 100644 index 0000000..ce2becc --- /dev/null +++ b/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/Main.kt @@ -0,0 +1,15 @@ +package test_repo + +object Main { + @JvmStatic + fun main(args: Array) { + Utils.printHello() + val model = Model("Cascade") + println(model.name) + acceptModel(model) + } + + fun acceptModel(m: Model?) { + // Do nothing, just for LSP reference + } +} \ No newline at end of file diff --git a/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/Model.kt b/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/Model.kt new file mode 100644 index 0000000..a32d18c --- /dev/null +++ b/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/Model.kt @@ -0,0 +1,3 @@ +package test_repo + +data class Model(val name: String) \ No newline at end of file diff --git a/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/ModelUser.kt b/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/ModelUser.kt new file mode 100644 index 0000000..5100436 --- /dev/null +++ b/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/ModelUser.kt @@ -0,0 +1,9 @@ +package test_repo + +object ModelUser { + @JvmStatic + fun main(args: Array) { + val model = Model("Cascade") + println(model.name) + } +} \ No newline at end of file diff --git a/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/Utils.kt b/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/Utils.kt new file mode 100644 index 0000000..cd2ba3c --- /dev/null +++ b/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/Utils.kt @@ -0,0 +1,7 @@ +package test_repo + +object Utils { + fun printHello() { + println("Hello from Utils!") + } +} \ No newline at end of file diff --git a/test/resources/repos/latex/test_repo/.gitignore b/test/resources/repos/latex/test_repo/.gitignore new file mode 100644 index 0000000..531e44b --- /dev/null +++ b/test/resources/repos/latex/test_repo/.gitignore @@ -0,0 +1,7 @@ +*.aux +*.log +*.out +*.pdf +*.toc +*.synctex.gz +build/ diff --git a/test/resources/repos/latex/test_repo/main.tex b/test/resources/repos/latex/test_repo/main.tex new file mode 100644 index 0000000..6b0cb8c --- /dev/null +++ b/test/resources/repos/latex/test_repo/main.tex @@ -0,0 +1,35 @@ +\documentclass{article} +\usepackage{amsmath} + +\title{Serena Texlab Smoke Test} +\author{Test Author} + +\begin{document} +\maketitle + +\section{Introduction} +\label{sec:intro} +This is the introduction. It points forward to Section~\ref{sec:methods}. + +\section{Methods} +\label{sec:methods} +The methods build on Section~\ref{sec:intro}. + +\subsection{Implementation Details} +\label{sec:details} +Details that refer back to Section~\ref{sec:methods} once more. + +\section{Conclusion} +\label{sec:conclusion} +Wrapping up, see Section~\ref{sec:details}. + +\input{sections/background.tex} + +For the broader context behind these methods, see Section~\ref{sec:background}. +The typesetting system itself is described by Lamport~\cite{lamport1994} and +Knuth~\cite{knuth1984}. + +\bibliographystyle{plain} +\bibliography{references} + +\end{document} diff --git a/test/resources/repos/latex/test_repo/references.bib b/test/resources/repos/latex/test_repo/references.bib new file mode 100644 index 0000000..25931be --- /dev/null +++ b/test/resources/repos/latex/test_repo/references.bib @@ -0,0 +1,13 @@ +@book{knuth1984, + author = {Knuth, Donald E.}, + title = {The {TeXbook}}, + publisher = {Addison-Wesley}, + year = {1984}, +} + +@book{lamport1994, + author = {Lamport, Leslie}, + title = {{LaTeX}: A Document Preparation System}, + publisher = {Addison-Wesley}, + year = {1994}, +} diff --git a/test/resources/repos/latex/test_repo/sections/background.tex b/test/resources/repos/latex/test_repo/sections/background.tex new file mode 100644 index 0000000..c9edfe8 --- /dev/null +++ b/test/resources/repos/latex/test_repo/sections/background.tex @@ -0,0 +1,9 @@ +\section{Background} +\label{sec:background} +The document model used here follows the sectioning structure of the TeX +typesetting system~\cite{knuth1984}. Cross-referencing between sections relies on +stable label keys, which is the behaviour this fixture exercises. + +\subsection{Prior Work} +\label{sec:prior-work} +A fuller description of the macro layer appears in the LaTeX manual~\cite{lamport1994}. diff --git a/test/resources/repos/latex/test_repo/slides.tex b/test/resources/repos/latex/test_repo/slides.tex new file mode 100644 index 0000000..d19f996 --- /dev/null +++ b/test/resources/repos/latex/test_repo/slides.tex @@ -0,0 +1,33 @@ +\documentclass{beamer} +\usetheme{default} + +\title{Serena Texlab Beamer Smoke Test} +\author{Test Author} + +\begin{document} + +\frame{\titlepage} + +\section{Overview} + +\begin{frame} +\frametitle{Introduction} +\label{frame:intro} +An introductory frame for the overview section. +\end{frame} + +\begin{frame} +\frametitle{Methodology} +\label{frame:method} +A frame describing the methodology, see Section~\ref{sec:results}. +\end{frame} + +\section{Results} +\label{sec:results} + +\begin{frame} +\frametitle{Findings} +A closing frame summarising the findings. +\end{frame} + +\end{document} diff --git a/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean b/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean new file mode 100644 index 0000000..169754f --- /dev/null +++ b/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean @@ -0,0 +1 @@ +def brokenFactory : Nat := diff --git a/test/resources/repos/lean4/test_repo/Helper.lean b/test/resources/repos/lean4/test_repo/Helper.lean new file mode 100644 index 0000000..6112bfc --- /dev/null +++ b/test/resources/repos/lean4/test_repo/Helper.lean @@ -0,0 +1,16 @@ +structure Calculator where + name : String + version : Nat + deriving Repr + +def add (x y : Nat) : Nat := + x + y + +def subtract (x y : Nat) : Int := + Int.ofNat x - Int.ofNat y + +def isPositive (x : Int) : Bool := + x > 0 + +def absolute (x : Int) : Int := + if isPositive x then x else -x diff --git a/test/resources/repos/lean4/test_repo/Main.lean b/test/resources/repos/lean4/test_repo/Main.lean new file mode 100644 index 0000000..261ef8f --- /dev/null +++ b/test/resources/repos/lean4/test_repo/Main.lean @@ -0,0 +1,24 @@ +import Helper + +def multiply (x y : Nat) : Nat := + x * y + +def calculate (c : Calculator) (op : String) (x y : Nat) : Option Int := + match op with + | "add" => some (Int.ofNat (add x y)) + | "subtract" => some (subtract x y) + | "multiply" => some (Int.ofNat (multiply x y)) + | _ => none + +def main : IO Unit := do + let c : Calculator := { name := "TestCalc", version := 1 } + IO.println s!"Using {c.name} version {c.version}" + let result1 := add 5 3 + IO.println s!"5 + 3 = {result1}" + let result2 := subtract 10 4 + IO.println s!"10 - 4 = {result2}" + match calculate c "multiply" 6 7 with + | some result => IO.println s!"6 * 7 = {result}" + | none => IO.println "Calculation failed" + IO.println s!"Is 5 positive? {isPositive 5}" + IO.println s!"Absolute of -10: {absolute (-10)}" diff --git a/test/resources/repos/lean4/test_repo/lake-manifest.json b/test/resources/repos/lean4/test_repo/lake-manifest.json new file mode 100644 index 0000000..5a610bd --- /dev/null +++ b/test/resources/repos/lean4/test_repo/lake-manifest.json @@ -0,0 +1,5 @@ +{"version": "1.1.0", + "packagesDir": ".lake/packages", + "packages": [], + "name": "test_repo", + "lakeDir": ".lake"} diff --git a/test/resources/repos/lean4/test_repo/lakefile.lean b/test/resources/repos/lean4/test_repo/lakefile.lean new file mode 100644 index 0000000..cb410f1 --- /dev/null +++ b/test/resources/repos/lean4/test_repo/lakefile.lean @@ -0,0 +1,9 @@ +import Lake +open Lake DSL + +package «test_repo» where + leanOptions := #[⟨`autoImplicit, false⟩] + +@[default_target] +lean_lib «Main» where + roots := #[`Main, `Helper] diff --git a/test/resources/repos/lean4/test_repo/lean-toolchain b/test/resources/repos/lean4/test_repo/lean-toolchain new file mode 100644 index 0000000..7638861 --- /dev/null +++ b/test/resources/repos/lean4/test_repo/lean-toolchain @@ -0,0 +1 @@ +leanprover/lean4:stable diff --git a/test/resources/repos/lua/test_repo/.gitignore b/test/resources/repos/lua/test_repo/.gitignore new file mode 100644 index 0000000..30bdcf9 --- /dev/null +++ b/test/resources/repos/lua/test_repo/.gitignore @@ -0,0 +1,13 @@ +# Lua specific +*.luac +.luarocks/ +lua_modules/ +luarocks/ + +# Build artifacts +build/ +dist/ + +# IDE +.vscode/ +.idea/ \ No newline at end of file diff --git a/test/resources/repos/lua/test_repo/main.lua b/test/resources/repos/lua/test_repo/main.lua new file mode 100644 index 0000000..cd6ecc9 --- /dev/null +++ b/test/resources/repos/lua/test_repo/main.lua @@ -0,0 +1,123 @@ +#!/usr/bin/env lua + +-- main.lua: Entry point for the test application + +local calculator = require("src.calculator") +local animals = require("src.animals") +local utils = require("src.utils") + +local function print_banner() + print("=" .. string.rep("=", 40)) + print(" Lua Test Repository") + print("=" .. string.rep("=", 40)) +end + +local function test_calculator() + print("\nTesting Calculator Module:") + print("5 + 3 =", calculator.add(5, 3)) + print("10 - 4 =", calculator.subtract(10, 4)) + print("6 * 7 =", calculator.multiply(6, 7)) + print("15 / 3 =", calculator.divide(15, 3)) + print("2^8 =", calculator.power(2, 8)) + print("5! =", calculator.factorial(5)) + + local numbers = {5, 2, 8, 3, 9, 1, 7} + print("Mean of", table.concat(numbers, ", "), "=", calculator.mean(numbers)) + print("Median of", table.concat(numbers, ", "), "=", calculator.median(numbers)) +end + +local function test_utils() + print("\nTesting Utils Module:") + + -- String utilities + print("Trimmed ' hello ' =", "'" .. utils.trim(" hello ") .. "'") + local parts = utils.split("apple,banana,orange", ",") + print("Split 'apple,banana,orange' by ',' =", table.concat(parts, " | ")) + print("'hello' starts with 'he' =", utils.starts_with("hello", "he")) + print("'world' ends with 'ld' =", utils.ends_with("world", "ld")) + + -- Table utilities + local t1 = {a = 1, b = 2} + local t2 = {b = 3, c = 4} + local merged = utils.table_merge(t1, t2) + print("Merged tables: a=" .. (merged.a or "nil") .. + ", b=" .. (merged.b or "nil") .. + ", c=" .. (merged.c or "nil")) + + -- Logger + local logger = utils.Logger:new("TestApp") + logger:info("Application started") + logger:debug("Debug information") + logger:warn("This is a warning") +end + +local function test_animals() + print("\nTesting Animal Module:") + local dog = animals.Dog:new("Rex") + print(dog:speak()) +end + +local function interactive_calculator() + print("\nInteractive Calculator (type 'quit' to exit):") + while true do + io.write("Enter operation (e.g., '5 + 3'): ") + local input = io.read() + + if input == "quit" then + break + end + + -- Simple parser for basic operations + local a, op, b = input:match("(%d+)%s*([%+%-%*/])%s*(%d+)") + if a and op and b then + a = tonumber(a) + b = tonumber(b) + local result + + if op == "+" then + result = calculator.add(a, b) + elseif op == "-" then + result = calculator.subtract(a, b) + elseif op == "*" then + result = calculator.multiply(a, b) + elseif op == "/" then + local success, res = pcall(calculator.divide, a, b) + if success then + result = res + else + print("Error: " .. res) + goto continue + end + end + + print("Result: " .. result) + else + print("Invalid input. Please use format: number operator number") + end + + ::continue:: + end +end + +-- Main execution +local function main(args) + print_banner() + + if #args == 0 then + test_calculator() + test_utils() + test_animals() + elseif args[1] == "interactive" then + interactive_calculator() + elseif args[1] == "test" then + test_calculator() + test_utils() + test_animals() + print("\nAll tests completed!") + else + print("Usage: lua main.lua [interactive|test]") + end +end + +-- Run main function +main(arg or {}) diff --git a/test/resources/repos/lua/test_repo/src/animals.lua b/test/resources/repos/lua/test_repo/src/animals.lua new file mode 100644 index 0000000..92f08f2 --- /dev/null +++ b/test/resources/repos/lua/test_repo/src/animals.lua @@ -0,0 +1,37 @@ +---@class Animal +local Animal = {} +Animal.__index = Animal + +---@param name string +---@return Animal +function Animal:new(name) + local self = setmetatable({}, Animal) + self.name = name + return self +end + +---@return string +function Animal:speak() + return self.name .. " makes a sound" +end + +---@class Dog: Animal +local Dog = setmetatable({}, { __index = Animal }) +Dog.__index = Dog + +---@param name string +---@return Dog +function Dog:new(name) + local self = Animal.new(self, name) + return setmetatable(self, Dog) +end + +---@return string +function Dog:speak() + return self.name .. " says woof" +end + +return { + Animal = Animal, + Dog = Dog, +} diff --git a/test/resources/repos/lua/test_repo/src/calculator.lua b/test/resources/repos/lua/test_repo/src/calculator.lua new file mode 100644 index 0000000..d23b258 --- /dev/null +++ b/test/resources/repos/lua/test_repo/src/calculator.lua @@ -0,0 +1,76 @@ +-- calculator.lua: A simple calculator module for testing LSP features + +local calculator = {} + +-- Basic arithmetic operations +function calculator.add(a, b) + return a + b +end + +function calculator.subtract(a, b) + return a - b +end + +function calculator.multiply(a, b) + return a * b +end + +function calculator.divide(a, b) + if b == 0 then + error("Division by zero") + end + return a / b +end + +-- Advanced operations +function calculator.power(base, exponent) + return base ^ exponent +end + +function calculator.factorial(n) + if n < 0 then + error("Factorial is not defined for negative numbers") + elseif n == 0 or n == 1 then + return 1 + else + local result = 1 + for i = 2, n do + result = result * i + end + return result + end +end + +-- Statistics functions +function calculator.mean(numbers) + if #numbers == 0 then + return nil + end + + local sum = 0 + for _, num in ipairs(numbers) do + sum = sum + num + end + return sum / #numbers +end + +function calculator.median(numbers) + if #numbers == 0 then + return nil + end + + local sorted = {} + for i, v in ipairs(numbers) do + sorted[i] = v + end + table.sort(sorted) + + local mid = math.floor(#sorted / 2) + if #sorted % 2 == 0 then + return (sorted[mid] + sorted[mid + 1]) / 2 + else + return sorted[mid + 1] + end +end + +return calculator \ No newline at end of file diff --git a/test/resources/repos/lua/test_repo/src/utils.lua b/test/resources/repos/lua/test_repo/src/utils.lua new file mode 100644 index 0000000..576b0cb --- /dev/null +++ b/test/resources/repos/lua/test_repo/src/utils.lua @@ -0,0 +1,120 @@ +-- utils.lua: Utility functions for the test repository + +local utils = {} + +-- String utilities +function utils.trim(s) + return s:match("^%s*(.-)%s*$") +end + +function utils.split(str, delimiter) + local result = {} + local pattern = string.format("([^%s]+)", delimiter) + for match in string.gmatch(str, pattern) do + table.insert(result, match) + end + return result +end + +function utils.starts_with(str, prefix) + return str:sub(1, #prefix) == prefix +end + +function utils.ends_with(str, suffix) + return str:sub(-#suffix) == suffix +end + +-- Table utilities +function utils.deep_copy(orig) + local orig_type = type(orig) + local copy + if orig_type == 'table' then + copy = {} + for orig_key, orig_value in next, orig, nil do + copy[utils.deep_copy(orig_key)] = utils.deep_copy(orig_value) + end + setmetatable(copy, utils.deep_copy(getmetatable(orig))) + else + copy = orig + end + return copy +end + +function utils.table_contains(tbl, value) + for _, v in ipairs(tbl) do + if v == value then + return true + end + end + return false +end + +function utils.table_merge(t1, t2) + local result = {} + for k, v in pairs(t1) do + result[k] = v + end + for k, v in pairs(t2) do + result[k] = v + end + return result +end + +-- File utilities +function utils.read_file(path) + local file = io.open(path, "r") + if not file then + return nil, "Could not open file: " .. path + end + local content = file:read("*all") + file:close() + return content +end + +function utils.write_file(path, content) + local file = io.open(path, "w") + if not file then + return false, "Could not open file for writing: " .. path + end + file:write(content) + file:close() + return true +end + +-- Class-like structure +utils.Logger = {} +utils.Logger.__index = utils.Logger + +function utils.Logger:new(name) + local self = setmetatable({}, utils.Logger) + self.name = name or "default" + self.level = "info" + return self +end + +function utils.Logger:set_level(level) + self.level = level +end + +function utils.Logger:log(message, level) + level = level or self.level + print(string.format("[%s] %s: %s", self.name, level:upper(), message)) +end + +function utils.Logger:debug(message) + self:log(message, "debug") +end + +function utils.Logger:info(message) + self:log(message, "info") +end + +function utils.Logger:warn(message) + self:log(message, "warn") +end + +function utils.Logger:error(message) + self:log(message, "error") +end + +return utils \ No newline at end of file diff --git a/test/resources/repos/lua/test_repo/tests/test_calculator.lua b/test/resources/repos/lua/test_repo/tests/test_calculator.lua new file mode 100644 index 0000000..9f2aaa8 --- /dev/null +++ b/test/resources/repos/lua/test_repo/tests/test_calculator.lua @@ -0,0 +1,93 @@ +-- test_calculator.lua: Unit tests for calculator module + +local calculator = require("src.calculator") + +local function assert_equals(actual, expected, message) + if actual ~= expected then + error(string.format("%s: expected %s, got %s", + message or "Assertion failed", tostring(expected), tostring(actual))) + end +end + +local function assert_error(func, message) + local success = pcall(func) + if success then + error(string.format("%s: expected error but none was thrown", + message or "Assertion failed")) + end +end + +local function test_basic_operations() + print("Testing basic operations...") + assert_equals(calculator.add(2, 3), 5, "Addition test") + assert_equals(calculator.add(-5, 5), 0, "Addition with negative") + assert_equals(calculator.add(0, 0), 0, "Addition with zeros") + + assert_equals(calculator.subtract(10, 3), 7, "Subtraction test") + assert_equals(calculator.subtract(5, 10), -5, "Subtraction negative result") + + assert_equals(calculator.multiply(4, 5), 20, "Multiplication test") + assert_equals(calculator.multiply(-3, 4), -12, "Multiplication with negative") + assert_equals(calculator.multiply(0, 100), 0, "Multiplication with zero") + + assert_equals(calculator.divide(10, 2), 5, "Division test") + assert_equals(calculator.divide(7, 2), 3.5, "Division with decimal result") + assert_error(function() calculator.divide(5, 0) end, "Division by zero") + + print("✓ Basic operations tests passed") +end + +local function test_advanced_operations() + print("Testing advanced operations...") + assert_equals(calculator.power(2, 3), 8, "Power test") + assert_equals(calculator.power(5, 0), 1, "Power of zero") + assert_equals(calculator.power(10, -1), 0.1, "Negative exponent") + + assert_equals(calculator.factorial(0), 1, "Factorial of 0") + assert_equals(calculator.factorial(1), 1, "Factorial of 1") + assert_equals(calculator.factorial(5), 120, "Factorial of 5") + assert_equals(calculator.factorial(10), 3628800, "Factorial of 10") + assert_error(function() calculator.factorial(-1) end, "Factorial of negative") + + print("✓ Advanced operations tests passed") +end + +local function test_statistics() + print("Testing statistics functions...") + + -- Mean tests + assert_equals(calculator.mean({1, 2, 3, 4, 5}), 3, "Mean of sequential numbers") + assert_equals(calculator.mean({10}), 10, "Mean of single number") + assert_equals(calculator.mean({-5, 5}), 0, "Mean with negatives") + assert_equals(calculator.mean({}), nil, "Mean of empty array") + + -- Median tests + assert_equals(calculator.median({1, 2, 3, 4, 5}), 3, "Median of odd count") + assert_equals(calculator.median({1, 2, 3, 4}), 2.5, "Median of even count") + assert_equals(calculator.median({5, 1, 3, 2, 4}), 3, "Median of unsorted") + assert_equals(calculator.median({7}), 7, "Median of single number") + assert_equals(calculator.median({}), nil, "Median of empty array") + + print("✓ Statistics tests passed") +end + +-- Run all tests +local function run_all_tests() + print("Running calculator tests...\n") + test_basic_operations() + test_advanced_operations() + test_statistics() + print("\n✅ All calculator tests passed!") +end + +-- Execute tests if run directly +if arg and arg[0] and arg[0]:match("test_calculator%.lua$") then + run_all_tests() +end + +return { + run_all_tests = run_all_tests, + test_basic_operations = test_basic_operations, + test_advanced_operations = test_advanced_operations, + test_statistics = test_statistics +} \ No newline at end of file diff --git a/test/resources/repos/luau/test_repo/.luaurc b/test/resources/repos/luau/test_repo/.luaurc new file mode 100644 index 0000000..70eed5e --- /dev/null +++ b/test/resources/repos/luau/test_repo/.luaurc @@ -0,0 +1,9 @@ +{ + "languageMode": "strict", + "lint": { + "*": true + }, + "aliases": { + "Packages": "Packages/" + } +} \ No newline at end of file diff --git a/test/resources/repos/luau/test_repo/src/init.luau b/test/resources/repos/luau/test_repo/src/init.luau new file mode 100644 index 0000000..e808594 --- /dev/null +++ b/test/resources/repos/luau/test_repo/src/init.luau @@ -0,0 +1,26 @@ +local module = require("./module") + +export type Config = { + name: string, + value: number, + enabled: boolean, +} + +local function createConfig(name: string, value: number): Config + return { + name = name, + value = value, + enabled = true, + } +end + +local function main() + local config = createConfig("test", 42) + local result = module.process(config) + print(result) +end + +return { + createConfig = createConfig, + main = main, +} \ No newline at end of file diff --git a/test/resources/repos/luau/test_repo/src/module.luau b/test/resources/repos/luau/test_repo/src/module.luau new file mode 100644 index 0000000..09170f3 --- /dev/null +++ b/test/resources/repos/luau/test_repo/src/module.luau @@ -0,0 +1,12 @@ +local function process(data: { name: string, value: number }): string + return `Processing {data.name} with value {data.value}` +end + +local function helper(x: number, y: number): number + return x + y +end + +return { + process = process, + helper = helper, +} \ No newline at end of file diff --git a/test/resources/repos/markdown/test_repo/CONTRIBUTING.md b/test/resources/repos/markdown/test_repo/CONTRIBUTING.md new file mode 100644 index 0000000..2b53355 --- /dev/null +++ b/test/resources/repos/markdown/test_repo/CONTRIBUTING.md @@ -0,0 +1,74 @@ +# Contributing Guidelines + +Thank you for considering contributing to this project! + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Getting Started](#getting-started) +- [Development Setup](#development-setup) +- [Submitting Changes](#submitting-changes) + +## Code of Conduct + +Please be respectful and considerate in all interactions. + +## Getting Started + +To contribute: + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Submit a pull request + +## Development Setup + +### Prerequisites + +- Git +- Node.js (v16+) +- npm or yarn + +### Installation Steps + +```bash +git clone https://github.com/example/repo.git +cd repo +npm install +``` + +## Submitting Changes + +### Pull Request Process + +1. Update documentation +2. Add tests for new features +3. Ensure all tests pass +4. Update the [README](README.md) + +### Commit Messages + +Use clear and descriptive commit messages: + +- feat: Add new feature +- fix: Bug fix +- docs: Documentation changes +- test: Add or update tests + +## Testing + +Run the test suite before submitting: + +```bash +npm test +``` + +For more information, see: + +- [User Guide](guide.md) +- [API Reference](api.md) + +## Questions? + +Contact the maintainers or open an issue. diff --git a/test/resources/repos/markdown/test_repo/README.md b/test/resources/repos/markdown/test_repo/README.md new file mode 100644 index 0000000..fbf9a5d --- /dev/null +++ b/test/resources/repos/markdown/test_repo/README.md @@ -0,0 +1,53 @@ +# Test Repository + +This is a test repository for markdown language server testing. + +## Overview + +This repository contains sample markdown files for testing LSP features. + +## Features + +- Document symbol detection +- Link navigation +- Reference finding +- Code completion + +### Installation + +To use this test repository: + +1. Clone the repository +2. Install dependencies +3. Run tests + +### Usage + +See [guide.md](guide.md) for detailed usage instructions. + +## Code Examples + +Here's a simple example: + +```python +def hello_world(): + print("Hello, World!") +``` + +### JavaScript Example + +```javascript +function greet(name) { + console.log(`Hello, ${name}!`); +} +``` + +## References + +- [Official Documentation](https://example.com/docs) +- [API Reference](api.md) +- [Contributing Guide](CONTRIBUTING.md) + +## License + +MIT License diff --git a/test/resources/repos/markdown/test_repo/api.md b/test/resources/repos/markdown/test_repo/api.md new file mode 100644 index 0000000..81d0e8a --- /dev/null +++ b/test/resources/repos/markdown/test_repo/api.md @@ -0,0 +1,78 @@ +# API Reference + +Complete API documentation for the test repository. + +## Classes + +### Client + +The main client class for interacting with the API. + +#### Methods + +##### `connect()` + +Establishes a connection to the server. + +**Parameters:** +- `host`: Server hostname +- `port`: Server port number + +**Returns:** Connection object + +##### `disconnect()` + +Closes the connection to the server. + +**Returns:** None + +### Server + +Server-side implementation. + +#### Configuration + +```json +{ + "host": "localhost", + "port": 8080, + "timeout": 30 +} +``` + +## Functions + +### `initialize(config)` + +Initializes the system with the provided configuration. + +**Parameters:** +- `config`: Configuration dictionary + +**Example:** + +```python +config = { + "host": "localhost", + "port": 8080 +} +initialize(config) +``` + +### `shutdown()` + +Gracefully shuts down the system. + +## Error Handling + +Common errors and their solutions: + +- `ConnectionError`: Check network connectivity +- `TimeoutError`: Increase timeout value +- `ConfigError`: Validate configuration file + +## See Also + +- [User Guide](guide.md) +- [README](README.md) +- [Contributing](CONTRIBUTING.md) diff --git a/test/resources/repos/markdown/test_repo/guide.md b/test/resources/repos/markdown/test_repo/guide.md new file mode 100644 index 0000000..025f430 --- /dev/null +++ b/test/resources/repos/markdown/test_repo/guide.md @@ -0,0 +1,59 @@ +# User Guide + +This guide provides detailed instructions for using the test repository. + +## Getting Started + +Welcome to the user guide. This document covers: + +- Basic concepts +- Advanced features +- Troubleshooting + +### Basic Concepts + +The fundamental concepts you need to understand: + +#### Headers and Structure + +Markdown documents use headers to create structure. Headers are created using `#` symbols. + +#### Links and References + +Internal links can reference other documents: + +- [Back to README](README.md) +- [See API documentation](api.md) + +### Advanced Features + +For advanced users, we provide: + +1. Custom extensions +2. Plugin support +3. Theme customization + +## Configuration + +Configuration options are stored in `config.yaml`: + +```yaml +server: + port: 8080 + host: localhost +``` + +## Troubleshooting + +If you encounter issues: + +1. Check the [README](README.md) first +2. Review [common issues](CONTRIBUTING.md) +3. Contact support + +## Next Steps + +After reading this guide, check out: + +- [API Reference](api.md) +- [Contributing Guidelines](CONTRIBUTING.md) diff --git a/test/resources/repos/matlab/test_repo/Calculator.m b/test/resources/repos/matlab/test_repo/Calculator.m new file mode 100644 index 0000000..d0c3aec --- /dev/null +++ b/test/resources/repos/matlab/test_repo/Calculator.m @@ -0,0 +1,81 @@ +classdef Calculator < handle + % Calculator A simple calculator class for testing MATLAB LSP + % + % This class provides basic arithmetic operations and demonstrates + % MATLAB class structure for LSP testing purposes. + + properties + LastResult double = 0 + History cell = {} + end + + properties (Access = private) + OperationCount uint32 = 0 + end + + methods + function obj = Calculator() + % Constructor for Calculator class + obj.LastResult = 0; + obj.History = {}; + obj.OperationCount = 0; + end + + function result = add(obj, a, b) + % ADD Add two numbers + % result = add(obj, a, b) returns the sum of a and b + result = a + b; + obj.updateState(result, 'add'); + end + + function result = subtract(obj, a, b) + % SUBTRACT Subtract b from a + % result = subtract(obj, a, b) returns a - b + result = a - b; + obj.updateState(result, 'subtract'); + end + + function result = multiply(obj, a, b) + % MULTIPLY Multiply two numbers + % result = multiply(obj, a, b) returns a * b + result = a * b; + obj.updateState(result, 'multiply'); + end + + function result = divide(obj, a, b) + % DIVIDE Divide a by b + % result = divide(obj, a, b) returns a / b + % Throws error if b is zero + if b == 0 + error('Calculator:DivisionByZero', 'Cannot divide by zero'); + end + result = a / b; + obj.updateState(result, 'divide'); + end + + function displayHistory(obj) + % DISPLAYHISTORY Display the calculation history + fprintf('Calculation History:\n'); + for i = 1:length(obj.History) + fprintf(' %d: %s = %.4f\n', i, obj.History{i}.operation, obj.History{i}.result); + end + end + end + + methods (Access = private) + function updateState(obj, result, operation) + % Update internal state after an operation + obj.LastResult = result; + obj.OperationCount = obj.OperationCount + 1; + obj.History{end+1} = struct('operation', operation, 'result', result); + end + end + + methods (Static) + function result = power(base, exponent) + % POWER Compute base raised to exponent + % result = Calculator.power(base, exponent) returns base^exponent + result = base ^ exponent; + end + end +end diff --git a/test/resources/repos/matlab/test_repo/diagnostics_sample.m b/test/resources/repos/matlab/test_repo/diagnostics_sample.m new file mode 100644 index 0000000..b0f87a6 --- /dev/null +++ b/test/resources/repos/matlab/test_repo/diagnostics_sample.m @@ -0,0 +1,3 @@ +function value = diagnostics_sample() +value = missingGreeting; +end diff --git a/test/resources/repos/matlab/test_repo/main.m b/test/resources/repos/matlab/test_repo/main.m new file mode 100644 index 0000000..c95948c --- /dev/null +++ b/test/resources/repos/matlab/test_repo/main.m @@ -0,0 +1,67 @@ +% MAIN Main script demonstrating Calculator and mathUtils usage +% +% This script shows how to use the Calculator class and mathUtils +% functions together for various mathematical operations. + +% Add lib folder to path +addpath('lib'); + +%% Section 1: Basic Calculator Operations +% Create a calculator instance and perform basic operations + +calc = Calculator(); + +% Perform some calculations +sum_result = calc.add(10, 5); +fprintf('10 + 5 = %d\n', sum_result); + +diff_result = calc.subtract(10, 3); +fprintf('10 - 3 = %d\n', diff_result); + +prod_result = calc.multiply(4, 7); +fprintf('4 * 7 = %d\n', prod_result); + +quot_result = calc.divide(20, 4); +fprintf('20 / 4 = %d\n', quot_result); + +%% Section 2: Static Method Usage +% Use the static power method + +power_result = Calculator.power(2, 10); +fprintf('2^10 = %d\n', power_result); + +%% Section 3: Math Utilities +% Test the mathUtils functions + +% Factorial +fact5 = mathUtils('factorial', 5); +fprintf('5! = %d\n', fact5); + +% Fibonacci +fib10 = mathUtils('fibonacci', 10); +fprintf('Fibonacci(10) = %d\n', fib10); + +% Prime check +is17prime = mathUtils('isPrime', 17); +fprintf('Is 17 prime? %s\n', mat2str(is17prime)); + +% Statistics +data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; +[dataMean, dataStd] = mathUtils('stats', data); +fprintf('Mean: %.2f, Std: %.2f\n', dataMean, dataStd); + +%% Section 4: Display History +% Show all calculations performed by the calculator + +calc.displayHistory(); + +%% Section 5: Error Handling +% Demonstrate error handling with division by zero + +try + calc.divide(10, 0); +catch ME + fprintf('Caught expected error: %s\n', ME.message); +end + +fprintf('\nAll tests completed successfully!\n'); diff --git a/test/resources/repos/msl/test_repo/main.mrc b/test/resources/repos/msl/test_repo/main.mrc new file mode 100644 index 0000000..04385ee --- /dev/null +++ b/test/resources/repos/msl/test_repo/main.mrc @@ -0,0 +1,38 @@ +; Main game module - tests alias, event, and menu parsing + +alias greet { + msg $chan Hello, $1 $+ ! Welcome aboard. +} + +alias -l calculate.doubloons { + var %base = 100 + var %bonus = $rand(1,50) + return $calc(%base + %bonus) +} + +on *:TEXT:!hello*:#: { + greet $nick +} + +on *:JOIN:#: { + .timer 1 3 greet $nick + var %coins = $format.coins(100) + msg $chan You have %coins +} + +raw 319:*: { + ; Handle channel list numeric + echo -a Channels: $3- +} + +menu channel { + Pirates Game + .Start Game:/start + .End Game:/stop +} + +alias show.player.info { + if ($is.admin($nick)) { + msg $chan $nick is an admin with $format.coins(500) + } +} diff --git a/test/resources/repos/msl/test_repo/utils.mrc b/test/resources/repos/msl/test_repo/utils.mrc new file mode 100644 index 0000000..c7a1226 --- /dev/null +++ b/test/resources/repos/msl/test_repo/utils.mrc @@ -0,0 +1,26 @@ +; Utility aliases and handlers + +alias format.coins { + return $bytes($1,b) doubloons +} + +alias is.admin { + if ($1 isop $chan) return $true + return $false +} + +alias welcome.message { + greet $1 + msg $chan You have $format.coins(100) to start with! +} + +dialog settings { + title "Game Settings" + size -1 -1 200 150 + edit "", 1, 10 10 180 20 + button "Save", 2, 60 40 80 25 +} + +ctcp *:VERSION:*: { + ctcpreply $nick VERSION PiratesIRC v1.0 +} diff --git a/test/resources/repos/nix/test_repo/.gitignore b/test/resources/repos/nix/test_repo/.gitignore new file mode 100644 index 0000000..1ce154d --- /dev/null +++ b/test/resources/repos/nix/test_repo/.gitignore @@ -0,0 +1,11 @@ +# Nix specific +result +result-* +.direnv/ + +# Build artifacts +*.drv + +# IDE +.vscode/ +.idea/ \ No newline at end of file diff --git a/test/resources/repos/nix/test_repo/default.nix b/test/resources/repos/nix/test_repo/default.nix new file mode 100644 index 0000000..1254db5 --- /dev/null +++ b/test/resources/repos/nix/test_repo/default.nix @@ -0,0 +1,183 @@ +# default.nix - Traditional Nix expression for backwards compatibility +{ pkgs ? import { } }: + +let + # Import library functions + lib = pkgs.lib; + stdenv = pkgs.stdenv; + + # Import our custom utilities + utils = import ./lib/utils.nix { inherit lib; }; + + # Custom function to create a greeting + makeGreeting = name: "Hello, ${name}!"; + + # List manipulation functions (using imported utils) + listUtils = { + double = list: map (x: x * 2) list; + sum = list: lib.foldl' (acc: x: acc + x) 0 list; + average = list: + if list == [ ] + then 0 + else (listUtils.sum list) / (builtins.length list); + # Use function from imported utils + unique = utils.lists.unique; + }; + + # String utilities + stringUtils = rec { + capitalize = str: + let + first = lib.substring 0 1 str; + rest = lib.substring 1 (-1) str; + in + (lib.toUpper first) + rest; + + repeat = n: str: lib.concatStrings (lib.genList (_: str) n); + + padLeft = width: char: str: + let + len = lib.stringLength str; + padding = if len >= width then 0 else width - len; + in + (repeat padding char) + str; + }; + + # Package builder helper + buildSimplePackage = { name, version, script }: + stdenv.mkDerivation { + pname = name; + inherit version; + + phases = [ "installPhase" ]; + + installPhase = '' + mkdir -p $out/bin + cat > $out/bin/${name} << EOF + #!/usr/bin/env bash + ${script} + EOF + chmod +x $out/bin/${name} + ''; + }; + +in +rec { + # Export utilities + inherit listUtils stringUtils makeGreeting; + + # Export imported utilities directly + inherit (utils) math strings; + + # Example packages + hello = buildSimplePackage { + name = "hello"; + version = "1.0"; + script = '' + echo "${makeGreeting "World"}" + ''; + }; + + calculator = buildSimplePackage { + name = "calculator"; + version = "0.1"; + script = '' + if [ $# -ne 3 ]; then + echo "Usage: calculator " + exit 1 + fi + + case $2 in + +) echo $(($1 + $3)) ;; + -) echo $(($1 - $3)) ;; + x) echo $(($1 * $3)) ;; + /) echo $(($1 / $3)) ;; + *) echo "Unknown operator: $2" ;; + esac + ''; + }; + + # Environment with multiple packages + devEnv = pkgs.buildEnv { + name = "dev-environment"; + paths = with pkgs; [ + git + vim + bash + hello + calculator + ]; + }; + + # Shell derivation + shell = pkgs.mkShell { + buildInputs = with pkgs; [ + bash + coreutils + findutils + gnugrep + gnused + ]; + + shellHook = '' + echo "Entering Nix shell environment" + echo "Available custom functions: makeGreeting, listUtils, stringUtils" + ''; + }; + + # Configuration example + config = { + system = { + stateVersion = "23.11"; + enable = true; + }; + + services = { + nginx = { + enable = false; + virtualHosts = { + "example.com" = { + root = "/var/www/example"; + locations."/" = { + index = "index.html"; + }; + }; + }; + }; + }; + + users = { + testUser = { + name = "test"; + group = "users"; + home = "/home/test"; + shell = "${pkgs.bash}/bin/bash"; + }; + }; + }; + + # Recursive attribute set example + tree = { + root = { + value = 1; + left = { + value = 2; + left = { value = 4; }; + right = { value = 5; }; + }; + right = { + value = 3; + left = { value = 6; }; + right = { value = 7; }; + }; + }; + + # Tree traversal function + traverse = node: + if node ? left && node ? right + then [ node.value ] ++ (tree.traverse node.left) ++ (tree.traverse node.right) + else if node ? value + then [ node.value ] + else [ ]; + }; +} diff --git a/test/resources/repos/nix/test_repo/diagnostics_sample.nix b/test/resources/repos/nix/test_repo/diagnostics_sample.nix new file mode 100644 index 0000000..320a6ab --- /dev/null +++ b/test/resources/repos/nix/test_repo/diagnostics_sample.nix @@ -0,0 +1,4 @@ +{ + greeting = missingGreeting; + consumer = missingConsumerValue; +} diff --git a/test/resources/repos/nix/test_repo/flake.nix b/test/resources/repos/nix/test_repo/flake.nix new file mode 100644 index 0000000..415992a --- /dev/null +++ b/test/resources/repos/nix/test_repo/flake.nix @@ -0,0 +1,146 @@ +{ + description = "Test Nix flake for language server testing"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + + # Import our default.nix for shared logic + defaultNix = import ./default.nix { inherit pkgs; }; + + # Custom derivation for testing + hello-custom = pkgs.stdenv.mkDerivation { + pname = "hello-custom"; + version = "1.0.0"; + + src = ./.; + + buildInputs = with pkgs; [ + bash + coreutils + ]; + + installPhase = '' + mkdir -p $out/bin + cp ${./scripts/hello.sh} $out/bin/hello-custom + chmod +x $out/bin/hello-custom + ''; + + meta = with pkgs.lib; { + description = "A custom hello world script"; + license = licenses.mit; + platforms = platforms.all; + }; + }; + + # Development shell configuration + devShell = pkgs.mkShell { + buildInputs = with pkgs; [ + # Development tools + git + gnumake + gcc + + # Nix tools + nix-prefetch-git + nixpkgs-fmt + nil + + # Languages + python3 + nodejs + rustc + cargo + ]; + + shellHook = '' + echo "Welcome to the Nix development shell!" + echo "Available tools: git, make, gcc, python3, nodejs, rustc" + ''; + }; + + in + { + # Packages + packages = { + default = hello-custom; + inherit hello-custom; + + # Another package for testing + utils = pkgs.stdenv.mkDerivation { + pname = "test-utils"; + version = "0.1.0"; + src = ./.; + + installPhase = '' + mkdir -p $out/share + echo "Utility functions" > $out/share/utils.txt + ''; + }; + }; + + # Apps + apps = { + default = { + type = "app"; + program = "${hello-custom}/bin/hello-custom"; + }; + + hello = { + type = "app"; + program = "${hello-custom}/bin/hello-custom"; + }; + }; + + # Development shells + devShells = { + default = devShell; + + # Minimal shell for testing + minimal = pkgs.mkShell { + buildInputs = with pkgs; [ + bash + coreutils + ]; + }; + }; + + # Overlay + overlays.default = final: prev: { + inherit hello-custom; + }; + + # NixOS module + nixosModules.default = { config, lib, pkgs, ... }: + with lib; + { + options.services.hello-custom = { + enable = mkEnableOption "hello-custom service"; + + message = mkOption { + type = types.str; + default = "Hello from NixOS!"; + description = "Message to display"; + }; + }; + + config = mkIf config.services.hello-custom.enable { + systemd.services.hello-custom = { + description = "Hello Custom Service"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + ExecStart = "${hello-custom}/bin/hello-custom"; + Type = "oneshot"; + }; + }; + }; + }; + } + ); +} \ No newline at end of file diff --git a/test/resources/repos/nix/test_repo/lib/utils.nix b/test/resources/repos/nix/test_repo/lib/utils.nix new file mode 100644 index 0000000..0d0e3bd --- /dev/null +++ b/test/resources/repos/nix/test_repo/lib/utils.nix @@ -0,0 +1,165 @@ +# Utility functions library +{ lib }: + +rec { + # Math utilities + math = { + # Calculate factorial + factorial = n: + if n == 0 + then 1 + else n * factorial (n - 1); + + # Calculate fibonacci number + fibonacci = n: + if n <= 1 + then n + else (fibonacci (n - 1)) + (fibonacci (n - 2)); + + # Check if number is prime + isPrime = n: + let + checkDivisible = i: + if i * i > n then true + else if lib.mod n i == 0 then false + else checkDivisible (i + 1); + in + if n <= 1 then false + else if n <= 3 then true + else checkDivisible 2; + + # Greatest common divisor + gcd = a: b: + if b == 0 + then a + else gcd b (lib.mod a b); + }; + + # String manipulation + strings = { + # Reverse a string + reverse = str: + let + len = lib.stringLength str; + chars = lib.genList (i: lib.substring (len - i - 1) 1 str) len; + in + lib.concatStrings chars; + + # Check if string is palindrome + isPalindrome = str: + str == strings.reverse str; + + # Convert to camelCase + toCamelCase = str: + let + words = lib.splitString "-" str; + capitalize = w: + if w == "" then "" + else (lib.toUpper (lib.substring 0 1 w)) + (lib.substring 1 (-1) w); + capitalizedWords = lib.tail (map capitalize words); + in + (lib.head words) + (lib.concatStrings capitalizedWords); + + # Convert to snake_case + toSnakeCase = str: + lib.replaceStrings ["-"] ["_"] (lib.toLower str); + }; + + # List operations + lists = { + # Get unique elements + unique = list: + lib.foldl' (acc: x: + if lib.elem x acc + then acc + else acc ++ [x] + ) [] list; + + # Zip two lists + zip = list1: list2: + let + len1 = lib.length list1; + len2 = lib.length list2; + minLen = if len1 < len2 then len1 else len2; + in + lib.genList (i: { + fst = lib.elemAt list1 i; + snd = lib.elemAt list2 i; + }) minLen; + + # Flatten nested list + flatten = list: + lib.foldl' (acc: x: + if builtins.isList x + then acc ++ (flatten x) + else acc ++ [x] + ) [] list; + + # Partition list by predicate + partition = pred: list: + lib.foldl' (acc: x: + if pred x + then { yes = acc.yes ++ [x]; no = acc.no; } + else { yes = acc.yes; no = acc.no ++ [x]; } + ) { yes = []; no = []; } list; + }; + + # Attribute set operations + attrs = { + # Deep merge two attribute sets + deepMerge = attr1: attr2: + lib.recursiveUpdate attr1 attr2; + + # Filter attributes by predicate + filterAttrs = pred: attrs: + lib.filterAttrs pred attrs; + + # Map over attribute values + mapValues = f: attrs: + lib.mapAttrs (name: value: f value) attrs; + + # Get nested attribute safely + getAttrPath = path: default: attrs: + lib.attrByPath path default attrs; + }; + + # File system utilities + files = { + # Read JSON file + readJSON = path: + builtins.fromJSON (builtins.readFile path); + + # Read TOML file + readTOML = path: + builtins.fromTOML (builtins.readFile path); + + # Check if path exists + pathExists = path: + builtins.pathExists path; + + # Get file type + getFileType = path: + let + type = builtins.readFileType path; + in + if type == "directory" then "dir" + else if type == "regular" then "file" + else if type == "symlink" then "link" + else "unknown"; + }; + + # Validation utilities + validate = { + # Check if value is email + isEmail = str: + builtins.match "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" str != null; + + # Check if value is URL + isURL = str: + builtins.match "^https?://[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}" str != null; + + # Check if value is valid version + isVersion = str: + builtins.match "^[0-9]+\\.[0-9]+\\.[0-9]+$" str != null; + }; +} \ No newline at end of file diff --git a/test/resources/repos/nix/test_repo/modules/example.nix b/test/resources/repos/nix/test_repo/modules/example.nix new file mode 100644 index 0000000..744b673 --- /dev/null +++ b/test/resources/repos/nix/test_repo/modules/example.nix @@ -0,0 +1,145 @@ +# Example NixOS module +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.example; + + # Helper function to generate config file + generateConfig = settings: '' + # Generated configuration + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (k: v: "${k} = ${toString v}") settings)} + ''; + +in { + # Module options + options = { + services.example = { + enable = mkEnableOption "example service"; + + package = mkOption { + type = types.package; + default = pkgs.hello; + description = "Package to use for the service"; + }; + + port = mkOption { + type = types.port; + default = 8080; + description = "Port to listen on"; + }; + + host = mkOption { + type = types.str; + default = "localhost"; + description = "Host to bind to"; + }; + + workers = mkOption { + type = types.int; + default = 4; + description = "Number of worker processes"; + }; + + settings = mkOption { + type = types.attrsOf types.anything; + default = {}; + description = "Additional settings"; + }; + + users = mkOption { + type = types.listOf types.str; + default = []; + description = "List of users with access"; + }; + + database = { + enable = mkOption { + type = types.bool; + default = false; + description = "Enable database support"; + }; + + type = mkOption { + type = types.enum [ "postgresql" "mysql" "sqlite" ]; + default = "sqlite"; + description = "Database type"; + }; + + host = mkOption { + type = types.str; + default = "localhost"; + description = "Database host"; + }; + + name = mkOption { + type = types.str; + default = "example"; + description = "Database name"; + }; + }; + }; + }; + + # Module configuration + config = mkIf cfg.enable { + # System packages + environment.systemPackages = [ cfg.package ]; + + # Systemd service + systemd.services.example = { + description = "Example Service"; + after = [ "network.target" ] ++ (optional cfg.database.enable "postgresql.service"); + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "simple"; + User = "example"; + Group = "example"; + ExecStart = "${cfg.package}/bin/example --port ${toString cfg.port} --host ${cfg.host}"; + Restart = "on-failure"; + RestartSec = 5; + + # Security hardening + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectHome = true; + NoNewPrivileges = true; + }; + + environment = { + EXAMPLE_WORKERS = toString cfg.workers; + EXAMPLE_CONFIG = generateConfig cfg.settings; + } // optionalAttrs cfg.database.enable { + DATABASE_TYPE = cfg.database.type; + DATABASE_HOST = cfg.database.host; + DATABASE_NAME = cfg.database.name; + }; + }; + + # User and group + users.users.example = { + isSystemUser = true; + group = "example"; + description = "Example service user"; + }; + + users.groups.example = {}; + + # Firewall rules + networking.firewall = mkIf (cfg.host == "0.0.0.0") { + allowedTCPPorts = [ cfg.port ]; + }; + + # Database setup + services.postgresql = mkIf (cfg.database.enable && cfg.database.type == "postgresql") { + enable = true; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [{ + name = "example"; + ensureDBOwnership = true; + }]; + }; + }; +} \ No newline at end of file diff --git a/test/resources/repos/nix/test_repo/scripts/hello.sh b/test/resources/repos/nix/test_repo/scripts/hello.sh new file mode 100644 index 0000000..c57ba01 --- /dev/null +++ b/test/resources/repos/nix/test_repo/scripts/hello.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# Simple hello script for testing +echo "Hello from Nix!" \ No newline at end of file diff --git a/test/resources/repos/ocaml/test_repo/bin/dune b/test/resources/repos/ocaml/test_repo/bin/dune new file mode 100644 index 0000000..67e87ae --- /dev/null +++ b/test/resources/repos/ocaml/test_repo/bin/dune @@ -0,0 +1,4 @@ +(executable + (public_name test_repo) + (name main) + (libraries test_repo)) diff --git a/test/resources/repos/ocaml/test_repo/bin/main.ml b/test/resources/repos/ocaml/test_repo/bin/main.ml new file mode 100644 index 0000000..3efa44f --- /dev/null +++ b/test/resources/repos/ocaml/test_repo/bin/main.ml @@ -0,0 +1,9 @@ +open Test_repo + +let n = 20 + +let () = + let res = fib n in + Printf.printf "fib(%d) = %d\n" n res; + let greeting = DemoModule.someFunction "Hello" in + Printf.printf "%s\n" greeting \ No newline at end of file diff --git a/test/resources/repos/ocaml/test_repo/dune-project b/test/resources/repos/ocaml/test_repo/dune-project new file mode 100644 index 0000000..0bb5a43 --- /dev/null +++ b/test/resources/repos/ocaml/test_repo/dune-project @@ -0,0 +1,26 @@ +(lang dune 3.18) + +(name test_repo) + +(generate_opam_files true) + +(source + (github username/reponame)) + +(authors "Author Name ") + +(maintainers "Maintainer Name ") + +(license LICENSE) + +(documentation https://url/to/documentation) + +(package + (name test_repo) + (synopsis "A short synopsis") + (description "A longer description") + (depends ocaml) + (tags + ("add topics" "to describe" your project))) + +; See the complete stanza docs at https://dune.readthedocs.io/en/stable/reference/dune-project/index.html diff --git a/test/resources/repos/ocaml/test_repo/lib/diagnostics_sample.ml b/test/resources/repos/ocaml/test_repo/lib/diagnostics_sample.ml new file mode 100644 index 0000000..c23ccef --- /dev/null +++ b/test/resources/repos/ocaml/test_repo/lib/diagnostics_sample.ml @@ -0,0 +1 @@ +let broken_factory ( diff --git a/test/resources/repos/ocaml/test_repo/lib/dune b/test/resources/repos/ocaml/test_repo/lib/dune new file mode 100644 index 0000000..aba011b --- /dev/null +++ b/test/resources/repos/ocaml/test_repo/lib/dune @@ -0,0 +1,4 @@ +(library + (public_name test_repo) + (name test_repo) + (modules test_repo)) diff --git a/test/resources/repos/ocaml/test_repo/lib/test_repo.ml b/test/resources/repos/ocaml/test_repo/lib/test_repo.ml new file mode 100644 index 0000000..8befffa --- /dev/null +++ b/test/resources/repos/ocaml/test_repo/lib/test_repo.ml @@ -0,0 +1,12 @@ +module DemoModule = struct + type value = string + + let someFunction s = + s ^ " More String" +end + +let rec fib n = + if n < 2 then 1 + else fib (n-1) + fib (n-2) + +let num_domains = 2 \ No newline at end of file diff --git a/test/resources/repos/ocaml/test_repo/lib/test_repo.mli b/test/resources/repos/ocaml/test_repo/lib/test_repo.mli new file mode 100644 index 0000000..b6b3341 --- /dev/null +++ b/test/resources/repos/ocaml/test_repo/lib/test_repo.mli @@ -0,0 +1,7 @@ +module DemoModule : sig + type value = string + val someFunction : string -> string +end + +val fib : int -> int +val num_domains : int \ No newline at end of file diff --git a/test/resources/repos/ocaml/test_repo/test/dune b/test/resources/repos/ocaml/test_repo/test/dune new file mode 100644 index 0000000..f4359ea --- /dev/null +++ b/test/resources/repos/ocaml/test_repo/test/dune @@ -0,0 +1,3 @@ +(test + (name test_test_repo) + (libraries test_repo)) \ No newline at end of file diff --git a/test/resources/repos/ocaml/test_repo/test/test_test_repo.ml b/test/resources/repos/ocaml/test_repo/test/test_test_repo.ml new file mode 100644 index 0000000..3efbaae --- /dev/null +++ b/test/resources/repos/ocaml/test_repo/test/test_test_repo.ml @@ -0,0 +1,18 @@ +open Test_repo + +let test_fib () = + assert (fib 0 = 1); + assert (fib 1 = 1); + assert (fib 2 = 2); + assert (fib 5 = 8); + Printf.printf "fib tests passed\n" + +let test_demo_module () = + let result = DemoModule.someFunction "Test" in + assert (result = "Test More String"); + Printf.printf "DemoModule tests passed\n" + +let () = + test_fib (); + test_demo_module (); + Printf.printf "All tests passed!\n" \ No newline at end of file diff --git a/test/resources/repos/ocaml/test_repo/test_repo.opam b/test/resources/repos/ocaml/test_repo/test_repo.opam new file mode 100644 index 0000000..ab7cba2 --- /dev/null +++ b/test/resources/repos/ocaml/test_repo/test_repo.opam @@ -0,0 +1,32 @@ +# This file is generated by dune, edit dune-project instead +opam-version: "2.0" +synopsis: "A short synopsis" +description: "A longer description" +maintainer: ["Maintainer Name "] +authors: ["Author Name "] +license: "LICENSE" +tags: ["add topics" "to describe" "your" "project"] +homepage: "https://github.com/username/reponame" +doc: "https://url/to/documentation" +bug-reports: "https://github.com/username/reponame/issues" +depends: [ + "dune" {>= "3.18"} + "ocaml" + "odoc" {with-doc} +] +build: [ + ["dune" "subst"] {dev} + [ + "dune" + "build" + "-p" + name + "-j" + jobs + "@install" + "@runtest" {with-test} + "@doc" {with-doc} + ] +] +dev-repo: "git+https://github.com/username/reponame.git" +x-maintenance-intent: ["(latest)"] diff --git a/test/resources/repos/pascal/test_repo/.gitignore b/test/resources/repos/pascal/test_repo/.gitignore new file mode 100644 index 0000000..42e0384 --- /dev/null +++ b/test/resources/repos/pascal/test_repo/.gitignore @@ -0,0 +1,8 @@ +backup/ +*.o +*.ppu +*.exe +*.lps +*.compiled +__history/ +__recovery/ diff --git a/test/resources/repos/pascal/test_repo/diagnostics_sample.pas b/test/resources/repos/pascal/test_repo/diagnostics_sample.pas new file mode 100644 index 0000000..7a3bf84 --- /dev/null +++ b/test/resources/repos/pascal/test_repo/diagnostics_sample.pas @@ -0,0 +1,4 @@ +program DiagnosticsSample; +begin + writeln( +end. diff --git a/test/resources/repos/pascal/test_repo/main.pas b/test/resources/repos/pascal/test_repo/main.pas new file mode 100644 index 0000000..f240bee --- /dev/null +++ b/test/resources/repos/pascal/test_repo/main.pas @@ -0,0 +1,130 @@ +unit Main; + +{$mode objfpc}{$H+} + +interface + +uses + Classes, SysUtils, Helper; + +type + { TUser - A simple user class } + TUser = class + private + FName: string; + FAge: Integer; + public + constructor Create(const AName: string; AAge: Integer); + destructor Destroy; override; + + function GetInfo: string; + procedure UpdateAge(NewAge: Integer); + + property Name: string read FName write FName; + property Age: Integer read FAge write FAge; + end; + + { TUserManager - Manages multiple users } + TUserManager = class + private + FUsers: TList; + public + constructor Create; + destructor Destroy; override; + + procedure AddUser(User: TUser); + function GetUserCount: Integer; + function FindUserByName(const AName: string): TUser; + end; + +{ Helper functions } + +/// Calculates the sum of two integers. +/// @param A First integer value +/// @param B Second integer value +/// @returns The sum of A and B +function CalculateSum(A, B: Integer): Integer; +procedure PrintMessage(const Msg: string); + +implementation + +{ TUser implementation } + +constructor TUser.Create(const AName: string; AAge: Integer); +begin + inherited Create; + FName := AName; + FAge := AAge; +end; + +destructor TUser.Destroy; +begin + inherited Destroy; +end; + +function TUser.GetInfo: string; +begin + Result := Format('Name: %s, Age: %d', [FName, FAge]); +end; + +procedure TUser.UpdateAge(NewAge: Integer); +begin + FAge := NewAge; +end; + +{ TUserManager implementation } + +constructor TUserManager.Create; +begin + inherited Create; + FUsers := TList.Create; +end; + +destructor TUserManager.Destroy; +var + i: Integer; +begin + for i := 0 to FUsers.Count - 1 do + TUser(FUsers[i]).Free; + FUsers.Free; + inherited Destroy; +end; + +procedure TUserManager.AddUser(User: TUser); +begin + FUsers.Add(User); +end; + +function TUserManager.GetUserCount: Integer; +begin + Result := FUsers.Count; +end; + +function TUserManager.FindUserByName(const AName: string): TUser; +var + i: Integer; +begin + Result := nil; + for i := 0 to FUsers.Count - 1 do + begin + if TUser(FUsers[i]).Name = AName then + begin + Result := TUser(FUsers[i]); + Exit; + end; + end; +end; + +{ Helper functions } + +function CalculateSum(A, B: Integer): Integer; +begin + Result := A + B; +end; + +procedure PrintMessage(const Msg: string); +begin + WriteLn(Msg); +end; + +end. diff --git a/test/resources/repos/perl/test_repo/diagnostics_sample.pl b/test/resources/repos/perl/test_repo/diagnostics_sample.pl new file mode 100644 index 0000000..4d78b68 --- /dev/null +++ b/test/resources/repos/perl/test_repo/diagnostics_sample.pl @@ -0,0 +1,5 @@ +use strict; +use warnings; + +print $missingGreeting; +print $missingConsumerValue; diff --git a/test/resources/repos/perl/test_repo/helper.pl b/test/resources/repos/perl/test_repo/helper.pl new file mode 100644 index 0000000..2ee934e --- /dev/null +++ b/test/resources/repos/perl/test_repo/helper.pl @@ -0,0 +1,9 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +sub helper_function { + print "Helper function was called.\n"; +} + +1; diff --git a/test/resources/repos/perl/test_repo/helper.t b/test/resources/repos/perl/test_repo/helper.t new file mode 100644 index 0000000..0887b18 --- /dev/null +++ b/test/resources/repos/perl/test_repo/helper.t @@ -0,0 +1,12 @@ +#!/usr/bin/env perl + +use lib '.'; +use strict; +use warnings; + +use Test::More tests => 1; + +require helper; + +helper_function(); +ok(1, 'helper_function callable from .t file'); diff --git a/test/resources/repos/perl/test_repo/main.pl b/test/resources/repos/perl/test_repo/main.pl new file mode 100644 index 0000000..e0e2be7 --- /dev/null +++ b/test/resources/repos/perl/test_repo/main.pl @@ -0,0 +1,22 @@ +#!/usr/bin/env perl +use strict; +use warnings; +use lib '.'; + +require helper; + +sub greet { + my ($name) = @_; + return "Hello, $name!"; +} + +my $user_name = "Perl User"; +my $greeting = greet($user_name); + +print "$greeting\n"; + +helper_function(); + +sub use_helper_function { + helper_function(); +} diff --git a/test/resources/repos/php/test_repo/diagnostics_sample.php b/test/resources/repos/php/test_repo/diagnostics_sample.php new file mode 100644 index 0000000..b1a6be1 --- /dev/null +++ b/test/resources/repos/php/test_repo/diagnostics_sample.php @@ -0,0 +1,11 @@ + \ No newline at end of file diff --git a/test/resources/repos/php/test_repo/index.php b/test/resources/repos/php/test_repo/index.php new file mode 100644 index 0000000..f8c4cda --- /dev/null +++ b/test/resources/repos/php/test_repo/index.php @@ -0,0 +1,20 @@ + \ No newline at end of file diff --git a/test/resources/repos/php/test_repo/sample.php b/test/resources/repos/php/test_repo/sample.php new file mode 100644 index 0000000..08e60bc --- /dev/null +++ b/test/resources/repos/php/test_repo/sample.php @@ -0,0 +1,122 @@ +name = $name; + $this->age = $age; + } + + public function getName(): string + { + return $this->name; + } + + public function getAge(): int + { + return $this->age; + } + + abstract public function describe(): string; +} + +/** + * A concrete animal that can greet visitors. + */ +class Dog extends Animal implements GreeterInterface +{ + private string $breed; + + public function __construct(string $name, int $age, string $breed) + { + parent::__construct($name, $age); + $this->breed = $breed; + } + + public function greet(string $visitorName): string + { + return "Woof! I'm {$this->name}. Hello, {$visitorName}!"; + } + + public function getBreed(): string + { + return $this->breed; + } + + public function describe(): string + { + return "Dog: {$this->name} ({$this->breed}), age {$this->age}"; + } + + public function fetch(string $item): string + { + return "{$this->name} fetches the {$item}!"; + } +} + +/** + * Another concrete animal. + */ +class Cat extends Animal +{ + private bool $indoor; + + public function __construct(string $name, int $age, bool $indoor = true) + { + parent::__construct($name, $age); + $this->indoor = $indoor; + } + + public function isIndoor(): bool + { + return $this->indoor; + } + + public function describe(): string + { + $type = $this->indoor ? 'indoor' : 'outdoor'; + return "Cat: {$this->name} ({$type}), age {$this->age}"; + } +} + +const MAX_ANIMALS = 100; +const DEFAULT_BREED = 'Mixed'; + +/** + * Factory function to create an animal by type name. + */ +function createAnimal(string $type, string $name, int $age): Animal +{ + return match ($type) { + 'dog' => new Dog($name, $age, DEFAULT_BREED), + 'cat' => new Cat($name, $age), + default => throw new \InvalidArgumentException("Unknown animal type: {$type}"), + }; +} + +/** + * Returns a summary string for a list of animals. + * + * @param Animal[] $animals + */ +function summarizeAnimals(array $animals): string +{ + return implode(', ', array_map(fn(Animal $a) => $a->describe(), $animals)); +} diff --git a/test/resources/repos/php/test_repo/simple_var.php b/test/resources/repos/php/test_repo/simple_var.php new file mode 100644 index 0000000..4ffe86e --- /dev/null +++ b/test/resources/repos/php/test_repo/simple_var.php @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/test/resources/repos/powershell/test_repo/PowerShellEditorServices.json b/test/resources/repos/powershell/test_repo/PowerShellEditorServices.json new file mode 100644 index 0000000..2014786 --- /dev/null +++ b/test/resources/repos/powershell/test_repo/PowerShellEditorServices.json @@ -0,0 +1 @@ +{"status":"started","languageServiceTransport":"Stdio","powerShellVersion":"7.5.3"} \ No newline at end of file diff --git a/test/resources/repos/powershell/test_repo/diagnostics_sample.ps1 b/test/resources/repos/powershell/test_repo/diagnostics_sample.ps1 new file mode 100644 index 0000000..c5ce760 --- /dev/null +++ b/test/resources/repos/powershell/test_repo/diagnostics_sample.ps1 @@ -0,0 +1,4 @@ +function Invoke-BrokenFactory { + if ($true) { + Write-Output "broken" +} diff --git a/test/resources/repos/powershell/test_repo/main.ps1 b/test/resources/repos/powershell/test_repo/main.ps1 new file mode 100644 index 0000000..f6d331f --- /dev/null +++ b/test/resources/repos/powershell/test_repo/main.ps1 @@ -0,0 +1,115 @@ +# Main script demonstrating various PowerShell features + +# Import utility functions +. "$PSScriptRoot\utils.ps1" + +# Global variables +$Script:ScriptName = "Main Script" +$Script:Counter = 0 + +<# +.SYNOPSIS + Greets a user with various greeting styles. +.PARAMETER Username + The name of the user to greet. +.PARAMETER GreetingType + The type of greeting (formal, casual, or default). +#> +function Greet-User { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Username, + + [Parameter(Mandatory = $false)] + [ValidateSet("formal", "casual", "default")] + [string]$GreetingType = "default" + ) + + switch ($GreetingType) { + "formal" { + Write-Output "Good day, $Username!" + } + "casual" { + Write-Output "Hey $Username!" + } + default { + Write-Output "Hello, $Username!" + } + } +} + +<# +.SYNOPSIS + Processes an array of items with the specified operation. +.PARAMETER Items + The array of items to process. +.PARAMETER Operation + The operation to perform (count, uppercase). +#> +function Process-Items { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string[]]$Items, + + [Parameter(Mandatory = $true)] + [ValidateSet("count", "uppercase")] + [string]$Operation + ) + + foreach ($item in $Items) { + switch ($Operation) { + "count" { + $Script:Counter++ + Write-Output "Processing item $($Script:Counter): $item" + } + "uppercase" { + Write-Output $item.ToUpper() + } + } + } +} + +<# +.SYNOPSIS + Main entry point for the script. +#> +function Main { + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [string]$User = "World", + + [Parameter(Mandatory = $false)] + [string]$Greeting = "default" + ) + + Write-Output "Starting $Script:ScriptName" + + # Use the Greet-User function + Greet-User -Username $User -GreetingType $Greeting + + # Process some items + $items = @("item1", "item2", "item3") + Write-Output "Processing items..." + Process-Items -Items $items -Operation "count" + + # Use utility functions from utils.ps1 + $upperName = Convert-ToUpperCase -InputString $User + Write-Output "Uppercase name: $upperName" + + $trimmed = Remove-Whitespace -InputString " Hello World " + Write-Output "Trimmed: '$trimmed'" + + Write-Output "Script completed successfully" +} + +class PersonFormatter { + [string] FormatName([string]$Name) { + return "Formatted: $Name" + } +} + +# Run main function +Main @args diff --git a/test/resources/repos/powershell/test_repo/utils.ps1 b/test/resources/repos/powershell/test_repo/utils.ps1 new file mode 100644 index 0000000..81db68e --- /dev/null +++ b/test/resources/repos/powershell/test_repo/utils.ps1 @@ -0,0 +1,185 @@ +# Utility functions for PowerShell operations + +<# +.SYNOPSIS + Converts a string to uppercase. +.PARAMETER InputString + The string to convert. +.OUTPUTS + System.String - The uppercase string. +#> +function Convert-ToUpperCase { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [string]$InputString + ) + + return $InputString.ToUpper() +} + +<# +.SYNOPSIS + Converts a string to lowercase. +.PARAMETER InputString + The string to convert. +.OUTPUTS + System.String - The lowercase string. +#> +function Convert-ToLowerCase { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [string]$InputString + ) + + return $InputString.ToLower() +} + +<# +.SYNOPSIS + Removes leading and trailing whitespace from a string. +.PARAMETER InputString + The string to trim. +.OUTPUTS + System.String - The trimmed string. +#> +function Remove-Whitespace { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [string]$InputString + ) + + return $InputString.Trim() +} + +<# +.SYNOPSIS + Creates a backup of a file. +.PARAMETER FilePath + The path to the file to backup. +.PARAMETER BackupDirectory + The directory where the backup will be created. +.OUTPUTS + System.String - The path to the backup file. +#> +function Backup-File { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + + [Parameter(Mandatory = $false)] + [string]$BackupDirectory = "." + ) + + if (-not (Test-Path $FilePath)) { + throw "File not found: $FilePath" + } + + $fileName = Split-Path $FilePath -Leaf + $timestamp = Get-Date -Format "yyyyMMdd_HHmmss" + $backupName = "$fileName.$timestamp.bak" + $backupPath = Join-Path $BackupDirectory $backupName + + Copy-Item -Path $FilePath -Destination $backupPath + return $backupPath +} + +<# +.SYNOPSIS + Checks if an array contains a specific element. +.PARAMETER Array + The array to search. +.PARAMETER Element + The element to find. +.OUTPUTS + System.Boolean - True if the element is found, false otherwise. +#> +function Test-ArrayContains { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [array]$Array, + + [Parameter(Mandatory = $true)] + $Element + ) + + return $Array -contains $Element +} + +<# +.SYNOPSIS + Writes a log message with timestamp. +.PARAMETER Message + The message to log. +.PARAMETER Level + The log level (Info, Warning, Error). +#> +function Write-LogMessage { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Message, + + [Parameter(Mandatory = $false)] + [ValidateSet("Info", "Warning", "Error")] + [string]$Level = "Info" + ) + + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $logEntry = "[$timestamp] [$Level] $Message" + + switch ($Level) { + "Info" { Write-Host $logEntry -ForegroundColor White } + "Warning" { Write-Host $logEntry -ForegroundColor Yellow } + "Error" { Write-Host $logEntry -ForegroundColor Red } + } +} + +<# +.SYNOPSIS + Validates if a string is a valid email address. +.PARAMETER Email + The email address to validate. +.OUTPUTS + System.Boolean - True if the email is valid, false otherwise. +#> +function Test-ValidEmail { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string]$Email + ) + + $emailRegex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" + return $Email -match $emailRegex +} + +<# +.SYNOPSIS + Checks if a string is a valid number. +.PARAMETER Value + The string to check. +.OUTPUTS + System.Boolean - True if the string is a valid number, false otherwise. +#> +function Test-IsNumber { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string]$Value + ) + + $number = 0 + return [double]::TryParse($Value, [ref]$number) +} diff --git a/test/resources/repos/python/test_repo/.gitignore b/test/resources/repos/python/test_repo/.gitignore new file mode 100644 index 0000000..811c627 --- /dev/null +++ b/test/resources/repos/python/test_repo/.gitignore @@ -0,0 +1 @@ +ignore_this_dir*/ \ No newline at end of file diff --git a/test/resources/repos/python/test_repo/custom_test/__init__.py b/test/resources/repos/python/test_repo/custom_test/__init__.py new file mode 100644 index 0000000..137db74 --- /dev/null +++ b/test/resources/repos/python/test_repo/custom_test/__init__.py @@ -0,0 +1,3 @@ +""" +Custom test package for testing code parsing capabilities. +""" diff --git a/test/resources/repos/python/test_repo/custom_test/advanced_features.py b/test/resources/repos/python/test_repo/custom_test/advanced_features.py new file mode 100644 index 0000000..b2fa7c9 --- /dev/null +++ b/test/resources/repos/python/test_repo/custom_test/advanced_features.py @@ -0,0 +1,475 @@ +""" +Advanced Python features for testing code parsing capabilities. + +This module contains various advanced Python code patterns to ensure +that the code parser can correctly handle them. +""" + +from __future__ import annotations + +import asyncio +import os +from abc import ABC, abstractmethod +from collections.abc import Callable, Iterable +from contextlib import contextmanager +from dataclasses import dataclass, field +from enum import Enum, Flag, IntEnum, auto +from functools import wraps +from typing import ( + Annotated, + Any, + ClassVar, + Final, + Generic, + Literal, + NewType, + Protocol, + TypedDict, + TypeVar, +) + +# Type variables for generics +T = TypeVar("T") +K = TypeVar("K") +V = TypeVar("V") + +# Custom types using NewType +UserId = NewType("UserId", str) +ItemId = NewType("ItemId", int) + +# Type aliases +PathLike = str | os.PathLike +JsonDict = dict[str, Any] + + +# TypedDict +class UserDict(TypedDict): + """TypedDict representing user data.""" + + id: str + name: str + email: str + age: int + roles: list[str] + + +# Enums +class Status(Enum): + """Status enum for process states.""" + + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + +class Priority(IntEnum): + """Priority levels for tasks.""" + + LOW = 0 + MEDIUM = 5 + HIGH = 10 + CRITICAL = auto() + + +class Permissions(Flag): + """Permission flags for access control.""" + + NONE = 0 + READ = 1 + WRITE = 2 + EXECUTE = 4 + ALL = READ | WRITE | EXECUTE + + +# Abstract class with various method types +class BaseProcessor(ABC): + """Abstract base class for processors with various method patterns.""" + + # Class variable with type annotation + DEFAULT_TIMEOUT: ClassVar[int] = 30 + MAX_RETRIES: Final[int] = 3 + + def __init__(self, name: str, config: dict[str, Any] | None = None): + self.name = name + self.config = config or {} + self._status = Status.PENDING + + @property + def status(self) -> Status: + """Status property getter.""" + return self._status + + @status.setter + def status(self, value: Status) -> None: + """Status property setter.""" + if not isinstance(value, Status): + raise TypeError(f"Expected Status enum, got {type(value)}") + self._status = value + + @abstractmethod + def process(self, data: Any) -> Any: + """Process the input data.""" + + @classmethod + def create_from_config(cls, config: dict[str, Any]) -> BaseProcessor: + """Factory classmethod.""" + name = config.get("name", "default") + return cls(name=name, config=config) + + @staticmethod + def validate_config(config: dict[str, Any]) -> bool: + """Static method for config validation.""" + return "name" in config + + def __str__(self) -> str: + return f"{self.__class__.__name__}(name={self.name})" + + +# Concrete implementation of abstract class +class DataProcessor(BaseProcessor): + """Concrete implementation of BaseProcessor.""" + + def __init__(self, name: str, config: dict[str, Any] | None = None, priority: Priority = Priority.MEDIUM): + super().__init__(name, config) + self.priority = priority + self.processed_count = 0 + + def process(self, data: Any) -> Any: + """Process the data.""" + + # Nested function definition + def transform(item: Any) -> Any: + # Nested function within a nested function + def apply_rules(x: Any) -> Any: + return x + + return apply_rules(item) + + # Lambda function + normalize = lambda x: x / max(x) if hasattr(x, "__iter__") and len(x) > 0 else x # noqa: F841 + + result = transform(data) + self.processed_count += 1 + return result + + # Method with complex type hints + def batch_process(self, items: list[str | dict[str, Any] | tuple[Any, ...]]) -> dict[str, list[Any]]: + """Process multiple items in a batch.""" + results: dict[str, list[Any]] = {"success": [], "error": []} + + for item in items: + try: + result = self.process(item) + results["success"].append(result) + except Exception as e: + results["error"].append((item, str(e))) + + return results + + # Generator method + def process_stream(self, data_stream: Iterable[T]) -> Iterable[T]: + """Process a stream of data, yielding results as they're processed.""" + for item in data_stream: + yield self.process(item) + + # Async method + async def async_process(self, data: Any) -> Any: + """Process data asynchronously.""" + await asyncio.sleep(0.1) + return self.process(data) + + # Method with function parameters + def apply_transform(self, data: Any, transform_func: Callable[[Any], Any]) -> Any: + """Apply a custom transform function to the data.""" + return transform_func(data) + + +# Dataclass +@dataclass +class Task: + """Task dataclass for tracking work items.""" + + id: str + name: str + status: Status = Status.PENDING + priority: Priority = Priority.MEDIUM + metadata: dict[str, Any] = field(default_factory=dict) + dependencies: list[str] = field(default_factory=list) + created_at: float | None = None + + def __post_init__(self): + if self.created_at is None: + import time + + self.created_at = time.time() + + def has_dependencies(self) -> bool: + """Check if task has dependencies.""" + return len(self.dependencies) > 0 + + +# Generic class +class Repository(Generic[T]): + """Generic repository for managing collections of items.""" + + def __init__(self): + self.items: dict[str, T] = {} + + def add(self, id: str, item: T) -> None: + """Add an item to the repository.""" + self.items[id] = item + + def get(self, id: str) -> T | None: + """Get an item by id.""" + return self.items.get(id) + + def remove(self, id: str) -> bool: + """Remove an item by id.""" + if id in self.items: + del self.items[id] + return True + return False + + def list_all(self) -> list[T]: + """List all items.""" + return list(self.items.values()) + + +# Type with Protocol (structural subtyping) +class Serializable(Protocol): + """Protocol for objects that can be serialized to dict.""" + + def to_dict(self) -> dict[str, Any]: ... + + +# +# Decorator function +def log_execution(func: Callable) -> Callable: + """Decorator to log function execution.""" + + @wraps(func) + def wrapper(*args, **kwargs): + print(f"Executing {func.__name__}") + result = func(*args, **kwargs) + print(f"Finished {func.__name__}") + return result + + return wrapper + + +# Context manager +@contextmanager +def transaction_context(name: str = "default"): + """Context manager for transaction-like operations.""" + print(f"Starting transaction: {name}") + try: + yield name + print(f"Committing transaction: {name}") + except Exception as e: + print(f"Rolling back transaction: {name}, error: {e}") + raise + + +# Function with complex parameter annotations +def advanced_search( + query: str, + filters: dict[str, Any] | None = None, + sort_by: str | None = None, + sort_order: Literal["asc", "desc"] = "asc", + page: int = 1, + page_size: int = 10, + include_metadata: bool = False, +) -> tuple[list[dict[str, Any]], int]: + """ + Advanced search function with many parameters. + + Returns search results and total count. + """ + results = [] + total = 0 + # Simulating search functionality + return results, total + + +# Class with nested classes +class OuterClass: + """Outer class with nested classes and methods.""" + + class NestedClass: + """Nested class inside OuterClass.""" + + def __init__(self, value: Any): + self.value = value + + def get_value(self) -> Any: + """Get the stored value.""" + return self.value + + class DeeplyNestedClass: + """Deeply nested class for testing parser depth capabilities.""" + + def deep_method(self) -> str: + """Method in deeply nested class.""" + return "deep" + + def __init__(self, name: str): + self.name = name + self.nested = self.NestedClass(name) + + def get_nested(self) -> NestedClass: + """Get the nested class instance.""" + return self.nested + + # Method with nested functions + def process_with_nested(self, data: Any) -> Any: + """Method demonstrating deeply nested function definitions.""" + + def level1(x: Any) -> Any: + """First level nested function.""" + + def level2(y: Any) -> Any: + """Second level nested function.""" + + def level3(z: Any) -> Any: + """Third level nested function.""" + return z + + return level3(y) + + return level2(x) + + return level1(data) + + +# Metaclass example +class Meta(type): + """Metaclass example for testing advanced class handling.""" + + def __new__(mcs, name, bases, attrs): + print(f"Creating class: {name}") + return super().__new__(mcs, name, bases, attrs) + + def __init__(cls, name, bases, attrs): + print(f"Initializing class: {name}") + super().__init__(name, bases, attrs) + + +class WithMeta(metaclass=Meta): + """Class that uses a metaclass.""" + + def __init__(self, value: str): + self.value = value + + +# Factory function that creates and returns instances +def create_processor(processor_type: str, name: str, config: dict[str, Any] | None = None) -> BaseProcessor: + """Factory function that creates and returns processor instances.""" + if processor_type == "data": + return DataProcessor(name, config) + else: + raise ValueError(f"Unknown processor type: {processor_type}") + + +# Nested decorator example +def with_retry(max_retries: int = 3): + """Decorator factory that creates a retry decorator.""" + + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + for attempt in range(max_retries): + try: + return func(*args, **kwargs) + except Exception as e: + if attempt == max_retries - 1: + raise + print(f"Retrying {func.__name__} after error: {e}") + return None + + return wrapper + + return decorator + + +@with_retry(max_retries=5) +def unreliable_operation(data: Any) -> Any: + """Function that might fail and uses the retry decorator.""" + import random + + if random.random() < 0.5: + raise RuntimeError("Random failure") + return data + + +# Complex type annotation with Annotated +ValidatedString = Annotated[str, "A string that has been validated"] +PositiveInt = Annotated[int, lambda x: x > 0] + + +def process_validated_data(data: ValidatedString, count: PositiveInt) -> list[str]: + """Process data with Annotated type hints.""" + return [data] * count + + +# Example of forward references and string literals in type annotations +class TreeNode: + """Tree node with forward reference to itself in annotations.""" + + def __init__(self, value: Any): + self.value = value + self.children: list[TreeNode] = [] + + def add_child(self, child: TreeNode) -> None: + """Add a child node.""" + self.children.append(child) + + def traverse(self) -> list[Any]: + """Traverse the tree and return all values.""" + result = [self.value] + for child in self.children: + result.extend(child.traverse()) + return result + + +# Main entry point for demonstration +def main() -> None: + """Main function demonstrating the use of various features.""" + # Create processor + processor = DataProcessor("test-processor", {"debug": True}) + + # Create tasks + task1 = Task(id="task1", name="First Task") + task2 = Task(id="task2", name="Second Task", dependencies=["task1"]) + + # Create repository + repo: Repository[Task] = Repository() + repo.add(task1.id, task1) + repo.add(task2.id, task2) + + # Process some data + data = [1, 2, 3, 4, 5] + result = processor.process(data) # noqa: F841 + + # Use context manager + with transaction_context("main"): + # Process more data + for task in repo.list_all(): + processor.process(task.name) + + # Use advanced search + _results, _total = advanced_search(query="test", filters={"status": Status.PENDING}, sort_by="priority", page=1, include_metadata=True) + + # Create a tree + root = TreeNode("root") + child1 = TreeNode("child1") + child2 = TreeNode("child2") + root.add_child(child1) + root.add_child(child2) + child1.add_child(TreeNode("grandchild1")) + + print("Done!") + + +if __name__ == "__main__": + main() diff --git a/test/resources/repos/python/test_repo/examples/__init__.py b/test/resources/repos/python/test_repo/examples/__init__.py new file mode 100644 index 0000000..2a6757f --- /dev/null +++ b/test/resources/repos/python/test_repo/examples/__init__.py @@ -0,0 +1,3 @@ +""" +Examples package for demonstrating test_repo module usage. +""" diff --git a/test/resources/repos/python/test_repo/examples/user_management.py b/test/resources/repos/python/test_repo/examples/user_management.py new file mode 100644 index 0000000..1e6da93 --- /dev/null +++ b/test/resources/repos/python/test_repo/examples/user_management.py @@ -0,0 +1,141 @@ +""" +Example demonstrating user management with the test_repo module. + +This example showcases: +- Creating and managing users +- Using various object types and relationships +- Type annotations and complex Python patterns +""" + +import logging +from dataclasses import dataclass +from typing import Any + +from test_repo.models import User, create_user_object +from test_repo.services import UserService + +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +@dataclass +class UserStats: + """Statistics about user activity.""" + + user_id: str + login_count: int = 0 + last_active_days: int = 0 + engagement_score: float = 0.0 + + def is_active(self) -> bool: + """Check if the user is considered active.""" + return self.last_active_days < 30 + + +class UserManager: + """Example class demonstrating complex user management.""" + + def __init__(self, service: UserService): + self.service = service + self.active_users: dict[str, User] = {} + self.user_stats: dict[str, UserStats] = {} + + def register_user(self, name: str, email: str, roles: list[str] | None = None) -> User: + """Register a new user.""" + logger.info(f"Registering new user: {name} ({email})") + user = self.service.create_user(name=name, email=email, roles=roles) + self.active_users[user.id] = user + self.user_stats[user.id] = UserStats(user_id=user.id) + return user + + def get_user(self, user_id: str) -> User | None: + """Get a user by ID.""" + if user_id in self.active_users: + return self.active_users[user_id] + + # Try to fetch from service + user = self.service.get_user(user_id) + if user: + self.active_users[user.id] = user + return user + + def update_user_stats(self, user_id: str, login_count: int, days_since_active: int) -> None: + """Update statistics for a user.""" + if user_id not in self.user_stats: + self.user_stats[user_id] = UserStats(user_id=user_id) + + stats = self.user_stats[user_id] + stats.login_count = login_count + stats.last_active_days = days_since_active + + # Calculate engagement score based on activity + engagement = (100 - min(days_since_active, 100)) * 0.8 + engagement += min(login_count, 20) * 0.2 + stats.engagement_score = engagement + + def get_active_users(self) -> list[User]: + """Get all active users.""" + active_user_ids = [user_id for user_id, stats in self.user_stats.items() if stats.is_active()] + return [self.active_users[user_id] for user_id in active_user_ids if user_id in self.active_users] + + def get_user_by_email(self, email: str) -> User | None: + """Find a user by their email address.""" + for user in self.active_users.values(): + if user.email == email: + return user + return None + + +# Example function demonstrating type annotations +def process_user_data(users: list[User], include_inactive: bool = False, transform_func: callable | None = None) -> dict[str, Any]: + """Process user data with optional transformations.""" + result: dict[str, Any] = {"users": [], "total": 0, "admin_count": 0} + + for user in users: + if transform_func: + user_data = transform_func(user.to_dict()) + else: + user_data = user.to_dict() + + result["users"].append(user_data) + result["total"] += 1 + + if "admin" in user.roles: + result["admin_count"] += 1 + + return result + + +def main(): + """Main function demonstrating the usage of UserManager.""" + # Initialize service and manager + service = UserService() + manager = UserManager(service) + + # Register some users + admin = manager.register_user("Admin User", "admin@example.com", ["admin"]) + user1 = manager.register_user("Regular User", "user@example.com", ["user"]) + user2 = manager.register_user("Another User", "another@example.com", ["user"]) + + # Update some stats + manager.update_user_stats(admin.id, 100, 5) + manager.update_user_stats(user1.id, 50, 10) + manager.update_user_stats(user2.id, 10, 45) # Inactive user + + # Get active users + active_users = manager.get_active_users() + logger.info(f"Active users: {len(active_users)}") + + # Process user data + user_data = process_user_data(active_users, transform_func=lambda u: {**u, "full_name": u.get("name", "")}) + + logger.info(f"Processed {user_data['total']} users, {user_data['admin_count']} admins") + + # Example of calling create_user directly + external_user = create_user_object(id="ext123", name="External User", email="external@example.org", roles=["external"]) + logger.info(f"Created external user: {external_user.name}") + + +if __name__ == "__main__": + main() diff --git a/test/resources/repos/python/test_repo/pyrefly.toml b/test/resources/repos/python/test_repo/pyrefly.toml new file mode 100644 index 0000000..e69de29 diff --git a/test/resources/repos/python/test_repo/scripts/__init__.py b/test/resources/repos/python/test_repo/scripts/__init__.py new file mode 100644 index 0000000..acd328e --- /dev/null +++ b/test/resources/repos/python/test_repo/scripts/__init__.py @@ -0,0 +1,3 @@ +""" +Scripts package containing entry point scripts for the application. +""" diff --git a/test/resources/repos/python/test_repo/scripts/run_app.py b/test/resources/repos/python/test_repo/scripts/run_app.py new file mode 100644 index 0000000..9625451 --- /dev/null +++ b/test/resources/repos/python/test_repo/scripts/run_app.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python +""" +Main entry point script for the test_repo application. + +This script demonstrates how a typical application entry point would be structured, +with command-line arguments, configuration loading, and service initialization. +""" + +import argparse +import json +import logging +import os +import sys +from typing import Any + +# Add parent directory to path to make imports work +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from test_repo.models import Item, User +from test_repo.services import ItemService, UserService + +# Configure logging +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") +logger = logging.getLogger(__name__) + + +def parse_args(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser(description="Test Repo Application") + + parser.add_argument("--config", type=str, default="config.json", help="Path to configuration file") + + parser.add_argument("--mode", choices=["user", "item", "both"], default="both", help="Operation mode") + + parser.add_argument("--verbose", action="store_true", help="Enable verbose logging") + + return parser.parse_args() + + +def load_config(config_path: str) -> dict[str, Any]: + """Load configuration from a JSON file.""" + if not os.path.exists(config_path): + logger.warning(f"Configuration file not found: {config_path}") + return {} + + try: + with open(config_path, encoding="utf-8") as f: + return json.load(f) + except json.JSONDecodeError: + logger.error(f"Invalid JSON in configuration file: {config_path}") + return {} + except Exception as e: + logger.error(f"Error loading configuration: {e}") + return {} + + +def create_sample_users(service: UserService, count: int = 3) -> list[User]: + """Create sample users for demonstration.""" + users = [] + + # Create admin user + admin = service.create_user(name="Admin User", email="admin@example.com", roles=["admin"]) + users.append(admin) + + # Create regular users + for i in range(count - 1): + user = service.create_user(name=f"User {i + 1}", email=f"user{i + 1}@example.com", roles=["user"]) + users.append(user) + + return users + + +def create_sample_items(service: ItemService, count: int = 5) -> list[Item]: + """Create sample items for demonstration.""" + categories = ["Electronics", "Books", "Clothing", "Food", "Other"] + items = [] + + for i in range(count): + category = categories[i % len(categories)] + item = service.create_item(name=f"Item {i + 1}", price=10.0 * (i + 1), category=category) + items.append(item) + + return items + + +def run_user_operations(service: UserService, config: dict[str, Any]) -> None: + """Run operations related to users.""" + logger.info("Running user operations") + + # Get configuration + user_count = config.get("user_count", 3) + + # Create users + users = create_sample_users(service, user_count) + logger.info(f"Created {len(users)} users") + + # Demonstrate some operations + for user in users: + logger.info(f"User: {user.name} (ID: {user.id})") + + # Access a method to demonstrate method calls + if user.has_role("admin"): + logger.info(f"{user.name} is an admin") + + # Lookup a user + found_user = service.get_user(users[0].id) + if found_user: + logger.info(f"Found user: {found_user.name}") + + +def run_item_operations(service: ItemService, config: dict[str, Any]) -> None: + """Run operations related to items.""" + logger.info("Running item operations") + + # Get configuration + item_count = config.get("item_count", 5) + + # Create items + items = create_sample_items(service, item_count) + logger.info(f"Created {len(items)} items") + + # Demonstrate some operations + total_price = 0.0 + for item in items: + price_display = item.get_display_price() + logger.info(f"Item: {item.name}, Price: {price_display}") + total_price += item.price + + logger.info(f"Total price of all items: ${total_price:.2f}") + + +def main(): + """Main entry point for the application.""" + # Parse command line arguments + args = parse_args() + + # Configure logging level + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + + logger.info("Starting Test Repo Application") + + # Load configuration + config = load_config(args.config) + logger.debug(f"Loaded configuration: {config}") + + # Initialize services + user_service = UserService() + item_service = ItemService() + + # Run operations based on mode + if args.mode in ("user", "both"): + run_user_operations(user_service, config) + + if args.mode in ("item", "both"): + run_item_operations(item_service, config) + + logger.info("Application completed successfully") + + +item_reference = Item(id="1", name="Item 1", price=10.0, category="Electronics") + +if __name__ == "__main__": + main() diff --git a/test/resources/repos/python/test_repo/test_repo/__init__.py b/test/resources/repos/python/test_repo/test_repo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/resources/repos/python/test_repo/test_repo/complex_types.py b/test/resources/repos/python/test_repo/test_repo/complex_types.py new file mode 100644 index 0000000..83e0e66 --- /dev/null +++ b/test/resources/repos/python/test_repo/test_repo/complex_types.py @@ -0,0 +1,23 @@ +from typing import TypedDict + +a: list[int] = [1] + + +class CustomListInt(list[int]): + def some_method(self): + pass + + +class CustomTypedDict(TypedDict): + a: int + b: str + + +class Outer2: + class InnerTypedDict(TypedDict): + a: int + b: str + + +class ComplexExtension(Outer2.InnerTypedDict, total=False): + c: bool diff --git a/test/resources/repos/python/test_repo/test_repo/diagnostics_sample.py b/test/resources/repos/python/test_repo/test_repo/diagnostics_sample.py new file mode 100644 index 0000000..616c2fa --- /dev/null +++ b/test/resources/repos/python/test_repo/test_repo/diagnostics_sample.py @@ -0,0 +1,11 @@ +from .models import User + + +def broken_factory() -> User: + return missing_user + + +def broken_consumer() -> None: + created_user = broken_factory() + print(created_user) + print(undefined_name) diff --git a/test/resources/repos/python/test_repo/test_repo/models.py b/test/resources/repos/python/test_repo/test_repo/models.py new file mode 100644 index 0000000..3bccd2e --- /dev/null +++ b/test/resources/repos/python/test_repo/test_repo/models.py @@ -0,0 +1,246 @@ +""" +Models module that demonstrates various Python class patterns. +""" + +from abc import ABC, abstractmethod +from typing import Any, Generic, TypeVar + +T = TypeVar("T") + + +class BaseModel(ABC): + """ + Abstract base class for all models. + """ + + def __init__(self, id: str, name: str | None = None): + self.id = id + self.name = name or id + + @abstractmethod + def to_dict(self) -> dict[str, Any]: + """Convert model to dictionary representation""" + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "BaseModel": + """Create a model instance from dictionary data""" + id = data.get("id", "") + name = data.get("name") + return cls(id=id, name=name) + + +class User(BaseModel): + """ + User model representing a system user. + """ + + def __init__(self, id: str, name: str | None = None, email: str = "", roles: list[str] | None = None): + super().__init__(id, name) + self.email = email + self.roles = roles or [] + + def to_dict(self) -> dict[str, Any]: + return {"id": self.id, "name": self.name, "email": self.email, "roles": self.roles} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "User": + instance = super().from_dict(data) + instance.email = data.get("email", "") + instance.roles = data.get("roles", []) + return instance + + def has_role(self, role: str) -> bool: + """Check if user has a specific role""" + return role in self.roles + + +class Item(BaseModel): + """ + Item model representing a product or service. + """ + + def __init__(self, id: str, name: str | None = None, price: float = 0.0, category: str = ""): + super().__init__(id, name) + self.price = price + self.category = category + + def to_dict(self) -> dict[str, Any]: + return {"id": self.id, "name": self.name, "price": self.price, "category": self.category} + + def get_display_price(self) -> str: + """Format price for display""" + return f"${self.price:.2f}" + + +# Generic type example +class Collection(Generic[T]): + def __init__(self, items: list[T] | None = None): + self.items = items or [] + + def add(self, item: T) -> None: + self.items.append(item) + + def get_all(self) -> list[T]: + return self.items + + +# Factory function +def create_user_object(id: str, name: str, email: str, roles: list[str] | None = None) -> User: + """Factory function to create a user""" + return User(id=id, name=name, email=email, roles=roles) + + +# Multiple inheritance examples + + +class Loggable: + """ + Mixin class that provides logging functionality. + Example of a common mixin pattern used with multiple inheritance. + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.log_entries: list[str] = [] + + def log(self, message: str) -> None: + """Add a log entry""" + self.log_entries.append(message) + + def get_logs(self) -> list[str]: + """Get all log entries""" + return self.log_entries + + +class Serializable: + """ + Mixin class that provides JSON serialization capabilities. + Another example of a mixin for multiple inheritance. + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def to_json(self) -> dict[str, Any]: + """Convert to JSON-serializable dictionary""" + return self.to_dict() if hasattr(self, "to_dict") else {} + + @classmethod + def from_json(cls, data: dict[str, Any]) -> Any: + """Create instance from JSON data""" + return cls.from_dict(data) if hasattr(cls, "from_dict") else cls(**data) + + +class Auditable: + """ + Mixin for tracking creation and modification timestamps. + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.created_at: str = kwargs.get("created_at", "") + self.updated_at: str = kwargs.get("updated_at", "") + + def update_timestamp(self, timestamp: str) -> None: + """Update the last modified timestamp""" + self.updated_at = timestamp + + +# Diamond inheritance pattern +class BaseService(ABC): + """ + Base class for service objects - demonstrates diamond inheritance pattern. + """ + + def __init__(self, name: str = "base"): + self.service_name = name + + @abstractmethod + def get_service_info(self) -> dict[str, str]: + """Get service information""" + + +class DataService(BaseService): + """ + Data handling service. + """ + + def __init__(self, **kwargs): + name = kwargs.pop("name", "data") + super().__init__(name=name) + self.data_source = kwargs.get("data_source", "default") + + def get_service_info(self) -> dict[str, str]: + return {"service_type": "data", "service_name": self.service_name, "data_source": self.data_source} + + +class NetworkService(BaseService): + """ + Network connectivity service. + """ + + def __init__(self, **kwargs): + name = kwargs.pop("name", "network") + super().__init__(name=name) + self.endpoint = kwargs.get("endpoint", "localhost") + + def get_service_info(self) -> dict[str, str]: + return {"service_type": "network", "service_name": self.service_name, "endpoint": self.endpoint} + + +class DataSyncService(DataService, NetworkService): + """ + Service that syncs data over network - example of diamond inheritance. + Inherits from both DataService and NetworkService, which both inherit from BaseService. + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.sync_interval = kwargs.get("sync_interval", 60) + + def get_service_info(self) -> dict[str, str]: + info = super().get_service_info() + info.update({"service_type": "data_sync", "sync_interval": str(self.sync_interval)}) + return info + + +# Multiple inheritance with mixins + + +class LoggableUser(User, Loggable): + """ + User class with logging capabilities. + Example of extending a concrete class with a mixin. + """ + + def __init__(self, id: str, name: str | None = None, email: str = "", roles: list[str] | None = None): + super().__init__(id=id, name=name, email=email, roles=roles) + + def add_role(self, role: str) -> None: + """Add a role to the user and log the action""" + if role not in self.roles: + self.roles.append(role) + self.log(f"Added role '{role}' to user {self.id}") + + +class TrackedItem(Item, Serializable, Auditable): + """ + Item with serialization and auditing capabilities. + Example of a class inheriting from a concrete class and multiple mixins. + """ + + def __init__( + self, id: str, name: str | None = None, price: float = 0.0, category: str = "", created_at: str = "", updated_at: str = "" + ): + super().__init__(id=id, name=name, price=price, category=category, created_at=created_at, updated_at=updated_at) + self.stock_level = 0 + + def update_stock(self, quantity: int) -> None: + """Update stock level and timestamp""" + self.stock_level = quantity + self.update_timestamp(f"stock_update_{quantity}") + + def to_dict(self) -> dict[str, Any]: + result = super().to_dict() + result.update({"stock_level": self.stock_level, "created_at": self.created_at, "updated_at": self.updated_at}) + return result diff --git a/test/resources/repos/python/test_repo/test_repo/name_collisions.py b/test/resources/repos/python/test_repo/test_repo/name_collisions.py new file mode 100644 index 0000000..21b2cc6 --- /dev/null +++ b/test/resources/repos/python/test_repo/test_repo/name_collisions.py @@ -0,0 +1,34 @@ +# ruff: noqa +var_will_be_overwritten = 1 + +var_will_be_overwritten = 2 + + +def func_using_overwritten_var(): + print(var_will_be_overwritten) + + +class ClassWillBeOverwritten: + def method1(self): + pass + + +class ClassWillBeOverwritten: + def method2(self): + pass + + +def func_will_be_overwritten(): + pass + + +def func_will_be_overwritten(): + pass + + +def func_calling_overwritten_func(): + func_will_be_overwritten() + + +def func_calling_overwritten_class(): + ClassWillBeOverwritten() diff --git a/test/resources/repos/python/test_repo/test_repo/nested.py b/test/resources/repos/python/test_repo/test_repo/nested.py new file mode 100644 index 0000000..2dfac93 --- /dev/null +++ b/test/resources/repos/python/test_repo/test_repo/nested.py @@ -0,0 +1,16 @@ +class OuterClass: + class NestedClass: + def find_me(self): + pass + + def nested_test(self): + class WithinMethod: + pass + + def func_within_func(): + pass + + a = self.NestedClass() # noqa: F841 + + +b = OuterClass().NestedClass().find_me() diff --git a/test/resources/repos/python/test_repo/test_repo/nested_base.py b/test/resources/repos/python/test_repo/test_repo/nested_base.py new file mode 100644 index 0000000..d064686 --- /dev/null +++ b/test/resources/repos/python/test_repo/test_repo/nested_base.py @@ -0,0 +1,75 @@ +""" +Module to test parsing of classes with nested module paths in base classes. +""" + +from typing import Generic, TypeVar + +T = TypeVar("T") + + +class BaseModule: + """Base module class for nested module tests.""" + + +class SubModule: + """Sub-module class for nested paths.""" + + class NestedBase: + """Nested base class.""" + + def base_method(self): + """Base method.""" + return "base" + + class NestedLevel2: + """Nested level 2.""" + + def nested_level_2_method(self): + """Nested level 2 method.""" + return "nested_level_2" + + class GenericBase(Generic[T]): + """Generic nested base class.""" + + def generic_method(self, value: T) -> T: + """Generic method.""" + return value + + +# Classes extending base classes with single-level nesting +class FirstLevel(SubModule): + """Class extending a class from a nested module path.""" + + def first_level_method(self): + """First level method.""" + return "first" + + +# Classes extending base classes with multi-level nesting +class TwoLevel(SubModule.NestedBase): + """Class extending a doubly-nested base class.""" + + def multi_level_method(self): + """Multi-level method.""" + return "multi" + + def base_method(self): + """Override of base method.""" + return "overridden" + + +class ThreeLevel(SubModule.NestedBase.NestedLevel2): + """Class extending a triply-nested base class.""" + + def three_level_method(self): + """Three-level method.""" + return "three" + + +# Class extending a generic base class with nesting +class GenericExtension(SubModule.GenericBase[str]): + """Class extending a generic nested base class.""" + + def generic_extension_method(self, text: str) -> str: + """Extension method.""" + return f"Extended: {text}" diff --git a/test/resources/repos/python/test_repo/test_repo/overloaded.py b/test/resources/repos/python/test_repo/test_repo/overloaded.py new file mode 100644 index 0000000..1a0d83a --- /dev/null +++ b/test/resources/repos/python/test_repo/test_repo/overloaded.py @@ -0,0 +1,88 @@ +""" +Module demonstrating function and method overloading with typing.overload +""" + +from typing import Any, overload + + +# Example of function overloading +@overload +def process_data(data: str) -> dict[str, str]: ... + + +@overload +def process_data(data: int) -> dict[str, int]: ... + + +@overload +def process_data(data: list[str | int]) -> dict[str, list[str | int]]: ... + + +def process_data(data: str | int | list[str | int]) -> dict[str, Any]: + """ + Process data based on its type. + + - If string: returns a dict with 'value': + - If int: returns a dict with 'value': + - If list: returns a dict with 'value': + """ + return {"value": data} + + +# Class with overloaded methods +class DataProcessor: + """ + A class demonstrating method overloading. + """ + + @overload + def transform(self, input_value: str) -> str: ... + + @overload + def transform(self, input_value: int) -> int: ... + + @overload + def transform(self, input_value: list[Any]) -> list[Any]: ... + + def transform(self, input_value: str | int | list[Any]) -> str | int | list[Any]: + """ + Transform input based on its type. + + - If string: returns the string in uppercase + - If int: returns the int multiplied by 2 + - If list: returns the list sorted + """ + if isinstance(input_value, str): + return input_value.upper() + elif isinstance(input_value, int): + return input_value * 2 + elif isinstance(input_value, list): + try: + return sorted(input_value) + except TypeError: + return input_value + return input_value + + @overload + def fetch(self, id: int) -> dict[str, Any]: ... + + @overload + def fetch(self, id: str, cache: bool = False) -> dict[str, Any] | None: ... + + def fetch(self, id: int | str, cache: bool = False) -> dict[str, Any] | None: + """ + Fetch data for a given ID. + + Args: + id: The ID to fetch, either numeric or string + cache: Whether to use cache for string IDs + + Returns: + Data dictionary or None if not found + + """ + # Implementation would actually fetch data + if isinstance(id, int): + return {"id": id, "type": "numeric"} + else: + return {"id": id, "type": "string", "cached": cache} diff --git a/test/resources/repos/python/test_repo/test_repo/services.py b/test/resources/repos/python/test_repo/test_repo/services.py new file mode 100644 index 0000000..01654ba --- /dev/null +++ b/test/resources/repos/python/test_repo/test_repo/services.py @@ -0,0 +1,78 @@ +""" +Services module demonstrating function usage and dependencies. +""" + +from typing import Any + +from .models import Item, User + + +class UserService: + """Service for user-related operations""" + + def __init__(self, user_db: dict[str, User] | None = None): + self.users = user_db or {} + + def create_user(self, id: str, name: str, email: str) -> User: + """Create a new user and store it""" + if id in self.users: + raise ValueError(f"User with ID {id} already exists") + + user = User(id=id, name=name, email=email) + self.users[id] = user + return user + + def get_user(self, id: str) -> User | None: + """Get a user by ID""" + return self.users.get(id) + + def list_users(self) -> list[User]: + """Get a list of all users""" + return list(self.users.values()) + + def delete_user(self, id: str) -> bool: + """Delete a user by ID""" + if id in self.users: + del self.users[id] + return True + return False + + +class ItemService: + """Service for item-related operations""" + + def __init__(self, item_db: dict[str, Item] | None = None): + self.items = item_db or {} + + def create_item(self, id: str, name: str, price: float, category: str) -> Item: + """Create a new item and store it""" + if id in self.items: + raise ValueError(f"Item with ID {id} already exists") + + item = Item(id=id, name=name, price=price, category=category) + self.items[id] = item + return item + + def get_item(self, id: str) -> Item | None: + """Get an item by ID""" + return self.items.get(id) + + def list_items(self, category: str | None = None) -> list[Item]: + """List all items, optionally filtered by category""" + if category: + return [item for item in self.items.values() if item.category == category] + return list(self.items.values()) + + +# Factory function for services +def create_service_container() -> dict[str, Any]: + """Create a container with all services""" + container = {"user_service": UserService(), "item_service": ItemService()} + return container + + +user_var_str = "user_var" + + +user_service = UserService() +user_service.create_user("1", "Alice", "alice@example.com") diff --git a/test/resources/repos/python/test_repo/test_repo/utils.py b/test/resources/repos/python/test_repo/test_repo/utils.py new file mode 100644 index 0000000..19d4f1b --- /dev/null +++ b/test/resources/repos/python/test_repo/test_repo/utils.py @@ -0,0 +1,123 @@ +""" +Utility functions and classes demonstrating various Python features. +""" + +import logging +from collections.abc import Callable +from typing import Any, TypeVar + +# Type variables for generic functions +T = TypeVar("T") +U = TypeVar("U") + + +def setup_logging(level: str = "INFO") -> logging.Logger: + """Set up and return a configured logger""" + levels = { + "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "WARNING": logging.WARNING, + "ERROR": logging.ERROR, + "CRITICAL": logging.CRITICAL, + } + + logger = logging.getLogger("test_repo") + logger.setLevel(levels.get(level.upper(), logging.INFO)) + + handler = logging.StreamHandler() + formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + handler.setFormatter(formatter) + logger.addHandler(handler) + + return logger + + +# Decorator example +def log_execution(func: Callable) -> Callable: + """Decorator to log function execution""" + + def wrapper(*args, **kwargs): + logger = logging.getLogger("test_repo") + logger.info(f"Executing function: {func.__name__}") + result = func(*args, **kwargs) + logger.info(f"Completed function: {func.__name__}") + return result + + return wrapper + + +# Higher-order function +def map_list(items: list[T], mapper: Callable[[T], U]) -> list[U]: + """Map a function over a list of items""" + return [mapper(item) for item in items] + + +# Class with various Python features +class ConfigManager: + """Manages configuration with various access patterns""" + + _instance = None + + # Singleton pattern + def __new__(cls, *args, **kwargs): + if not cls._instance: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self, initial_config: dict[str, Any] | None = None): + if not hasattr(self, "initialized"): + self.config = initial_config or {} + self.initialized = True + + def __getitem__(self, key: str) -> Any: + """Allow dictionary-like access""" + return self.config.get(key) + + def __setitem__(self, key: str, value: Any) -> None: + """Allow dictionary-like setting""" + self.config[key] = value + + @property + def debug_mode(self) -> bool: + """Property example""" + return self.config.get("debug", False) + + @debug_mode.setter + def debug_mode(self, value: bool) -> None: + self.config["debug"] = value + + +# Context manager example +class Timer: + """Context manager for timing code execution""" + + def __init__(self, name: str = "Timer"): + self.name = name + self.start_time = None + self.end_time = None + + def __enter__(self): + import time + + self.start_time = time.time() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + import time + + self.end_time = time.time() + print(f"{self.name} took {self.end_time - self.start_time:.6f} seconds") + + +# Functions with default arguments +def retry(func: Callable, max_attempts: int = 3, delay: float = 1.0) -> Any: + """Retry a function with backoff""" + import time + + for attempt in range(max_attempts): + try: + return func() + except Exception as e: + if attempt == max_attempts - 1: + raise e + time.sleep(delay * (2**attempt)) diff --git a/test/resources/repos/python/test_repo/test_repo/variables.py b/test/resources/repos/python/test_repo/test_repo/variables.py new file mode 100644 index 0000000..81d6fbd --- /dev/null +++ b/test/resources/repos/python/test_repo/test_repo/variables.py @@ -0,0 +1,96 @@ +""" +Test module for variable declarations and usage. + +This module tests various types of variable declarations and usages including: +- Module-level variables +- Class-level variables +- Instance variables +- Variable reassignments +""" + +from dataclasses import dataclass, field + +# Module-level variables +module_var = "Initial module value" + +reassignable_module_var = 10 +reassignable_module_var = 20 # Reassigned + +# Module-level variable with type annotation +typed_module_var: int = 42 + + +# Regular class with class and instance variables +class VariableContainer: + """Class that contains various variables.""" + + # Class-level variables + class_var = "Initial class value" + + reassignable_class_var = True + reassignable_class_var = False # Reassigned #noqa: PIE794 + + # Class-level variable with type annotation + typed_class_var: str = "typed value" + + def __init__(self): + # Instance variables + self.instance_var = "Initial instance value" + self.reassignable_instance_var = 100 + + # Instance variable with type annotation + self.typed_instance_var: list[str] = ["item1", "item2"] + + def modify_instance_var(self): + # Reassign instance variable + self.instance_var = "Modified instance value" + self.reassignable_instance_var = 200 # Reassigned + + def use_module_var(self): + # Use module-level variables + result = module_var + " used in method" + other_result = reassignable_module_var + 5 + return result, other_result + + def use_class_var(self): + # Use class-level variables + result = VariableContainer.class_var + " used in method" + other_result = VariableContainer.reassignable_class_var + return result, other_result + + +# Dataclass with variables +@dataclass +class VariableDataclass: + """Dataclass that contains various fields.""" + + # Field variables with type annotations + id: int + name: str + items: list[str] = field(default_factory=list) + metadata: dict[str, str] = field(default_factory=dict) + optional_value: float | None = None + + # This will be reassigned in various places + status: str = "pending" + + +# Function that uses the module variables +def use_module_variables(): + """Function that uses module-level variables.""" + result = module_var + " used in function" + other_result = reassignable_module_var * 2 + return result, other_result + + +# Create instances and use variables +dataclass_instance = VariableDataclass(id=1, name="Test") +dataclass_instance.status = "active" # Reassign dataclass field + +# Use variables at module level +module_result = module_var + " used at module level" +other_module_result = reassignable_module_var + 30 + +# Create a second dataclass instance with different status +second_dataclass = VariableDataclass(id=2, name="Another Test") +second_dataclass.status = "completed" # Another reassignment of status diff --git a/test/resources/repos/r/test_repo/.Rbuildignore b/test/resources/repos/r/test_repo/.Rbuildignore new file mode 100644 index 0000000..168b513 --- /dev/null +++ b/test/resources/repos/r/test_repo/.Rbuildignore @@ -0,0 +1,3 @@ +^.*\.Rproj$ +^\.Rproj\.user$ +^\.serena$ \ No newline at end of file diff --git a/test/resources/repos/r/test_repo/DESCRIPTION b/test/resources/repos/r/test_repo/DESCRIPTION new file mode 100644 index 0000000..d42148c --- /dev/null +++ b/test/resources/repos/r/test_repo/DESCRIPTION @@ -0,0 +1,13 @@ +Package: testpackage +Type: Package +Title: Test Package for R Language Server +Version: 1.0.0 +Author: Serena Test +Maintainer: Serena Test +Description: A minimal test package for testing R language server functionality. + This package contains sample R functions for testing symbol extraction, + completion, and other language server features. +License: MIT + file LICENSE +Encoding: UTF-8 +LazyData: true +RoxygenNote: 7.2.0 \ No newline at end of file diff --git a/test/resources/repos/r/test_repo/NAMESPACE b/test/resources/repos/r/test_repo/NAMESPACE new file mode 100644 index 0000000..857bf83 --- /dev/null +++ b/test/resources/repos/r/test_repo/NAMESPACE @@ -0,0 +1,7 @@ +# Generated by roxygen2: do not edit by hand + +export(calculate_mean) +export(create_data_frame) +export(fit_linear_model) +export(plot_data) +export(process_data) \ No newline at end of file diff --git a/test/resources/repos/r/test_repo/R/models.R b/test/resources/repos/r/test_repo/R/models.R new file mode 100644 index 0000000..b130e8f --- /dev/null +++ b/test/resources/repos/r/test_repo/R/models.R @@ -0,0 +1,48 @@ +#' Fit a linear model +#' +#' @param formula A formula for the model +#' @param data A data frame containing the variables +#' @return A fitted lm object +#' @export +fit_linear_model <- function(formula, data) { + if (missing(formula) || missing(data)) { + stop("Both formula and data are required") + } + + model <- lm(formula, data = data) + + # Add some custom attributes + attr(model, "created_by") <- "fit_linear_model" + attr(model, "creation_time") <- Sys.time() + + return(model) +} + +#' Plot data using ggplot2-style syntax +#' +#' @param data A data frame +#' @param x_var Column name for x-axis +#' @param y_var Column name for y-axis +#' @return A plot object +#' @export +plot_data <- function(data, x_var, y_var) { + if (!is.data.frame(data)) { + stop("data must be a data frame") + } + + if (!(x_var %in% names(data))) { + stop(paste("Column", x_var, "not found in data")) + } + + if (!(y_var %in% names(data))) { + stop(paste("Column", y_var, "not found in data")) + } + + # Create a simple base R plot + plot(data[[x_var]], data[[y_var]], + xlab = x_var, ylab = y_var, + main = paste(y_var, "vs", x_var)) + + # Add a trend line + abline(lm(data[[y_var]] ~ data[[x_var]]), col = "red") +} \ No newline at end of file diff --git a/test/resources/repos/r/test_repo/R/utils.R b/test/resources/repos/r/test_repo/R/utils.R new file mode 100644 index 0000000..d516f15 --- /dev/null +++ b/test/resources/repos/r/test_repo/R/utils.R @@ -0,0 +1,44 @@ +#' Calculate mean of numeric vector +#' +#' @param x A numeric vector +#' @return The mean of the vector +#' @export +calculate_mean <- function(x) { + if (!is.numeric(x)) { + stop("Input must be numeric") + } + mean(x, na.rm = TRUE) +} + +#' Process data by removing missing values +#' +#' @param data A data frame +#' @return A cleaned data frame +#' @export +process_data <- function(data) { + if (!is.data.frame(data)) { + stop("Input must be a data frame") + } + + # Remove rows with any missing values + clean_data <- na.omit(data) + + # Add a processed flag + clean_data$processed <- TRUE + + return(clean_data) +} + +#' Create a sample data frame +#' +#' @param n Number of rows to create +#' @return A data frame with sample data +#' @export +create_data_frame <- function(n = 100) { + data.frame( + id = 1:n, + value = rnorm(n), + category = sample(c("A", "B", "C"), n, replace = TRUE), + stringsAsFactors = FALSE + ) +} \ No newline at end of file diff --git a/test/resources/repos/r/test_repo/examples/analysis.R b/test/resources/repos/r/test_repo/examples/analysis.R new file mode 100644 index 0000000..508c451 --- /dev/null +++ b/test/resources/repos/r/test_repo/examples/analysis.R @@ -0,0 +1,30 @@ +# Example R script demonstrating package usage + +# Load required libraries +library(testpackage) + +# Create sample data +sample_data <- create_data_frame(n = 50) + +# Process the data +clean_data <- process_data(sample_data) + +# Calculate some statistics +mean_value <- calculate_mean(clean_data$value) +cat("Mean value:", mean_value, "\n") + +# Fit a simple model +model <- fit_linear_model(value ~ id, data = clean_data) +summary(model) + +# Create a plot +plot_data(clean_data, "id", "value") + +# Additional analysis function (not exported) +analyze_categories <- function(data) { + table(data$category) +} + +# Run the analysis +category_summary <- analyze_categories(clean_data) +print(category_summary) \ No newline at end of file diff --git a/test/resources/repos/rego/test_repo/policies/authz.rego b/test/resources/repos/rego/test_repo/policies/authz.rego new file mode 100644 index 0000000..fbac4ba --- /dev/null +++ b/test/resources/repos/rego/test_repo/policies/authz.rego @@ -0,0 +1,33 @@ +package policies + +import data.utils + +# Default deny +default allow := false + +# Admin access rule +allow if { + input.user.role == "admin" + utils.is_valid_user(input.user) +} + +# Read access for authenticated users +allow_read if { + input.action == "read" + input.user.authenticated +} + +# User roles list +admin_roles := ["admin", "superuser"] + +# Helper function to check if user is admin +is_admin(user) if { + admin_roles[_] == user.role +} + +# Check if action is allowed for user +check_permission(user, action) if { + user.role == "admin" + allowed_actions := ["read", "write", "delete"] + allowed_actions[_] == action +} diff --git a/test/resources/repos/rego/test_repo/policies/validation.rego b/test/resources/repos/rego/test_repo/policies/validation.rego new file mode 100644 index 0000000..49e6422 --- /dev/null +++ b/test/resources/repos/rego/test_repo/policies/validation.rego @@ -0,0 +1,23 @@ +package policies + +import data.policies +import data.utils + +# Validate user input +validate_user_input if { + utils.is_valid_user(input.user) + utils.is_valid_email(input.user.email) +} + +# Check if user has valid credentials +has_valid_credentials(user) if { + user.username != "" + user.password != "" + utils.is_valid_email(user.email) +} + +# Validate request +validate_request if { + input.user.authenticated + policies.allow +} diff --git a/test/resources/repos/rego/test_repo/utils/helpers.rego b/test/resources/repos/rego/test_repo/utils/helpers.rego new file mode 100644 index 0000000..2fb60bd --- /dev/null +++ b/test/resources/repos/rego/test_repo/utils/helpers.rego @@ -0,0 +1,29 @@ +package utils + +# User validation +is_valid_user(user) if { + user.id != "" + user.email != "" +} + +# Email validation +is_valid_email(email) if { + contains(email, "@") + contains(email, ".") +} + +# Username validation +is_valid_username(username) if { + count(username) >= 3 + count(username) <= 32 +} + +# Check if string is empty +is_empty(str) if { + str == "" +} + +# Check if array contains element +array_contains(arr, elem) if { + arr[_] == elem +} diff --git a/test/resources/repos/ruby/test_repo/.solargraph.yml b/test/resources/repos/ruby/test_repo/.solargraph.yml new file mode 100644 index 0000000..b4678ee --- /dev/null +++ b/test/resources/repos/ruby/test_repo/.solargraph.yml @@ -0,0 +1,4 @@ +--- +include: + - "main.rb" + - "lib.rb" diff --git a/test/resources/repos/ruby/test_repo/examples/user_management.rb b/test/resources/repos/ruby/test_repo/examples/user_management.rb new file mode 100644 index 0000000..e14091a --- /dev/null +++ b/test/resources/repos/ruby/test_repo/examples/user_management.rb @@ -0,0 +1,116 @@ +require '../services.rb' +require '../models.rb' + +class UserStats + attr_reader :user_count, :active_users, :last_updated + + def initialize + @user_count = 0 + @active_users = 0 + @last_updated = Time.now + end + + def update_stats(total, active) + @user_count = total + @active_users = active + @last_updated = Time.now + end + + def activity_ratio + return 0.0 if @user_count == 0 + (@active_users.to_f / @user_count * 100).round(2) + end + + def formatted_stats + "Users: #{@user_count}, Active: #{@active_users} (#{activity_ratio}%)" + end +end + +class UserManager + def initialize + @service = Services::UserService.new + @stats = UserStats.new + end + + def create_user_with_tracking(id, name, email = nil) + user = @service.create_user(id, name) + user.email = email if email + + update_statistics + notify_user_created(user) + + user + end + + def get_user_details(id) + user = @service.get_user(id) + return nil unless user + + { + user_info: user.full_info, + created_at: Time.now, + stats: @stats.formatted_stats + } + end + + def bulk_create_users(user_data_list) + created_users = [] + + user_data_list.each do |data| + user = create_user_with_tracking(data[:id], data[:name], data[:email]) + created_users << user + end + + created_users + end + + private + + def update_statistics + total_users = @service.users.length + # For demo purposes, assume all users are active + @stats.update_stats(total_users, total_users) + end + + def notify_user_created(user) + puts "User created: #{user.name} (ID: #{user.id})" + end +end + +def process_user_data(raw_data) + processed = raw_data.map do |entry| + { + id: entry["id"] || entry[:id], + name: entry["name"] || entry[:name], + email: entry["email"] || entry[:email] + } + end + + processed.reject { |entry| entry[:name].nil? || entry[:name].empty? } +end + +def main + # Example usage + manager = UserManager.new + + sample_data = [ + { id: 1, name: "Alice Johnson", email: "alice@example.com" }, + { id: 2, name: "Bob Smith", email: "bob@example.com" }, + { id: 3, name: "Charlie Brown" } + ] + + users = manager.bulk_create_users(sample_data) + + users.each do |user| + details = manager.get_user_details(user.id) + puts details[:user_info] + end + + puts "\nFinal statistics:" + stats = UserStats.new + stats.update_stats(users.length, users.length) + puts stats.formatted_stats +end + +# Execute if this file is run directly +main if __FILE__ == $0 \ No newline at end of file diff --git a/test/resources/repos/ruby/test_repo/lib.rb b/test/resources/repos/ruby/test_repo/lib.rb new file mode 100644 index 0000000..65eba9d --- /dev/null +++ b/test/resources/repos/ruby/test_repo/lib.rb @@ -0,0 +1,21 @@ +class Calculator + def add(a, b) + a + b + end + + def subtract(a, b) + a - b + end +end + +class Greeter + def format_greeting(name) + name + end +end + +class ConsoleGreeter < Greeter + def format_greeting(name) + "Hello, #{name}!" + end +end diff --git a/test/resources/repos/ruby/test_repo/main.rb b/test/resources/repos/ruby/test_repo/main.rb new file mode 100644 index 0000000..b9a98c9 --- /dev/null +++ b/test/resources/repos/ruby/test_repo/main.rb @@ -0,0 +1,23 @@ +require './lib.rb' + +class DemoClass + attr_accessor :value + + def initialize(value) + @value = value + end + + def print_value + puts @value + end +end + +def helper_function(number = 42) + demo = DemoClass.new(number) + Calculator.new.add(demo.value, 10) + greeter = ConsoleGreeter.new + puts greeter.format_greeting("Ruby") + demo.print_value +end + +helper_function diff --git a/test/resources/repos/ruby/test_repo/models.rb b/test/resources/repos/ruby/test_repo/models.rb new file mode 100644 index 0000000..64476c3 --- /dev/null +++ b/test/resources/repos/ruby/test_repo/models.rb @@ -0,0 +1,86 @@ +class User + attr_accessor :id, :name, :email + + def initialize(id, name, email = nil) + @id = id + @name = name + @email = email + end + + def full_info + info = "User: #{@name} (ID: #{@id})" + info += ", Email: #{@email}" if @email + info + end + + def to_hash + { + id: @id, + name: @name, + email: @email + } + end + + def self.from_hash(hash) + new(hash[:id], hash[:name], hash[:email]) + end + + class << self + def default_user + new(0, "Guest") + end + end +end + +class Item + attr_reader :id, :name, :price + + def initialize(id, name, price) + @id = id + @name = name + @price = price + end + + def discounted_price(discount_percent) + @price * (1 - discount_percent / 100.0) + end + + def description + "#{@name}: $#{@price}" + end +end + +module ItemHelpers + def format_price(price) + "$#{sprintf('%.2f', price)}" + end + + def calculate_tax(price, tax_rate = 0.08) + price * tax_rate + end +end + +class Order + include ItemHelpers + + def initialize + @items = [] + @total = 0 + end + + def add_item(item, quantity = 1) + @items << { item: item, quantity: quantity } + calculate_total + end + + def total_with_tax + tax = calculate_tax(@total) + @total + tax + end + + private + + def calculate_total + @total = @items.sum { |entry| entry[:item].price * entry[:quantity] } + end +end \ No newline at end of file diff --git a/test/resources/repos/ruby/test_repo/nested.rb b/test/resources/repos/ruby/test_repo/nested.rb new file mode 100644 index 0000000..61c4edc --- /dev/null +++ b/test/resources/repos/ruby/test_repo/nested.rb @@ -0,0 +1,51 @@ +class OuterClass + def initialize + @value = "outer" + end + + def outer_method + inner_function = lambda do |x| + x * 2 + end + + result = inner_function.call(5) + puts "Result: #{result}" + end + + class NestedClass + def initialize(name) + @name = name + end + + def find_me + "Found in NestedClass: #{@name}" + end + + def nested_method + puts "Nested method called" + end + + class DeeplyNested + def deep_method + "Deep inside" + end + end + end + + module NestedModule + def module_method + "Module method" + end + + class ModuleClass + def module_class_method + "Module class method" + end + end + end +end + +# Test usage of nested classes +outer = OuterClass.new +nested = OuterClass::NestedClass.new("test") +result = nested.find_me \ No newline at end of file diff --git a/test/resources/repos/ruby/test_repo/services.rb b/test/resources/repos/ruby/test_repo/services.rb new file mode 100644 index 0000000..7d25381 --- /dev/null +++ b/test/resources/repos/ruby/test_repo/services.rb @@ -0,0 +1,60 @@ +require './lib.rb' +require './models.rb' + +module Services + class UserService + attr_reader :users + + def initialize + @users = {} + end + + def create_user(id, name) + user = User.new(id, name) + @users[id] = user + user + end + + def get_user(id) + @users[id] + end + + def delete_user(id) + @users.delete(id) + end + + private + + def validate_user_data(id, name) + return false if id.nil? || name.nil? + return false if name.empty? + true + end + end + + class ItemService + def initialize + @items = [] + end + + def add_item(item) + @items << item + end + + def find_item(id) + @items.find { |item| item.id == id } + end + end +end + +# Module-level function +def create_service_container + { + user_service: Services::UserService.new, + item_service: Services::ItemService.new + } +end + +# Variables for testing +user_service_instance = Services::UserService.new +item_service_instance = Services::ItemService.new \ No newline at end of file diff --git a/test/resources/repos/ruby/test_repo/variables.rb b/test/resources/repos/ruby/test_repo/variables.rb new file mode 100644 index 0000000..63e4190 --- /dev/null +++ b/test/resources/repos/ruby/test_repo/variables.rb @@ -0,0 +1,150 @@ +require './models.rb' + +# Global variables for testing references +$global_counter = 0 +$global_config = { + debug: true, + timeout: 30 +} + +class DataContainer + attr_accessor :status, :data, :metadata + + def initialize + @status = "pending" + @data = {} + @metadata = { + created_at: Time.now, + version: "1.0" + } + end + + def update_status(new_status) + old_status = @status + @status = new_status + log_status_change(old_status, new_status) + end + + def process_data(input_data) + @data = input_data + @status = "processing" + + # Process the data + result = @data.transform_values { |v| v.to_s.upcase } + @status = "completed" + + result + end + + def get_metadata_info + info = "Status: #{@status}, Version: #{@metadata[:version]}" + info += ", Created: #{@metadata[:created_at]}" + info + end + + private + + def log_status_change(old_status, new_status) + puts "Status changed from #{old_status} to #{new_status}" + end +end + +class StatusTracker + def initialize + @tracked_items = [] + end + + def add_item(item) + @tracked_items << item + item.status = "tracked" if item.respond_to?(:status=) + end + + def find_by_status(target_status) + @tracked_items.select { |item| item.status == target_status } + end + + def update_all_status(new_status) + @tracked_items.each do |item| + item.status = new_status if item.respond_to?(:status=) + end + end +end + +# Module level variables and functions +module ProcessingHelper + PROCESSING_MODES = ["sync", "async", "batch"].freeze + + @@instance_count = 0 + + def self.create_processor(mode = "sync") + @@instance_count += 1 + { + id: @@instance_count, + mode: mode, + created_at: Time.now + } + end + + def self.get_instance_count + @@instance_count + end +end + +# Test instances for reference testing +dataclass_instance = DataContainer.new +dataclass_instance.status = "initialized" + +second_dataclass = DataContainer.new +second_dataclass.update_status("ready") + +tracker = StatusTracker.new +tracker.add_item(dataclass_instance) +tracker.add_item(second_dataclass) + +# Function that uses the variables +def demonstrate_variable_usage + puts "Global counter: #{$global_counter}" + + container = DataContainer.new + container.status = "demo" + + processor = ProcessingHelper.create_processor("async") + puts "Created processor #{processor[:id]} in #{processor[:mode]} mode" + + container +end + +# More complex variable interactions +class VariableInteractionTest + def initialize + @internal_status = "created" + @data_containers = [] + end + + def add_container(container) + @data_containers << container + container.status = "added_to_collection" + @internal_status = "modified" + end + + def process_all_containers + @data_containers.each do |container| + container.status = "batch_processed" + end + @internal_status = "processing_complete" + end + + def get_status_summary + statuses = @data_containers.map(&:status) + { + internal: @internal_status, + containers: statuses, + count: @data_containers.length + } + end +end + +# Create instances for testing +interaction_test = VariableInteractionTest.new +interaction_test.add_container(dataclass_instance) +interaction_test.add_container(second_dataclass) \ No newline at end of file diff --git a/test/resources/repos/rust/test_repo/Cargo.lock b/test/resources/repos/rust/test_repo/Cargo.lock new file mode 100644 index 0000000..ebc459f --- /dev/null +++ b/test/resources/repos/rust/test_repo/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "rsandbox" +version = "0.1.0" diff --git a/test/resources/repos/rust/test_repo/Cargo.toml b/test/resources/repos/rust/test_repo/Cargo.toml new file mode 100644 index 0000000..43ffbf6 --- /dev/null +++ b/test/resources/repos/rust/test_repo/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "rsandbox" +version = "0.1.0" +edition = "2021" + +[dependencies] diff --git a/test/resources/repos/rust/test_repo/src/diagnostics_sample.rs b/test/resources/repos/rust/test_repo/src/diagnostics_sample.rs new file mode 100644 index 0000000..120e9e3 --- /dev/null +++ b/test/resources/repos/rust/test_repo/src/diagnostics_sample.rs @@ -0,0 +1,9 @@ +pub fn broken_factory() -> String { + missing_greeting +} + +pub fn broken_consumer() { + let value = broken_factory(); + println!("{value}"); + println!("{}", missing_consumer_value); +} diff --git a/test/resources/repos/rust/test_repo/src/lib.rs b/test/resources/repos/rust/test_repo/src/lib.rs new file mode 100644 index 0000000..178d016 --- /dev/null +++ b/test/resources/repos/rust/test_repo/src/lib.rs @@ -0,0 +1,23 @@ +pub mod diagnostics_sample; + +// This function returns the sum of 2 + 2 +pub fn add() -> i32 { + let res = 2 + 2; + res +} +pub fn multiply() -> i32 { + 2 * 3 +} + +pub trait Greeter { + fn format_greeting(&self, name: &str) -> String; +} + +pub struct ConsoleGreeter; + +impl Greeter for ConsoleGreeter { + fn format_greeting(&self, name: &str) -> String { + format!("Hello, {name}!") + } +} + diff --git a/test/resources/repos/rust/test_repo/src/main.rs b/test/resources/repos/rust/test_repo/src/main.rs new file mode 100644 index 0000000..a5bb806 --- /dev/null +++ b/test/resources/repos/rust/test_repo/src/main.rs @@ -0,0 +1,10 @@ +use rsandbox::{add, ConsoleGreeter, Greeter}; + +fn main() { + println!("Hello, World!"); + println!("Good morning!"); + println!("add result: {}", add()); + let greeter = ConsoleGreeter; + println!("{}", greeter.format_greeting("Rust")); + println!("inserted line"); +} diff --git a/test/resources/repos/rust/test_repo_2024/Cargo.lock b/test/resources/repos/rust/test_repo_2024/Cargo.lock new file mode 100644 index 0000000..88beb72 --- /dev/null +++ b/test/resources/repos/rust/test_repo_2024/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "rsandbox_2024" +version = "0.1.0" diff --git a/test/resources/repos/rust/test_repo_2024/Cargo.toml b/test/resources/repos/rust/test_repo_2024/Cargo.toml new file mode 100644 index 0000000..2f20bf2 --- /dev/null +++ b/test/resources/repos/rust/test_repo_2024/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "rsandbox_2024" +version = "0.1.0" +edition = "2024" + +[dependencies] \ No newline at end of file diff --git a/test/resources/repos/rust/test_repo_2024/src/lib.rs b/test/resources/repos/rust/test_repo_2024/src/lib.rs new file mode 100644 index 0000000..28fac4c --- /dev/null +++ b/test/resources/repos/rust/test_repo_2024/src/lib.rs @@ -0,0 +1,21 @@ +pub fn multiply(a: i32, b: i32) -> i32 { + a * b +} + +pub struct Calculator { + pub result: i32, +} + +impl Calculator { + pub fn new() -> Self { + Calculator { result: 0 } + } + + pub fn add(&mut self, value: i32) { + self.result += value; + } + + pub fn get_result(&self) -> i32 { + self.result + } +} \ No newline at end of file diff --git a/test/resources/repos/rust/test_repo_2024/src/main.rs b/test/resources/repos/rust/test_repo_2024/src/main.rs new file mode 100644 index 0000000..ec20c47 --- /dev/null +++ b/test/resources/repos/rust/test_repo_2024/src/main.rs @@ -0,0 +1,19 @@ +fn main() { + println!("Hello, Rust 2024 edition!"); + let result = add(2, 3); + println!("2 + 3 = {}", result); +} + +pub fn add(a: i32, b: i32) -> i32 { + a + b +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add() { + assert_eq!(add(2, 3), 5); + } +} \ No newline at end of file diff --git a/test/resources/repos/scala/build.sbt b/test/resources/repos/scala/build.sbt new file mode 100644 index 0000000..d00e145 --- /dev/null +++ b/test/resources/repos/scala/build.sbt @@ -0,0 +1 @@ +scalaVersion := "2.13.16" diff --git a/test/resources/repos/scala/project/build.properties b/test/resources/repos/scala/project/build.properties new file mode 100644 index 0000000..ee4c672 --- /dev/null +++ b/test/resources/repos/scala/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.10.1 diff --git a/test/resources/repos/scala/project/metals.sbt b/test/resources/repos/scala/project/metals.sbt new file mode 100644 index 0000000..69465cb --- /dev/null +++ b/test/resources/repos/scala/project/metals.sbt @@ -0,0 +1,8 @@ +// format: off +// DO NOT EDIT! This file is auto-generated. + +// This file enables sbt-bloop to create bloop config files. + +addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "2.0.13") + +// format: on diff --git a/test/resources/repos/scala/project/plugins.sbt b/test/resources/repos/scala/project/plugins.sbt new file mode 100644 index 0000000..a739802 --- /dev/null +++ b/test/resources/repos/scala/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "v2.0.14") \ No newline at end of file diff --git a/test/resources/repos/scala/src/main/scala/com/example/Main.scala b/test/resources/repos/scala/src/main/scala/com/example/Main.scala new file mode 100644 index 0000000..2407efe --- /dev/null +++ b/test/resources/repos/scala/src/main/scala/com/example/Main.scala @@ -0,0 +1,29 @@ +package com.example + +object Main { + def main(args: Array[String]): Unit = { + println("Hello, Scala!") + + // Use Utils from another file + Utils.printHello() + val result = Utils.multiply(3, 4) + println(s"3 * 4 = $result") + + // Call local methods + val sum = add(5, 3) + println(s"5 + 3 = $sum") + } + + def add(a: Int, b: Int): Int = { + a + b + } + + // https://github.com/oraios/serena/issues/688 + def someMethod(config: Config): Unit = { + val str = config.field1 + + println(str) + } + + case class Config(field1:String) +} diff --git a/test/resources/repos/scala/src/main/scala/com/example/Utils.scala b/test/resources/repos/scala/src/main/scala/com/example/Utils.scala new file mode 100644 index 0000000..0b6edf4 --- /dev/null +++ b/test/resources/repos/scala/src/main/scala/com/example/Utils.scala @@ -0,0 +1,11 @@ +package com.example + +object Utils { + def printHello(): Unit = { + println("Hello from Utils!") + } + + def multiply(x: Int, y: Int): Int = { + x * y + } +} diff --git a/test/resources/repos/scala/test_repo/src/main/scala/com/example/DiagnosticsSample.scala b/test/resources/repos/scala/test_repo/src/main/scala/com/example/DiagnosticsSample.scala new file mode 100644 index 0000000..ca9d929 --- /dev/null +++ b/test/resources/repos/scala/test_repo/src/main/scala/com/example/DiagnosticsSample.scala @@ -0,0 +1,9 @@ +package com.example + +object DiagnosticsSample { + def brokenFactory(): String = missingGreeting + def brokenConsumer(): Unit = { + println(brokenFactory()) + println(missingConsumerValue) + } +} diff --git a/test/resources/repos/scss/test_repo/_mixins.scss b/test/resources/repos/scss/test_repo/_mixins.scss new file mode 100644 index 0000000..88b9ff9 --- /dev/null +++ b/test/resources/repos/scss/test_repo/_mixins.scss @@ -0,0 +1,17 @@ +@use "variables" as vars; + +// Common card-style container used by buttons, panels, etc. +@mixin card-surface($bg: vars.$color-bg) { + background-color: $bg; + border-radius: 4px; + padding: vars.$space-md; +} + +@mixin focus-ring($color: vars.$color-primary) { + outline: 2px solid $color; + outline-offset: 2px; +} + +@function rem($px) { + @return ($px / 16) * 1rem; +} diff --git a/test/resources/repos/scss/test_repo/_variables.scss b/test/resources/repos/scss/test_repo/_variables.scss new file mode 100644 index 0000000..9ba4b6e --- /dev/null +++ b/test/resources/repos/scss/test_repo/_variables.scss @@ -0,0 +1,10 @@ +// Color tokens used across the design system. +$color-primary: #2563eb; +$color-secondary: #f97316; +$color-text: #1a1a1a; +$color-bg: #ffffff; + +// Spacing scale. +$space-sm: 0.5rem; +$space-md: 1rem; +$space-lg: 2rem; diff --git a/test/resources/repos/scss/test_repo/buttons.scss b/test/resources/repos/scss/test_repo/buttons.scss new file mode 100644 index 0000000..13a21dd --- /dev/null +++ b/test/resources/repos/scss/test_repo/buttons.scss @@ -0,0 +1,22 @@ +@use "variables" as vars; +@use "mixins" as mix; + +.button { + @include mix.card-surface(); + color: vars.$color-text; + cursor: pointer; + + &:focus-visible { + @include mix.focus-ring(); + } +} + +.button-primary { + @include mix.card-surface(vars.$color-primary); + color: white; +} + +.button-secondary { + @include mix.card-surface(vars.$color-secondary); + color: white; +} diff --git a/test/resources/repos/scss/test_repo/css/diagnostics_sample.css b/test/resources/repos/scss/test_repo/css/diagnostics_sample.css new file mode 100644 index 0000000..a0a21dd --- /dev/null +++ b/test/resources/repos/scss/test_repo/css/diagnostics_sample.css @@ -0,0 +1,13 @@ +/* + * Plain-CSS diagnostics sample. The vscode-css-languageservice engine inside + * some-sass reports parser errors here even with lint diagnostics off — the + * unterminated rule body and stray brace produce syntax-level diagnostics. + */ +.broken { + color: red; + background: +} + +.also-broken } { + margin: 0; +} diff --git a/test/resources/repos/scss/test_repo/css/main.css b/test/resources/repos/scss/test_repo/css/main.css new file mode 100644 index 0000000..d1b7fbe --- /dev/null +++ b/test/resources/repos/scss/test_repo/css/main.css @@ -0,0 +1,41 @@ +@import "reset.css"; +@import "theme.css"; + +body { + font-family: var(--font-body); + color: var(--color-text); + background-color: var(--color-bg); + margin: 0; +} + +#page-header { + padding: 1rem; + background-color: var(--color-primary); +} + +#site-title { + margin: 0; + font-size: 1.5rem; +} + +.button { + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; +} + +.button-primary { + background-color: var(--color-primary); + color: white; +} + +.button-secondary { + background-color: transparent; + border: 1px solid var(--color-primary); +} + +@media (max-width: 600px) { + #site-title { + font-size: 1.2rem; + } +} diff --git a/test/resources/repos/scss/test_repo/css/reset.css b/test/resources/repos/scss/test_repo/css/reset.css new file mode 100644 index 0000000..f81c6a9 --- /dev/null +++ b/test/resources/repos/scss/test_repo/css/reset.css @@ -0,0 +1,18 @@ +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; +} + +a { + color: inherit; + text-decoration: none; +} + +ul { + list-style: none; + padding: 0; +} diff --git a/test/resources/repos/scss/test_repo/css/theme.css b/test/resources/repos/scss/test_repo/css/theme.css new file mode 100644 index 0000000..3e82d36 --- /dev/null +++ b/test/resources/repos/scss/test_repo/css/theme.css @@ -0,0 +1,15 @@ +:root { + --color-bg: #ffffff; + --color-text: #1a1a1a; + --color-primary: #2563eb; + --color-secondary: #f97316; + --font-body: "Inter", system-ui, sans-serif; + --font-heading: "Inter", system-ui, sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-bg: #0f172a; + --color-text: #f1f5f9; + } +} diff --git a/test/resources/repos/scss/test_repo/diagnostics_sample.scss b/test/resources/repos/scss/test_repo/diagnostics_sample.scss new file mode 100644 index 0000000..16609f1 --- /dev/null +++ b/test/resources/repos/scss/test_repo/diagnostics_sample.scss @@ -0,0 +1,5 @@ +// Some Sass flags references to undefined variables and missing semicolons. +.broken { + color: $undefined-variable + background: $also-not-defined; +} diff --git a/test/resources/repos/scss/test_repo/main.scss b/test/resources/repos/scss/test_repo/main.scss new file mode 100644 index 0000000..1783693 --- /dev/null +++ b/test/resources/repos/scss/test_repo/main.scss @@ -0,0 +1,16 @@ +@use "variables" as vars; +@use "mixins" as mix; +@forward "buttons"; + +body { + background-color: vars.$color-bg; + color: vars.$color-text; + margin: 0; + padding: vars.$space-lg; + font-size: mix.rem(16); +} + +.panel { + @include mix.card-surface(); + margin-bottom: vars.$space-md; +} diff --git a/test/resources/repos/solidity/test_repo/.gitignore b/test/resources/repos/solidity/test_repo/.gitignore new file mode 100644 index 0000000..f9a2eef --- /dev/null +++ b/test/resources/repos/solidity/test_repo/.gitignore @@ -0,0 +1,4 @@ +out/ +cache/ +artifacts/ +node_modules/ diff --git a/test/resources/repos/solidity/test_repo/contracts/DiagnosticsSample.sol b/test/resources/repos/solidity/test_repo/contracts/DiagnosticsSample.sol new file mode 100644 index 0000000..7b2a089 --- /dev/null +++ b/test/resources/repos/solidity/test_repo/contracts/DiagnosticsSample.sol @@ -0,0 +1,7 @@ +pragma solidity ^0.8.0; + +contract DiagnosticsSample { + function brokenFactory() pure returns (string memory) { + return "broken"; + } +} diff --git a/test/resources/repos/solidity/test_repo/contracts/Token.sol b/test/resources/repos/solidity/test_repo/contracts/Token.sol new file mode 100644 index 0000000..92c698c --- /dev/null +++ b/test/resources/repos/solidity/test_repo/contracts/Token.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "./interfaces/IERC20.sol"; +import "./lib/SafeMath.sol"; + +/// @title Token +/// @notice A simple ERC-20 token implementation used as a test fixture. +contract Token is IERC20 { + using SafeMath for uint256; + + // ------------------------------------------------------------------------- + // State variables + // ------------------------------------------------------------------------- + + string public name; + string public symbol; + uint8 public decimals; + uint256 private _totalSupply; + + mapping(address => uint256) private _balances; + mapping(address => mapping(address => uint256)) private _allowances; + + // ------------------------------------------------------------------------- + // Errors + // ------------------------------------------------------------------------- + + /// @notice Thrown when transferring to the zero address. + error ZeroAddress(); + + /// @notice Thrown when the caller's balance is insufficient. + error InsufficientBalance(address account, uint256 required, uint256 available); + + /// @notice Thrown when the allowance is insufficient. + error InsufficientAllowance(address spender, uint256 required, uint256 available); + + // ------------------------------------------------------------------------- + // Constructor + // ------------------------------------------------------------------------- + + /// @param _name Human-readable token name. + /// @param _symbol Token ticker symbol. + /// @param supply Initial supply minted to `msg.sender` (in whole tokens). + constructor(string memory _name, string memory _symbol, uint256 supply) { + name = _name; + symbol = _symbol; + decimals = 18; + _mint(msg.sender, supply * 10 ** decimals); + } + + // ------------------------------------------------------------------------- + // IERC20 view functions + // ------------------------------------------------------------------------- + + /// @inheritdoc IERC20 + function totalSupply() external view override returns (uint256) { + return _totalSupply; + } + + /// @inheritdoc IERC20 + function balanceOf(address account) external view override returns (uint256) { + return _balances[account]; + } + + /// @inheritdoc IERC20 + function allowance(address owner, address spender) external view override returns (uint256) { + return _allowances[owner][spender]; + } + + // ------------------------------------------------------------------------- + // IERC20 mutating functions + // ------------------------------------------------------------------------- + + /// @inheritdoc IERC20 + function transfer(address to, uint256 amount) external override returns (bool) { + _transfer(msg.sender, to, amount); + return true; + } + + /// @inheritdoc IERC20 + function approve(address spender, uint256 amount) external override returns (bool) { + _approve(msg.sender, spender, amount); + return true; + } + + /// @inheritdoc IERC20 + function transferFrom(address from, address to, uint256 amount) external override returns (bool) { + uint256 currentAllowance = _allowances[from][msg.sender]; + if (currentAllowance < amount) { + revert InsufficientAllowance(msg.sender, amount, currentAllowance); + } + _approve(from, msg.sender, currentAllowance.sub(amount)); + _transfer(from, to, amount); + return true; + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + function _transfer(address from, address to, uint256 amount) internal { + if (to == address(0)) revert ZeroAddress(); + uint256 fromBalance = _balances[from]; + if (fromBalance < amount) { + revert InsufficientBalance(from, amount, fromBalance); + } + _balances[from] = fromBalance.sub(amount); + _balances[to] = _balances[to].add(amount); + emit Transfer(from, to, amount); + } + + function _approve(address owner, address spender, uint256 amount) internal { + if (owner == address(0) || spender == address(0)) revert ZeroAddress(); + _allowances[owner][spender] = amount; + emit Approval(owner, spender, amount); + } + + function _mint(address account, uint256 amount) internal { + if (account == address(0)) revert ZeroAddress(); + _totalSupply = _totalSupply.add(amount); + _balances[account] = _balances[account].add(amount); + emit Transfer(address(0), account, amount); + } +} diff --git a/test/resources/repos/solidity/test_repo/contracts/interfaces/IERC20.sol b/test/resources/repos/solidity/test_repo/contracts/interfaces/IERC20.sol new file mode 100644 index 0000000..48863b0 --- /dev/null +++ b/test/resources/repos/solidity/test_repo/contracts/interfaces/IERC20.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title IERC20 +/// @notice Minimal ERC-20 interface used by the test token. +interface IERC20 { + /// @notice Emitted when tokens are transferred between accounts. + event Transfer(address indexed from, address indexed to, uint256 value); + + /// @notice Emitted when an allowance is set via `approve`. + event Approval(address indexed owner, address indexed spender, uint256 value); + + /// @notice Returns the total token supply. + function totalSupply() external view returns (uint256); + + /// @notice Returns the token balance of `account`. + function balanceOf(address account) external view returns (uint256); + + /// @notice Transfers `amount` tokens to `to`. + function transfer(address to, uint256 amount) external returns (bool); + + /// @notice Returns the remaining allowance that `spender` has over `owner`'s tokens. + function allowance(address owner, address spender) external view returns (uint256); + + /// @notice Sets `amount` as the allowance of `spender` over the caller's tokens. + function approve(address spender, uint256 amount) external returns (bool); + + /// @notice Moves `amount` tokens from `from` to `to` using the allowance mechanism. + function transferFrom(address from, address to, uint256 amount) external returns (bool); +} diff --git a/test/resources/repos/solidity/test_repo/contracts/lib/SafeMath.sol b/test/resources/repos/solidity/test_repo/contracts/lib/SafeMath.sol new file mode 100644 index 0000000..e8e9de1 --- /dev/null +++ b/test/resources/repos/solidity/test_repo/contracts/lib/SafeMath.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title SafeMath +/// @notice Arithmetic helpers with overflow checks (illustrative — 0.8+ reverts natively). +library SafeMath { + /// @notice Returns the sum of `a` and `b`, reverting on overflow. + function add(uint256 a, uint256 b) internal pure returns (uint256) { + return a + b; + } + + /// @notice Returns `a` minus `b`, reverting on underflow. + function sub(uint256 a, uint256 b) internal pure returns (uint256) { + require(b <= a, "SafeMath: subtraction underflow"); + return a - b; + } + + /// @notice Returns the product of `a` and `b`, reverting on overflow. + function mul(uint256 a, uint256 b) internal pure returns (uint256) { + if (a == 0) return 0; + return a * b; + } + + /// @notice Returns the integer division of `a` by `b`, reverting on division by zero. + function div(uint256 a, uint256 b) internal pure returns (uint256) { + require(b > 0, "SafeMath: division by zero"); + return a / b; + } +} diff --git a/test/resources/repos/solidity/test_repo/foundry.toml b/test/resources/repos/solidity/test_repo/foundry.toml new file mode 100644 index 0000000..dc26202 --- /dev/null +++ b/test/resources/repos/solidity/test_repo/foundry.toml @@ -0,0 +1,5 @@ +[profile.default] +src = "contracts" +out = "out" +libs = [] +solc = "0.8.20" diff --git a/test/resources/repos/svelte/test_repo/.gitignore b/test/resources/repos/svelte/test_repo/.gitignore new file mode 100644 index 0000000..1e7ac1f --- /dev/null +++ b/test/resources/repos/svelte/test_repo/.gitignore @@ -0,0 +1,27 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +# Lock files +pnpm-lock.yaml +yarn.lock diff --git a/test/resources/repos/svelte/test_repo/package-lock.json b/test/resources/repos/svelte/test_repo/package-lock.json new file mode 100644 index 0000000..5fa3a17 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/package-lock.json @@ -0,0 +1,1864 @@ +{ + "name": "test-repo", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "test-repo", + "version": "0.0.1", + "devDependencies": { + "@fontsource/fira-mono": "^5.2.7", + "@neoconfetti/svelte": "^2.2.2", + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^25.6.1", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "tailwindcss": "^4.2.2", + "typescript": "^6.0.2", + "vite": "^8.0.7" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@fontsource/fira-mono": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/fira-mono/-/fira-mono-5.2.7.tgz", + "integrity": "sha512-wYrAn6i3nH6luqQBZxtWUpl4UTUvs9AEbEeZxksPMwIqyjRRaxHTNW3c2VfM50gabS2IS7pT8lVWS2USB4ukYA==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@neoconfetti/svelte": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@neoconfetti/svelte/-/svelte-2.2.2.tgz", + "integrity": "sha512-E7xCFVEEm5Ctnj2udTJy1b9oaTvjz1zi1mYdEtE8rB5BVwq6kHisosDS+zdWN5PMfEMjtbsOV9Cl6tsNSAD1sA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", + "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.69.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz", + "integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", + "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", + "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz", + "integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", + "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.1.tgz", + "integrity": "sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", + "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.1.tgz", + "integrity": "sha512-FGUOmAqxXdN/H9Zm8slrqO7SLtFisXRB7rfOsHNJ3MLTD2po/+Stg8XyErkpumPHbuUiYTcqrEIzxpVWKTLqtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.0", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/test/resources/repos/svelte/test_repo/package.json b/test/resources/repos/svelte/test_repo/package.json new file mode 100644 index 0000000..b0dde71 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/package.json @@ -0,0 +1,28 @@ +{ + "name": "test-repo", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@fontsource/fira-mono": "^5.2.7", + "@neoconfetti/svelte": "^2.2.2", + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^25.6.1", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "tailwindcss": "^4.2.2", + "typescript": "^6.0.2", + "vite": "^8.0.7" + } +} diff --git a/test/resources/repos/svelte/test_repo/src/app.html b/test/resources/repos/svelte/test_repo/src/app.html new file mode 100644 index 0000000..b6a3073 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
    %sveltekit.body%
    + + diff --git a/test/resources/repos/svelte/test_repo/src/lib/components/Counter.svelte b/test/resources/repos/svelte/test_repo/src/lib/components/Counter.svelte new file mode 100644 index 0000000..3f872d9 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/lib/components/Counter.svelte @@ -0,0 +1,106 @@ + + + + +
    + + +
    +
    + + {Math.floor(count.current)} +
    +
    + + +
    + + diff --git a/test/resources/repos/svelte/test_repo/src/lib/components/Header.svelte b/test/resources/repos/svelte/test_repo/src/lib/components/Header.svelte new file mode 100644 index 0000000..a9c6d1d --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/lib/components/Header.svelte @@ -0,0 +1,121 @@ + + +
    +
    +
    + + + +
    +
    +
    + + diff --git a/test/resources/repos/svelte/test_repo/src/lib/components/Words.svelte b/test/resources/repos/svelte/test_repo/src/lib/components/Words.svelte new file mode 100644 index 0000000..5229cbf --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/lib/components/Words.svelte @@ -0,0 +1,21 @@ + \ No newline at end of file diff --git a/test/resources/repos/svelte/test_repo/src/lib/diagnostics_sample.svelte b/test/resources/repos/svelte/test_repo/src/lib/diagnostics_sample.svelte new file mode 100644 index 0000000..1d4cb9d --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/lib/diagnostics_sample.svelte @@ -0,0 +1,5 @@ + + +

    {count}

    diff --git a/test/resources/repos/svelte/test_repo/src/lib/diagnostics_sample.ts b/test/resources/repos/svelte/test_repo/src/lib/diagnostics_sample.ts new file mode 100644 index 0000000..80c2656 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/lib/diagnostics_sample.ts @@ -0,0 +1,8 @@ +export function brokenFactory(): string { + return missingGreeting; +} + +export function brokenConsumer(): void { + const value = brokenFactory(); + console.log(missingConsumerValue); +} diff --git a/test/resources/repos/svelte/test_repo/src/lib/game.ts b/test/resources/repos/svelte/test_repo/src/lib/game.ts new file mode 100644 index 0000000..6e17d54 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/lib/game.ts @@ -0,0 +1,64 @@ +import { allowed } from "../routes/(sverdle)/words.server.ts"; +import { words } from "$lib/components/Words.svelte"; + +export const GAME_VERSION = "1.0"; + +export class Game { + index: number; + guesses: string[]; + answers: string[]; + answer: string; + + constructor(serialized: string | undefined = undefined) { + if (serialized) { + const [index, guesses, answers] = serialized.split("-"); + + this.index = +index; + this.guesses = guesses ? guesses.split(" ") : []; + this.answers = answers ? answers.split(" ") : []; + } else { + this.index = Math.floor(Math.random() * words.length); + this.guesses = ["", "", "", "", "", ""]; + this.answers = []; + } + + this.answer = words[this.index]; + } + + enter(letters: string[]) { + const word = letters.join(""); + const valid = allowed.has(word); + + if (!valid) return false; + + this.guesses[this.answers.length] = word; + + const available = Array.from(this.answer); + const answer = Array(5).fill("_"); + + for (let i = 0; i < 5; i += 1) { + if (letters[i] === available[i]) { + answer[i] = "x"; + available[i] = " "; + } + } + + for (let i = 0; i < 5; i += 1) { + if (answer[i] === "_") { + const index = available.indexOf(letters[i]); + if (index !== -1) { + answer[i] = "c"; + available[index] = " "; + } + } + } + + this.answers.push(answer.join("")); + + return true; + } + + toString() { + return `${this.index}-${this.guesses.join(" ")}-${this.answers.join(" ")}`; + } +} diff --git a/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/+page.server.ts b/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/+page.server.ts new file mode 100644 index 0000000..70da254 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/+page.server.ts @@ -0,0 +1,55 @@ +import { fail } from "@sveltejs/kit"; +import { Game, GAME_VERSION } from "$lib/game.js"; + +console.log(GAME_VERSION); +import { count } from "$lib/components/Counter.svelte"; + +export const load = ({ cookies }) => { + const game = new Game(cookies.get("sverdle")); + + return { + startingCount: count.current, + + guesses: game.guesses, + + answers: game.answers, + + answer: game.answers.length >= 6 ? game.answer : null, + }; +}; + +export const actions = { + update: async ({ request, cookies }) => { + const game = new Game(cookies.get("sverdle")); + + const data = await request.formData(); + const key = data.get("key"); + + const i = game.answers.length; + + if (key === "backspace") { + game.guesses[i] = game.guesses[i].slice(0, -1); + } else { + game.guesses[i] += key; + } + + cookies.set("sverdle", game.toString(), { path: "/" }); + }, + + enter: async ({ request, cookies }) => { + const game = new Game(cookies.get("sverdle")); + + const data = await request.formData(); + const guess = data.getAll("guess") as string[]; + + if (!game.enter(guess)) { + return fail(400, { badGuess: true }); + } + + cookies.set("sverdle", game.toString(), { path: "/" }); + }, + + restart: async ({ cookies }) => { + cookies.delete("sverdle", { path: "/" }); + }, +}; diff --git a/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/+page.svelte b/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/+page.svelte new file mode 100644 index 0000000..b237ca3 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/+page.svelte @@ -0,0 +1,393 @@ + + + + + + Sverdle + + + +

    Sverdle

    + +
    { + // prevent default callback from resetting the form + return ({ result, update }) => { + shake = result.type === 'failure'; + update({ reset: false }); + }; + }} +> + How to play + +
    (shake = false)}> + {#each Array.from(Array(6).keys()) as row (row)} + {@const current = row === i} +

    Row {row + 1}

    +
    + {#each Array.from(Array(5).keys()) as column (column)} + {@const guess = current ? currentGuess : data.guesses[row]} + {@const answer = data.answers[row]?.[column]} + {@const value = guess?.[column] ?? ''} + {@const selected = current && column === guess.length} + {@const exact = answer === 'x'} + {@const close = answer === 'c'} + {@const missing = answer === '_'} +
    + {value} + + {#if exact} + (correct) + {:else if close} + (present) + {:else if missing} + (absent) + {:else} + empty + {/if} + + +
    + {/each} +
    + {/each} +
    + +
    + {#if won || data.answers.length >= 6} + {#if !won && data.answer} +

    the answer was "{data.answer}"

    + {/if} + + {:else} +
    + + + + + {#each ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'] as row (row)} +
    + {#each row as letter, index (index)} + + {/each} +
    + {/each} +
    + {/if} +
    +
    + +{#if won} +
    +{/if} + + diff --git a/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/how-to-play/+page.svelte b/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/how-to-play/+page.svelte new file mode 100644 index 0000000..e8e2cec --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/how-to-play/+page.svelte @@ -0,0 +1,95 @@ + + How to play Sverdle + + + +
    +

    How to play Sverdle

    + +

    + Sverdle is a clone of Wordle, the + word guessing game. To play, enter a five-letter English word. For example: +

    + +
    + r + i + t + z + y +
    + +

    + The y is in the right place. r and + t + are the right letters, but in the wrong place. The other letters are wrong, and can be discarded. + Let's make another guess: +

    + +
    + p + a + r + t + y +
    + +

    This time we guessed right! You have six guesses to get the word.

    + +

    + Unlike the original Wordle, Sverdle runs on the server instead of in the browser, making it + impossible to cheat. It uses <form> and cookies to submit data, meaning you can + even play with JavaScript disabled! +

    +
    + + diff --git a/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/how-to-play/+page.ts b/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/how-to-play/+page.ts new file mode 100644 index 0000000..6838d9f --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/how-to-play/+page.ts @@ -0,0 +1,8 @@ +import { dev } from "$app/environment"; +import { GAME_VERSION } from "$lib/game"; + +console.log(GAME_VERSION); + +export const csr = dev; + +export const prerender = true; diff --git a/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/words.server.ts b/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/words.server.ts new file mode 100644 index 0000000..6e4996a --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/routes/(sverdle)/words.server.ts @@ -0,0 +1,10 @@ +import { words } from "$lib/components/Words.svelte"; + +export const allowed = new Set([ + ...words, + "aahed", + "aalii", + "aargh", + "aarti", + "abaca", +]); diff --git a/test/resources/repos/svelte/test_repo/src/routes/+layout.svelte b/test/resources/repos/svelte/test_repo/src/routes/+layout.svelte new file mode 100644 index 0000000..1c30374 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/routes/+layout.svelte @@ -0,0 +1,56 @@ + + +
    +
    +
    {@render children()}
    + + +
    + + diff --git a/test/resources/repos/svelte/test_repo/src/routes/layout.css b/test/resources/repos/svelte/test_repo/src/routes/layout.css new file mode 100644 index 0000000..a0907fe --- /dev/null +++ b/test/resources/repos/svelte/test_repo/src/routes/layout.css @@ -0,0 +1,104 @@ +@import 'tailwindcss'; +@import '@fontsource/fira-mono'; + +:root { + --font-body: Arial, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, + 'Open Sans', 'Helvetica Neue', sans-serif; + --font-mono: 'Fira Mono', monospace; + --color-bg-0: rgb(202, 216, 228); + --color-bg-1: hsl(209, 36%, 86%); + --color-bg-2: hsl(224, 44%, 95%); + --color-theme-1: #ff3e00; + --color-theme-2: #4075a6; + --color-text: rgba(0, 0, 0, 0.7); + --column-width: 42rem; + --column-margin-top: 4rem; + font-family: var(--font-body); + color: var(--color-text); +} + +body { + min-height: 100vh; + margin: 0; + background-attachment: fixed; + background-color: var(--color-bg-1); + background-size: 100vw 100vh; + background-image: radial-gradient(50% 50% at 50% 50%, rgba(255, 255, 255, 0.75) 0%, rgba(255, 255, 255, 0) 100%), + linear-gradient(180deg, var(--color-bg-0) 0%, var(--color-bg-1) 15%, var(--color-bg-2) 50%); +} + +h1, +h2, +p { + font-weight: 400; +} + +p { + line-height: 1.5; +} + +a { + color: var(--color-theme-1); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +h1 { + font-size: 2rem; + text-align: center; +} + +h2 { + font-size: 1rem; +} + +pre { + font-size: 16px; + font-family: var(--font-mono); + background-color: rgba(255, 255, 255, 0.45); + border-radius: 3px; + box-shadow: 2px 2px 6px rgb(255 255 255 / 25%); + padding: 0.5em; + overflow-x: auto; + color: var(--color-text); +} + +.text-column { + display: flex; + max-width: 48rem; + flex: 0.6; + flex-direction: column; + justify-content: center; + margin: 0 auto; +} + +input, +button { + font-size: inherit; + font-family: inherit; +} + +button:focus:not(:focus-visible) { + outline: none; +} + +@media (min-width: 720px) { + h1 { + font-size: 2.4rem; + } +} + +.visually-hidden { + border: 0; + clip: rect(0 0 0 0); + height: auto; + margin: 0; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; + white-space: nowrap; +} diff --git a/test/resources/repos/svelte/test_repo/svelte.config.js b/test/resources/repos/svelte/test_repo/svelte.config.js new file mode 100644 index 0000000..10c4eeb --- /dev/null +++ b/test/resources/repos/svelte/test_repo/svelte.config.js @@ -0,0 +1,13 @@ +import adapter from '@sveltejs/adapter-auto'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. + // See https://svelte.dev/docs/kit/adapters for more information about adapters. + adapter: adapter() + } +}; + +export default config; diff --git a/test/resources/repos/svelte/test_repo/tsconfig.json b/test/resources/repos/svelte/test_repo/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/test/resources/repos/svelte/test_repo/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/test/resources/repos/svelte/test_repo/vite.config.ts b/test/resources/repos/svelte/test_repo/vite.config.ts new file mode 100644 index 0000000..56f40c7 --- /dev/null +++ b/test/resources/repos/svelte/test_repo/vite.config.ts @@ -0,0 +1,5 @@ +import tailwindcss from '@tailwindcss/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ plugins: [tailwindcss(), sveltekit()] }); diff --git a/test/resources/repos/swift/test_repo/Package.swift b/test/resources/repos/swift/test_repo/Package.swift new file mode 100644 index 0000000..5e9b7cf --- /dev/null +++ b/test/resources/repos/swift/test_repo/Package.swift @@ -0,0 +1,17 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "test_repo", + products: [ + .library( + name: "test_repo", + targets: ["test_repo"]), + ], + targets: [ + .target( + name: "test_repo", + dependencies: [], + path: "src"), + ] +) diff --git a/test/resources/repos/swift/test_repo/src/diagnostics_sample.swift b/test/resources/repos/swift/test_repo/src/diagnostics_sample.swift new file mode 100644 index 0000000..b70b2c6 --- /dev/null +++ b/test/resources/repos/swift/test_repo/src/diagnostics_sample.swift @@ -0,0 +1,3 @@ +func brokenFactory() -> String { + return +} diff --git a/test/resources/repos/swift/test_repo/src/main.swift b/test/resources/repos/swift/test_repo/src/main.swift new file mode 100644 index 0000000..8463077 --- /dev/null +++ b/test/resources/repos/swift/test_repo/src/main.swift @@ -0,0 +1,62 @@ +import Foundation + +// Main entry point +func main() { + let calculator = Calculator() + let result = calculator.add(5, 3) + print("Result: \(result)") + + let user = User(name: "Alice", age: 30) + user.greet() + + let area = Utils.calculateArea(radius: 5.0) + print("Circle area: \(area)") +} + +class Calculator { + func add(_ a: Int, _ b: Int) -> Int { + return a + b + } + + func multiply(_ a: Int, _ b: Int) -> Int { + return a * b + } +} + +struct User { + let name: String + let age: Int + + func greet() { + print("Hello, my name is \(name) and I am \(age) years old.") + } + + func isAdult() -> Bool { + return age >= 18 + } +} + +enum Status { + case active + case inactive + case pending +} + +protocol Drawable { + func draw() +} + +class Circle: Drawable { + let radius: Double + + init(radius: Double) { + self.radius = radius + } + + func draw() { + print("Drawing a circle with radius \(radius)") + } +} + +// Call main +main() \ No newline at end of file diff --git a/test/resources/repos/swift/test_repo/src/utils.swift b/test/resources/repos/swift/test_repo/src/utils.swift new file mode 100644 index 0000000..cf1422c --- /dev/null +++ b/test/resources/repos/swift/test_repo/src/utils.swift @@ -0,0 +1,20 @@ +import Foundation + +public struct Utils { + public static func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + return formatter.string(from: date) + } + + public static func calculateArea(radius: Double) -> Double { + return Double.pi * radius * radius + } +} + +public extension String { + func isValidEmail() -> Bool { + let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" + return NSPredicate(format: "SELF MATCHES %@", emailRegex).evaluate(with: self) + } +} \ No newline at end of file diff --git a/test/resources/repos/systemverilog/test_repo/alu.sv b/test/resources/repos/systemverilog/test_repo/alu.sv new file mode 100644 index 0000000..1e416b3 --- /dev/null +++ b/test/resources/repos/systemverilog/test_repo/alu.sv @@ -0,0 +1,39 @@ +// ALU module for testing SystemVerilog LSP +module alu #( + parameter DATA_WIDTH = 32 +) ( + input logic [DATA_WIDTH-1:0] a, + input logic [DATA_WIDTH-1:0] b, + input logic [2:0] op, + output logic [DATA_WIDTH-1:0] result, + output logic zero +); + + typedef enum logic [2:0] { + ALU_ADD = 3'b000, + ALU_SUB = 3'b001, + ALU_AND = 3'b010, + ALU_OR = 3'b011, + ALU_XOR = 3'b100, + ALU_SLL = 3'b101, + ALU_SRL = 3'b110, + ALU_SRA = 3'b111 + } alu_op_t; + + always_comb begin + case (op) + ALU_ADD: result = a + b; + ALU_SUB: result = a - b; + ALU_AND: result = a & b; + ALU_OR: result = a | b; + ALU_XOR: result = a ^ b; + ALU_SLL: result = a << b[4:0]; + ALU_SRL: result = a >> b[4:0]; + ALU_SRA: result = $signed(a) >>> b[4:0]; + default: result = '0; + endcase + end + + assign zero = (result == '0); + +endmodule diff --git a/test/resources/repos/systemverilog/test_repo/counter.sv b/test/resources/repos/systemverilog/test_repo/counter.sv new file mode 100644 index 0000000..2af8d1c --- /dev/null +++ b/test/resources/repos/systemverilog/test_repo/counter.sv @@ -0,0 +1,19 @@ +// Simple counter module for testing SystemVerilog LSP +module counter #( + parameter WIDTH = 8 +) ( + input logic clk, + input logic rst_n, + input logic enable, + output logic [WIDTH-1:0] count +); + + // Counter logic + always_ff @(posedge clk or negedge rst_n) begin + if (!rst_n) + count <= '0; + else if (enable) + count <= count + 1'b1; + end + +endmodule diff --git a/test/resources/repos/systemverilog/test_repo/diagnostics_sample.sv b/test/resources/repos/systemverilog/test_repo/diagnostics_sample.sv new file mode 100644 index 0000000..b536426 --- /dev/null +++ b/test/resources/repos/systemverilog/test_repo/diagnostics_sample.sv @@ -0,0 +1,5 @@ +module diagnostics_sample; + initial begin + if ( + end +endmodule diff --git a/test/resources/repos/systemverilog/test_repo/top.sv b/test/resources/repos/systemverilog/test_repo/top.sv new file mode 100644 index 0000000..5684ca0 --- /dev/null +++ b/test/resources/repos/systemverilog/test_repo/top.sv @@ -0,0 +1,33 @@ +// Top module that instantiates counter and alu for cross-file testing +`include "types.svh" + +module top ( + input logic clk, + input logic rst_n, + input logic enable, + input word_t a, + input word_t b, + input logic [2:0] op, + output byte_t count, + output word_t alu_result, + output logic alu_zero +); + + // Instantiate counter module + counter #(.WIDTH(8)) u_counter ( + .clk(clk), + .rst_n(rst_n), + .enable(enable), + .count(count) + ); + + // Instantiate ALU module + alu #(.DATA_WIDTH(32)) u_alu ( + .a(a), + .b(b), + .op(op), + .result(alu_result), + .zero(alu_zero) + ); + +endmodule diff --git a/test/resources/repos/systemverilog/test_repo/types.svh b/test/resources/repos/systemverilog/test_repo/types.svh new file mode 100644 index 0000000..29482a7 --- /dev/null +++ b/test/resources/repos/systemverilog/test_repo/types.svh @@ -0,0 +1,16 @@ +// Common types header for testing SystemVerilog LSP +`ifndef TYPES_SVH +`define TYPES_SVH + +typedef logic [7:0] byte_t; +typedef logic [15:0] halfword_t; +typedef logic [31:0] word_t; +typedef logic [63:0] doubleword_t; + +typedef struct packed { + logic valid; + logic [31:0] data; + logic [3:0] tag; +} tagged_data_t; + +`endif // TYPES_SVH diff --git a/test/resources/repos/terraform/test_repo/data.tf b/test/resources/repos/terraform/test_repo/data.tf new file mode 100644 index 0000000..0dd2d93 --- /dev/null +++ b/test/resources/repos/terraform/test_repo/data.tf @@ -0,0 +1,28 @@ +# Data sources for the Terraform configuration + +# Get the latest Ubuntu AMI +data "aws_ami" "ubuntu" { + most_recent = true + owners = ["099720109477"] # Canonical + + filter { + name = "name" + values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } +} + +# Get available availability zones +data "aws_availability_zones" "available" { + state = "available" +} + +# Get current AWS caller identity +data "aws_caller_identity" "current" {} + +# Get current AWS region +data "aws_region" "current" {} diff --git a/test/resources/repos/terraform/test_repo/diagnostics_sample.tf b/test/resources/repos/terraform/test_repo/diagnostics_sample.tf new file mode 100644 index 0000000..15de22c --- /dev/null +++ b/test/resources/repos/terraform/test_repo/diagnostics_sample.tf @@ -0,0 +1,4 @@ +resource "aws_instance" "broken" { + ami = + instance_type = "t2.micro" +} diff --git a/test/resources/repos/terraform/test_repo/main.tf b/test/resources/repos/terraform/test_repo/main.tf new file mode 100644 index 0000000..d186c20 --- /dev/null +++ b/test/resources/repos/terraform/test_repo/main.tf @@ -0,0 +1,126 @@ +# Main Terraform configuration +terraform { + required_version = ">= 1.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = var.aws_region +} + +# EC2 Instance +resource "aws_instance" "web_server" { + ami = data.aws_ami.ubuntu.id + instance_type = var.instance_type + + vpc_security_group_ids = [aws_security_group.web_sg.id] + subnet_id = aws_subnet.public.id + + user_data = <<-EOF + #!/bin/bash + apt-get update + apt-get install -y nginx + systemctl start nginx + systemctl enable nginx + EOF + + tags = { + Name = "${var.project_name}-web-server" + Environment = var.environment + Project = var.project_name + } +} + +# S3 Bucket +resource "aws_s3_bucket" "app_bucket" { + bucket = "${var.project_name}-${var.environment}-bucket" + + tags = { + Name = "${var.project_name}-bucket" + Environment = var.environment + Project = var.project_name + } +} + +resource "aws_s3_bucket_versioning" "app_bucket_versioning" { + bucket = aws_s3_bucket.app_bucket.id + versioning_configuration { + status = "Enabled" + } +} + +# VPC +resource "aws_vpc" "main" { + cidr_block = "10.0.0.0/16" + enable_dns_hostnames = true + enable_dns_support = true + + tags = { + Name = "${var.project_name}-vpc" + Environment = var.environment + Project = var.project_name + } +} + +# Internet Gateway +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.main.id + + tags = { + Name = "${var.project_name}-igw" + Environment = var.environment + Project = var.project_name + } +} + +# Public Subnet +resource "aws_subnet" "public" { + vpc_id = aws_vpc.main.id + cidr_block = "10.0.1.0/24" + availability_zone = data.aws_availability_zones.available.names[0] + map_public_ip_on_launch = true + + tags = { + Name = "${var.project_name}-public-subnet" + Environment = var.environment + Project = var.project_name + } +} + +# Security Group +resource "aws_security_group" "web_sg" { + name_prefix = "${var.project_name}-web-" + vpc_id = aws_vpc.main.id + + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.project_name}-web-sg" + Environment = var.environment + Project = var.project_name + } +} diff --git a/test/resources/repos/terraform/test_repo/outputs.tf b/test/resources/repos/terraform/test_repo/outputs.tf new file mode 100644 index 0000000..856ed6d --- /dev/null +++ b/test/resources/repos/terraform/test_repo/outputs.tf @@ -0,0 +1,46 @@ +# Output values for the Terraform configuration + +output "instance_id" { + description = "ID of the EC2 instance" + value = aws_instance.web_server.id +} + +output "instance_public_ip" { + description = "Public IP address of the EC2 instance" + value = aws_instance.web_server.public_ip +} + +output "instance_public_dns" { + description = "Public DNS name of the EC2 instance" + value = aws_instance.web_server.public_dns +} + +output "s3_bucket_name" { + description = "Name of the S3 bucket" + value = aws_s3_bucket.app_bucket.bucket +} + +output "s3_bucket_arn" { + description = "ARN of the S3 bucket" + value = aws_s3_bucket.app_bucket.arn +} + +output "vpc_id" { + description = "ID of the VPC" + value = aws_vpc.main.id +} + +output "subnet_id" { + description = "ID of the public subnet" + value = aws_subnet.public.id +} + +output "security_group_id" { + description = "ID of the security group" + value = aws_security_group.web_sg.id +} + +output "application_url" { + description = "URL to access the application" + value = "http://${aws_instance.web_server.public_dns}" +} diff --git a/test/resources/repos/terraform/test_repo/variables.tf b/test/resources/repos/terraform/test_repo/variables.tf new file mode 100644 index 0000000..92437bd --- /dev/null +++ b/test/resources/repos/terraform/test_repo/variables.tf @@ -0,0 +1,61 @@ +# Input variables for the Terraform configuration + +variable "aws_region" { + description = "AWS region for resources" + type = string + default = "us-west-2" +} + +variable "instance_type" { + description = "EC2 instance type" + type = string + default = "t3.micro" + + validation { + condition = contains([ + "t3.micro", "t3.small", "t3.medium", + "t2.micro", "t2.small", "t2.medium" + ], var.instance_type) + error_message = "Instance type must be a valid t2 or t3 instance type." + } +} + +variable "environment" { + description = "Environment name (dev, staging, prod)" + type = string + default = "dev" + + validation { + condition = contains(["dev", "staging", "prod"], var.environment) + error_message = "Environment must be dev, staging, or prod." + } +} + +variable "project_name" { + description = "Name of the project" + type = string + default = "terraform-test" + + validation { + condition = can(regex("^[a-z0-9-]+$", var.project_name)) + error_message = "Project name must contain only lowercase letters, numbers, and hyphens." + } +} + +variable "enable_monitoring" { + description = "Enable CloudWatch monitoring" + type = bool + default = false +} + +variable "allowed_cidr_blocks" { + description = "List of CIDR blocks allowed to access the application" + type = list(string) + default = ["0.0.0.0/0"] +} + +variable "tags" { + description = "Additional tags to apply to resources" + type = map(string) + default = {} +} diff --git a/test/resources/repos/toml/test_repo/Cargo.toml b/test/resources/repos/toml/test_repo/Cargo.toml new file mode 100644 index 0000000..a295cc0 --- /dev/null +++ b/test/resources/repos/toml/test_repo/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "test_project" +version = "0.1.0" +edition = "2021" +description = "A test TOML file for Serena TOML language support" +authors = ["Test Author "] +license = "MIT" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +tokio = { version = "1.0", features = ["full"] } + +[dev-dependencies] +proptest = "1.0" + +[features] +default = ["feature1"] +feature1 = [] +feature2 = ["feature1"] + +[profile.release] +lto = true +opt-level = 3 + +[[bin]] +name = "main" +path = "src/main.rs" + +[workspace] +members = ["crates/*"] diff --git a/test/resources/repos/toml/test_repo/config.toml b/test/resources/repos/toml/test_repo/config.toml new file mode 100644 index 0000000..912d2dc --- /dev/null +++ b/test/resources/repos/toml/test_repo/config.toml @@ -0,0 +1,66 @@ +# Configuration file with various TOML features for testing + +[server] +host = "localhost" +port = 8080 +debug = false +timeout = 30.5 + +# Inline table example +endpoint = { url = "https://api.example.com", version = "v1" } + +[server.ssl] +enabled = true +cert_path = "/etc/ssl/cert.pem" +key_path = "/etc/ssl/key.pem" + +[database] +connection_string = """ + postgresql://user:password@localhost:5432/mydb? + sslmode=require& + pool_size=10 +""" + +[database.pool] +min_connections = 5 +max_connections = 20 +idle_timeout = 300 + +[logging] +level = "info" +format = "json" +outputs = ["stdout", "file"] + +[logging.file] +path = "/var/log/app.log" +max_size = 10485760 # 10MB +max_backups = 5 +compress = true + +# Array of inline tables +[[endpoints]] +name = "users" +path = "/api/users" +methods = ["GET", "POST", "PUT", "DELETE"] +auth_required = true + +[[endpoints]] +name = "health" +path = "/health" +methods = ["GET"] +auth_required = false + +# Datetime values +[metadata] +created = 1979-05-27T07:32:00Z +updated = 2024-01-15T10:30:00-05:00 + +# Special characters in strings +[messages] +welcome = "Hello, World!" +multiline = ''' +This is a +multiline literal +string. +''' +with_escapes = "Line1\nLine2\tTabbed" diff --git a/test/resources/repos/toml/test_repo/diagnostics_sample.toml b/test/resources/repos/toml/test_repo/diagnostics_sample.toml new file mode 100644 index 0000000..ed4ae4c --- /dev/null +++ b/test/resources/repos/toml/test_repo/diagnostics_sample.toml @@ -0,0 +1,2 @@ +[diagnostics] +value = diff --git a/test/resources/repos/toml/test_repo/pyproject.toml b/test/resources/repos/toml/test_repo/pyproject.toml new file mode 100644 index 0000000..37887c2 --- /dev/null +++ b/test/resources/repos/toml/test_repo/pyproject.toml @@ -0,0 +1,45 @@ +[project] +name = "test-project" +version = "0.1.0" +description = "A test Python project for TOML support" +readme = "README.md" +requires-python = ">=3.11" +license = {text = "MIT"} +authors = [ + {name = "Test Author", email = "test@example.com"} +] +dependencies = [ + "pydantic>=2.0", + "httpx>=0.24", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "ruff>=0.1", + "mypy>=1.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.ruff] +line-length = 88 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "B", "S"] + +[tool.ruff.lint.isort] +known-first-party = ["test_project"] + +[tool.mypy] +python_version = "3.11" +strict = true +warn_unreachable = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +addopts = "-v --cov=src" diff --git a/test/resources/repos/typescript/cross_package_a/shared_utils.ts b/test/resources/repos/typescript/cross_package_a/shared_utils.ts new file mode 100644 index 0000000..a775afb --- /dev/null +++ b/test/resources/repos/typescript/cross_package_a/shared_utils.ts @@ -0,0 +1,14 @@ +export function sharedUtilityFunction(input: string): string { + return `processed: ${input}`; +} + +export class SharedClass { + name: string; + constructor(name: string) { + this.name = name; + } + + greet(): string { + return `Hello, ${this.name}`; + } +} diff --git a/test/resources/repos/typescript/cross_package_a/tsconfig.json b/test/resources/repos/typescript/cross_package_a/tsconfig.json new file mode 100644 index 0000000..7341858 --- /dev/null +++ b/test/resources/repos/typescript/cross_package_a/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2017", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "declaration": true, + "outDir": "./dist", + "rootDir": "." + }, + "include": ["*.ts"] +} diff --git a/test/resources/repos/typescript/cross_package_b/consumer.ts b/test/resources/repos/typescript/cross_package_b/consumer.ts new file mode 100644 index 0000000..491bb27 --- /dev/null +++ b/test/resources/repos/typescript/cross_package_b/consumer.ts @@ -0,0 +1,10 @@ +import { sharedUtilityFunction, SharedClass } from "../cross_package_a/shared_utils"; + +export function consumeSharedUtil(): string { + return sharedUtilityFunction("test data"); +} + +export function consumeSharedClass(): string { + const instance = new SharedClass("World"); + return instance.greet(); +} diff --git a/test/resources/repos/typescript/cross_package_b/tsconfig.json b/test/resources/repos/typescript/cross_package_b/tsconfig.json new file mode 100644 index 0000000..44c8ed2 --- /dev/null +++ b/test/resources/repos/typescript/cross_package_b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2017", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "rootDir": ".", + "paths": { + "../cross_package_a/*": ["../cross_package_a/*"] + } + }, + "include": ["*.ts"] +} diff --git a/test/resources/repos/typescript/test_repo/diagnostics_sample.ts b/test/resources/repos/typescript/test_repo/diagnostics_sample.ts new file mode 100644 index 0000000..566643e --- /dev/null +++ b/test/resources/repos/typescript/test_repo/diagnostics_sample.ts @@ -0,0 +1,9 @@ +export function brokenFactory(): string { + return missingGreeting; +} + +export function brokenConsumer(): void { + const value = brokenFactory(); + console.log(value); + console.log(missingConsumerValue); +} diff --git a/test/resources/repos/typescript/test_repo/formatters.ts b/test/resources/repos/typescript/test_repo/formatters.ts new file mode 100644 index 0000000..fe233f9 --- /dev/null +++ b/test/resources/repos/typescript/test_repo/formatters.ts @@ -0,0 +1,9 @@ +export interface Greeter { + formatGreeting(name: string): string; +} + +export class ConsoleGreeter implements Greeter { + formatGreeting(name: string): string { + return `Hello, ${name}!`; + } +} diff --git a/test/resources/repos/typescript/test_repo/index.ts b/test/resources/repos/typescript/test_repo/index.ts new file mode 100644 index 0000000..417e586 --- /dev/null +++ b/test/resources/repos/typescript/test_repo/index.ts @@ -0,0 +1,25 @@ +import { ConsoleGreeter, Greeter } from "./formatters"; + +export class DemoClass { + value: number; + constructor(value: number) { + this.value = value; + } + printValue() { + console.log(this.value); + } +} + +export function helperFunction() { + const demo = new DemoClass(42); + demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); +} + +helperFunction(); + +export function unusedStandaloneFunction(): string { + return "This function is not referenced anywhere"; +} diff --git a/test/resources/repos/typescript/test_repo/jsx_component.tsx b/test/resources/repos/typescript/test_repo/jsx_component.tsx new file mode 100644 index 0000000..c8dea77 --- /dev/null +++ b/test/resources/repos/typescript/test_repo/jsx_component.tsx @@ -0,0 +1,44 @@ +// Regression fixture: mirrors the real-world pattern that caused +// languageId=typescript (instead of typescriptreact) to truncate symbol +// ranges at the first multi-line JSX expression. + +import * as React from "react" + +type SectionProps = { title: string; emphasised?: boolean } + +function Section({ title, emphasised }: SectionProps) { + return emphasised ? ( +

    {title}

    + ) : ( +

    {title}

    + ) +} + +export function JsxComponent({ heading, items }: { heading: string; items: string[] }) { + const renderHeader = (title: string) => + title.length > 10 ? ( +
    + ) : ( +
    + ) + + // The truncation bug used to cut JsxComponent's range right around here, + // hiding everything below from find_symbol. Keep this comment so the + // regression test can assert the function body extends past it. + const renderItem = (item: string, idx: number) => ( +
  • + {item} +
  • + ) + + return ( +
    + {renderHeader(heading)} +
      {items.map(renderItem)}
    +
    + ) +} + +export function trailingHelper(): string { + return "this symbol is invisible to find_symbol when JsxComponent's range is truncated" +} diff --git a/test/resources/repos/typescript/test_repo/tsconfig.json b/test/resources/repos/typescript/test_repo/tsconfig.json new file mode 100644 index 0000000..7267a68 --- /dev/null +++ b/test/resources/repos/typescript/test_repo/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2017", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "jsx": "react" + }, + "include": ["**/*.ts", "**/*.tsx"] +} diff --git a/test/resources/repos/typescript/test_repo/use_helper.ts b/test/resources/repos/typescript/test_repo/use_helper.ts new file mode 100644 index 0000000..e9147d2 --- /dev/null +++ b/test/resources/repos/typescript/test_repo/use_helper.ts @@ -0,0 +1,8 @@ +import {helperFunction} from "./index"; + + +export function useHelper() { + helperFunction(); +} + +useHelper(); diff --git a/test/resources/repos/typescript/test_repo/ws_manager.js b/test/resources/repos/typescript/test_repo/ws_manager.js new file mode 100644 index 0000000..6017321 --- /dev/null +++ b/test/resources/repos/typescript/test_repo/ws_manager.js @@ -0,0 +1,74 @@ +/** + * Dummy WebSocket manager class for testing ambiguous regex matching. + */ +class WebSocketManager { + constructor() { + console.log("WebSocketManager initializing\nStatus OK"); + this.ws = null; + this.statusElement = document.getElementById("status"); + } + + /** + * Connects to the WebSocket server. + */ + connectToServer() { + if (this.ws?.readyState === WebSocket.OPEN) { + this.updateConnectionStatus("Already connected", true); + return; + } + + try { + this.ws = new WebSocket("ws://localhost:4402"); + this.updateConnectionStatus("Connecting...", false); + + this.ws.onopen = () => { + console.log("Connected to server"); + this.updateConnectionStatus("Connected", true); + }; + + this.ws.onmessage = (event) => { + console.log("Received message:", event.data); + try { + const data = JSON.parse(event.data); + this.handleMessage(data); + } catch (error) { + console.error("Failed to parse message:", error); + this.updateConnectionStatus("Parse error", false); + } + }; + + this.ws.onclose = (event) => { + console.log("Connection closed"); + const message = event.reason || undefined; + this.updateConnectionStatus("Disconnected", false, message); + this.ws = null; + }; + + this.ws.onerror = (error) => { + console.error("WebSocket error:", error); + this.updateConnectionStatus("Connection error", false); + }; + } catch (error) { + console.error("Failed to connect to server:", error); + this.updateConnectionStatus("Connection failed", false); + } + } + + /** + * Updates the connection status display. + */ + updateConnectionStatus(status, isConnected, message) { + if (this.statusElement) { + const text = message ? `${status}: ${message}` : status; + this.statusElement.textContent = text; + this.statusElement.style.color = isConnected ? "green" : "red"; + } + } + + /** + * Handles incoming messages. + */ + handleMessage(data) { + console.log("Handling:", data); + } +} \ No newline at end of file diff --git a/test/resources/repos/vue/test_repo/.gitignore b/test/resources/repos/vue/test_repo/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/test/resources/repos/vue/test_repo/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/test/resources/repos/vue/test_repo/index.html b/test/resources/repos/vue/test_repo/index.html new file mode 100644 index 0000000..75e5223 --- /dev/null +++ b/test/resources/repos/vue/test_repo/index.html @@ -0,0 +1,13 @@ + + + + + + + Vue Calculator + + +
    + + + diff --git a/test/resources/repos/vue/test_repo/package.json b/test/resources/repos/vue/test_repo/package.json new file mode 100644 index 0000000..846a5b1 --- /dev/null +++ b/test/resources/repos/vue/test_repo/package.json @@ -0,0 +1,14 @@ +{ + "name": "vue-calculator-test-fixture", + "version": "1.0.0", + "type": "module", + "description": "Vue 3 + Pinia + TypeScript test fixtures for Serena LSP testing", + "dependencies": { + "vue": "^3.4.0", + "pinia": "^2.1.0" + }, + "devDependencies": { + "@vue/language-server": "^2.0.0", + "typescript": "~5.5.4" + } +} diff --git a/test/resources/repos/vue/test_repo/src/App.vue b/test/resources/repos/vue/test_repo/src/App.vue new file mode 100644 index 0000000..a5ece8a --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/App.vue @@ -0,0 +1,230 @@ + diff --git a/test/resources/repos/vue/test_repo/src/components/CalculatorButton.vue b/test/resources/repos/vue/test_repo/src/components/CalculatorButton.vue new file mode 100644 index 0000000..68b1763 --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/components/CalculatorButton.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/test/resources/repos/vue/test_repo/src/components/CalculatorDisplay.vue b/test/resources/repos/vue/test_repo/src/components/CalculatorDisplay.vue new file mode 100644 index 0000000..622709b --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/components/CalculatorDisplay.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/test/resources/repos/vue/test_repo/src/components/CalculatorInput.vue b/test/resources/repos/vue/test_repo/src/components/CalculatorInput.vue new file mode 100644 index 0000000..2d7bc86 --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/components/CalculatorInput.vue @@ -0,0 +1,300 @@ + + + + + diff --git a/test/resources/repos/vue/test_repo/src/composables/useFormatter.ts b/test/resources/repos/vue/test_repo/src/composables/useFormatter.ts new file mode 100644 index 0000000..9c64106 --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/composables/useFormatter.ts @@ -0,0 +1,126 @@ +import { ref, computed } from 'vue' +import type { Ref, ComputedRef } from 'vue' +import type { FormatOptions } from '@/types' + +/** + * Composable for formatting numbers with various options. + * Demonstrates: composable pattern, refs, computed, type imports + */ +export function useFormatter(initialPrecision: number = 2) { + // State + const precision = ref(initialPrecision) + const useGrouping = ref(true) + const locale = ref('en-US') + + // Computed properties + const formatOptions = computed((): FormatOptions => ({ + maxDecimals: precision.value, + useGrouping: useGrouping.value + })) + + // Methods + const formatNumber = (value: number): string => { + return value.toLocaleString(locale.value, { + minimumFractionDigits: precision.value, + maximumFractionDigits: precision.value, + useGrouping: useGrouping.value + }) + } + + const formatCurrency = (value: number, currency: string = 'USD'): string => { + return value.toLocaleString(locale.value, { + style: 'currency', + currency, + minimumFractionDigits: precision.value, + maximumFractionDigits: precision.value + }) + } + + const formatPercentage = (value: number): string => { + return `${(value * 100).toFixed(precision.value)}%` + } + + const setPrecision = (newPrecision: number): void => { + if (newPrecision >= 0 && newPrecision <= 10) { + precision.value = newPrecision + } + } + + const toggleGrouping = (): void => { + useGrouping.value = !useGrouping.value + } + + const setLocale = (newLocale: string): void => { + locale.value = newLocale + } + + // Return composable API + return { + // State (readonly) + precision: computed(() => precision.value), + useGrouping: computed(() => useGrouping.value), + locale: computed(() => locale.value), + formatOptions, + + // Methods + formatNumber, + formatCurrency, + formatPercentage, + setPrecision, + toggleGrouping, + setLocale + } +} + +/** + * Composable for time formatting. + * Demonstrates: simpler composable, pure functions + */ +export function useTimeFormatter() { + const formatTime = (date: Date): string => { + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + } + + const formatDate = (date: Date): string => { + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }) + } + + const formatDateTime = (date: Date): string => { + return `${formatDate(date)} ${formatTime(date)}` + } + + const getRelativeTime = (date: Date): string => { + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffSecs = Math.floor(diffMs / 1000) + const diffMins = Math.floor(diffSecs / 60) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffSecs < 60) return 'just now' + if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago` + if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago` + return `${diffDays} day${diffDays > 1 ? 's' : ''} ago` + } + + return { + formatTime, + formatDate, + formatDateTime, + getRelativeTime + } +} + +/** + * Type definitions for return types + */ +export type UseFormatterReturn = ReturnType +export type UseTimeFormatterReturn = ReturnType diff --git a/test/resources/repos/vue/test_repo/src/composables/useTheme.ts b/test/resources/repos/vue/test_repo/src/composables/useTheme.ts new file mode 100644 index 0000000..706f1fe --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/composables/useTheme.ts @@ -0,0 +1,113 @@ +import { ref, computed, watch, inject, provide, type InjectionKey, type Ref } from 'vue' + +/** + * Theme configuration type + */ +export interface ThemeConfig { + isDark: boolean + primaryColor: string + fontSize: number +} + +/** + * Injection key for theme - demonstrates provide/inject pattern + */ +export const ThemeKey: InjectionKey> = Symbol('theme') + +/** + * Composable for theme management with watchers. + * Demonstrates: watch, provide/inject, localStorage interaction + */ +export function useThemeProvider() { + // Initialize theme from localStorage or defaults + const loadThemeFromStorage = (): ThemeConfig => { + const stored = localStorage.getItem('app-theme') + if (stored) { + try { + return JSON.parse(stored) + } catch { + // Fall through to defaults + } + } + return { + isDark: false, + primaryColor: '#667eea', + fontSize: 16 + } + } + + const theme = ref(loadThemeFromStorage()) + + // Computed properties + const isDarkMode = computed(() => theme.value.isDark) + const themeClass = computed(() => theme.value.isDark ? 'dark-theme' : 'light-theme') + + // Watch for theme changes and persist to localStorage + watch( + theme, + (newTheme) => { + localStorage.setItem('app-theme', JSON.stringify(newTheme)) + document.documentElement.className = newTheme.isDark ? 'dark' : 'light' + }, + { deep: true } + ) + + // Methods + const toggleDarkMode = (): void => { + theme.value.isDark = !theme.value.isDark + } + + const setPrimaryColor = (color: string): void => { + theme.value.primaryColor = color + } + + const setFontSize = (size: number): void => { + if (size >= 12 && size <= 24) { + theme.value.fontSize = size + } + } + + const resetTheme = (): void => { + theme.value = { + isDark: false, + primaryColor: '#667eea', + fontSize: 16 + } + } + + // Provide theme to child components + provide(ThemeKey, theme) + + return { + theme, + isDarkMode, + themeClass, + toggleDarkMode, + setPrimaryColor, + setFontSize, + resetTheme + } +} + +/** + * Composable for consuming theme in child components. + * Demonstrates: inject pattern + */ +export function useTheme() { + const theme = inject(ThemeKey) + + if (!theme) { + throw new Error('useTheme must be used within a component that provides ThemeKey') + } + + const isDark = computed(() => theme.value.isDark) + const primaryColor = computed(() => theme.value.primaryColor) + const fontSize = computed(() => theme.value.fontSize) + + return { + theme, + isDark, + primaryColor, + fontSize + } +} diff --git a/test/resources/repos/vue/test_repo/src/main.ts b/test/resources/repos/vue/test_repo/src/main.ts new file mode 100644 index 0000000..764d958 --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/main.ts @@ -0,0 +1,9 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' + +const app = createApp(App) +const pinia = createPinia() + +app.use(pinia) +app.mount('#app') diff --git a/test/resources/repos/vue/test_repo/src/stores/calculator.ts b/test/resources/repos/vue/test_repo/src/stores/calculator.ts new file mode 100644 index 0000000..4c8a441 --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/stores/calculator.ts @@ -0,0 +1,179 @@ +import { defineStore } from 'pinia' +import type { HistoryEntry, Operation, CalculatorState } from '@/types' + +export const useCalculatorStore = defineStore('calculator', { + state: (): CalculatorState => ({ + currentValue: 0, + previousValue: null, + operation: null, + history: [], + displayValue: '0' + }), + + getters: { + /** + * Get the most recent history entries (last 10) + */ + recentHistory: (state): HistoryEntry[] => { + return state.history.slice(-10).reverse() + }, + + /** + * Check if calculator has any history + */ + hasHistory: (state): boolean => { + return state.history.length > 0 + }, + + /** + * Get the current display text + */ + display: (state): string => { + return state.displayValue + } + }, + + actions: { + /** + * Set a number value + */ + setNumber(value: number) { + this.currentValue = value + this.displayValue = value.toString() + }, + + /** + * Append a digit to the current value + */ + appendDigit(digit: number) { + if (this.displayValue === '0') { + this.displayValue = digit.toString() + } else { + this.displayValue += digit.toString() + } + this.currentValue = parseFloat(this.displayValue) + }, + + /** + * Add two numbers + */ + add() { + if (this.previousValue !== null && this.operation) { + this.executeOperation() + } + this.previousValue = this.currentValue + this.operation = 'add' + this.displayValue = '0' + }, + + /** + * Subtract two numbers + */ + subtract() { + if (this.previousValue !== null && this.operation) { + this.executeOperation() + } + this.previousValue = this.currentValue + this.operation = 'subtract' + this.displayValue = '0' + }, + + /** + * Multiply two numbers + */ + multiply() { + if (this.previousValue !== null && this.operation) { + this.executeOperation() + } + this.previousValue = this.currentValue + this.operation = 'multiply' + this.displayValue = '0' + }, + + /** + * Divide two numbers + */ + divide() { + if (this.previousValue !== null && this.operation) { + this.executeOperation() + } + this.previousValue = this.currentValue + this.operation = 'divide' + this.displayValue = '0' + }, + + /** + * Execute the pending operation + */ + executeOperation() { + if (this.previousValue === null || this.operation === null) { + return + } + + let result = 0 + const prev = this.previousValue + const current = this.currentValue + let expression = '' + + switch (this.operation) { + case 'add': + result = prev + current + expression = `${prev} + ${current}` + break + case 'subtract': + result = prev - current + expression = `${prev} - ${current}` + break + case 'multiply': + result = prev * current + expression = `${prev} × ${current}` + break + case 'divide': + if (current === 0) { + this.displayValue = 'Error' + this.clear() + return + } + result = prev / current + expression = `${prev} ÷ ${current}` + break + } + + // Add to history + this.history.push({ + expression, + result, + timestamp: new Date() + }) + + this.currentValue = result + this.displayValue = result.toString() + this.previousValue = null + this.operation = null + }, + + /** + * Calculate the equals operation + */ + equals() { + this.executeOperation() + }, + + /** + * Clear the calculator state + */ + clear() { + this.currentValue = 0 + this.previousValue = null + this.operation = null + this.displayValue = '0' + }, + + /** + * Clear all history + */ + clearHistory() { + this.history = [] + } + } +}) diff --git a/test/resources/repos/vue/test_repo/src/types/index.ts b/test/resources/repos/vue/test_repo/src/types/index.ts new file mode 100644 index 0000000..2766ee2 --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/types/index.ts @@ -0,0 +1,32 @@ +/** + * Represents a single calculation in the history + */ +export interface HistoryEntry { + expression: string + result: number + timestamp: Date +} + +/** + * Valid calculator operations + */ +export type Operation = 'add' | 'subtract' | 'multiply' | 'divide' | null + +/** + * The complete state of the calculator + */ +export interface CalculatorState { + currentValue: number + previousValue: number | null + operation: Operation + history: HistoryEntry[] + displayValue: string +} + +/** + * Format options for displaying numbers + */ +export interface FormatOptions { + maxDecimals?: number + useGrouping?: boolean +} diff --git a/test/resources/repos/vue/test_repo/tsconfig.json b/test/resources/repos/vue/test_repo/tsconfig.json new file mode 100644 index 0000000..8c9fa9f --- /dev/null +++ b/test/resources/repos/vue/test_repo/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "plugins": [ + { + "name": "@vue/typescript-plugin" + } + ], + "include": ["src/**/*.ts", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/test/resources/repos/vue/test_repo/tsconfig.node.json b/test/resources/repos/vue/test_repo/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/test/resources/repos/vue/test_repo/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/test/resources/repos/vue/test_repo/vite.config.ts b/test/resources/repos/vue/test_repo/vite.config.ts new file mode 100644 index 0000000..4343664 --- /dev/null +++ b/test/resources/repos/vue/test_repo/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + } +}) diff --git a/test/resources/repos/yaml/test_repo/config.yaml b/test/resources/repos/yaml/test_repo/config.yaml new file mode 100644 index 0000000..f6b6eba --- /dev/null +++ b/test/resources/repos/yaml/test_repo/config.yaml @@ -0,0 +1,25 @@ +# Application configuration +app: + name: test-application + version: 1.0.0 + port: 8080 + debug: true + +database: + host: localhost + port: 5432 + name: testdb + username: admin + password: secret123 + +logging: + level: info + format: json + outputs: + - console + - file + +features: + authentication: true + caching: false + monitoring: true diff --git a/test/resources/repos/yaml/test_repo/data.yaml b/test/resources/repos/yaml/test_repo/data.yaml new file mode 100644 index 0000000..86eb283 --- /dev/null +++ b/test/resources/repos/yaml/test_repo/data.yaml @@ -0,0 +1,41 @@ +# Sample data structure +users: + - id: 1 + name: John Doe + email: john@example.com + roles: + - admin + - developer + active: true + + - id: 2 + name: Jane Smith + email: jane@example.com + roles: + - developer + active: true + + - id: 3 + name: Bob Johnson + email: bob@example.com + roles: + - viewer + active: false + +projects: + - name: project-alpha + status: active + team: + - John Doe + - Jane Smith + tags: + - backend + - api + + - name: project-beta + status: planning + team: + - Jane Smith + tags: + - frontend + - ui diff --git a/test/resources/repos/yaml/test_repo/diagnostics_sample.yaml b/test/resources/repos/yaml/test_repo/diagnostics_sample.yaml new file mode 100644 index 0000000..c07e469 --- /dev/null +++ b/test/resources/repos/yaml/test_repo/diagnostics_sample.yaml @@ -0,0 +1,5 @@ +name: diagnostics +value: + - good + - + broken: [ diff --git a/test/resources/repos/yaml/test_repo/services.yml b/test/resources/repos/yaml/test_repo/services.yml new file mode 100644 index 0000000..ce92c8a --- /dev/null +++ b/test/resources/repos/yaml/test_repo/services.yml @@ -0,0 +1,51 @@ +# Docker compose services definition +version: '3.8' + +services: + web: + image: nginx:latest + ports: + - "80:80" + - "443:443" + volumes: + - ./html:/usr/share/nginx/html + environment: + - NGINX_HOST=localhost + - NGINX_PORT=80 + networks: + - frontend + + api: + image: node:18 + ports: + - "3000:3000" + depends_on: + - database + environment: + - NODE_ENV=production + - DB_HOST=database + networks: + - frontend + - backend + + database: + image: postgres:15 + ports: + - "5432:5432" + environment: + - POSTGRES_DB=mydb + - POSTGRES_USER=admin + - POSTGRES_PASSWORD=password + volumes: + - db-data:/var/lib/postgresql/data + networks: + - backend + +networks: + frontend: + driver: bridge + backend: + driver: bridge + +volumes: + db-data: diff --git a/test/resources/repos/zig/test_repo/.gitignore b/test/resources/repos/zig/test_repo/.gitignore new file mode 100644 index 0000000..0274a76 --- /dev/null +++ b/test/resources/repos/zig/test_repo/.gitignore @@ -0,0 +1,5 @@ +zig-cache/ +zig-out/ +.zig-cache/ +build/ +dist/ \ No newline at end of file diff --git a/test/resources/repos/zig/test_repo/build.zig b/test/resources/repos/zig/test_repo/build.zig new file mode 100644 index 0000000..c9c1ff5 --- /dev/null +++ b/test/resources/repos/zig/test_repo/build.zig @@ -0,0 +1,31 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const exe = b.addExecutable(.{ + .name = "test_repo", + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + const lib_tests = b.addTest(.{ + .root_source_file = b.path("src/calculator.zig"), + .target = target, + .optimize = optimize, + }); + + const run_lib_tests = b.addRunArtifact(lib_tests); + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_lib_tests.step); +} diff --git a/test/resources/repos/zig/test_repo/src/calculator.zig b/test/resources/repos/zig/test_repo/src/calculator.zig new file mode 100644 index 0000000..289ccf1 --- /dev/null +++ b/test/resources/repos/zig/test_repo/src/calculator.zig @@ -0,0 +1,66 @@ +const std = @import("std"); + +pub const CalculatorError = error{ + DivisionByZero, + Overflow, +}; + +pub const Calculator = struct { + const Self = @This(); + + pub fn init() Self { + return .{}; + } + + pub fn add(self: Self, a: i32, b: i32) i32 { + _ = self; + return a + b; + } + + pub fn subtract(self: Self, a: i32, b: i32) i32 { + _ = self; + return a - b; + } + + pub fn multiply(self: Self, a: i32, b: i32) i32 { + _ = self; + return a * b; + } + + pub fn divide(self: Self, a: i32, b: i32) !f64 { + _ = self; + if (b == 0) { + return CalculatorError.DivisionByZero; + } + return @as(f64, @floatFromInt(a)) / @as(f64, @floatFromInt(b)); + } + + pub fn power(self: Self, base: i32, exponent: u32) i64 { + _ = self; + return std.math.pow(i64, base, exponent); + } +}; + +test "Calculator add" { + const calc = Calculator.init(); + try std.testing.expectEqual(@as(i32, 7), calc.add(3, 4)); + try std.testing.expectEqual(@as(i32, 0), calc.add(-5, 5)); +} + +test "Calculator subtract" { + const calc = Calculator.init(); + try std.testing.expectEqual(@as(i32, -1), calc.subtract(3, 4)); + try std.testing.expectEqual(@as(i32, 10), calc.subtract(15, 5)); +} + +test "Calculator multiply" { + const calc = Calculator.init(); + try std.testing.expectEqual(@as(i32, 12), calc.multiply(3, 4)); + try std.testing.expectEqual(@as(i32, -25), calc.multiply(-5, 5)); +} + +test "Calculator divide" { + const calc = Calculator.init(); + try std.testing.expectEqual(@as(f64, 2.0), try calc.divide(10, 5)); + try std.testing.expectError(CalculatorError.DivisionByZero, calc.divide(10, 0)); +} \ No newline at end of file diff --git a/test/resources/repos/zig/test_repo/src/diagnostics_sample.zig b/test/resources/repos/zig/test_repo/src/diagnostics_sample.zig new file mode 100644 index 0000000..844d6f1 --- /dev/null +++ b/test/resources/repos/zig/test_repo/src/diagnostics_sample.zig @@ -0,0 +1,3 @@ +pub fn brokenFactory() void { + const value = +} diff --git a/test/resources/repos/zig/test_repo/src/main.zig b/test/resources/repos/zig/test_repo/src/main.zig new file mode 100644 index 0000000..156adab --- /dev/null +++ b/test/resources/repos/zig/test_repo/src/main.zig @@ -0,0 +1,32 @@ +const std = @import("std"); +const calculator = @import("calculator.zig"); +const math_utils = @import("math_utils.zig"); + +pub fn main() !void { + const stdout = std.io.getStdOut().writer(); + + const calc = calculator.Calculator.init(); + + const sum = calc.add(10, 5); + const diff = calc.subtract(10, 5); + const prod = calc.multiply(10, 5); + const quot = calc.divide(10, 5) catch |err| { + try stdout.print("Division error: {}\n", .{err}); + return; + }; + + try stdout.print("10 + 5 = {}\n", .{sum}); + try stdout.print("10 - 5 = {}\n", .{diff}); + try stdout.print("10 * 5 = {}\n", .{prod}); + try stdout.print("10 / 5 = {}\n", .{quot}); + + const factorial_result = math_utils.factorial(5); + try stdout.print("5! = {}\n", .{factorial_result}); + + const is_prime = math_utils.isPrime(17); + try stdout.print("Is 17 prime? {}\n", .{is_prime}); +} + +pub fn greeting(name: []const u8) []const u8 { + return std.fmt.allocPrint(std.heap.page_allocator, "Hello, {s}!", .{name}) catch "Hello!"; +} diff --git a/test/resources/repos/zig/test_repo/src/math_utils.zig b/test/resources/repos/zig/test_repo/src/math_utils.zig new file mode 100644 index 0000000..f214d72 --- /dev/null +++ b/test/resources/repos/zig/test_repo/src/math_utils.zig @@ -0,0 +1,65 @@ +const std = @import("std"); + +pub fn factorial(n: u32) u64 { + if (n == 0 or n == 1) { + return 1; + } + var result: u64 = 1; + var i: u32 = 2; + while (i <= n) : (i += 1) { + result *= i; + } + return result; +} + +pub fn isPrime(n: u32) bool { + if (n <= 1) return false; + if (n <= 3) return true; + if (n % 2 == 0 or n % 3 == 0) return false; + + var i: u32 = 5; + while (i * i <= n) : (i += 6) { + if (n % i == 0 or n % (i + 2) == 0) { + return false; + } + } + return true; +} + +pub fn gcd(a: u32, b: u32) u32 { + var x = a; + var y = b; + while (y != 0) { + const temp = y; + y = x % y; + x = temp; + } + return x; +} + +pub fn lcm(a: u32, b: u32) u32 { + return (a * b) / gcd(a, b); +} + +test "factorial" { + try std.testing.expectEqual(@as(u64, 1), factorial(0)); + try std.testing.expectEqual(@as(u64, 1), factorial(1)); + try std.testing.expectEqual(@as(u64, 120), factorial(5)); + try std.testing.expectEqual(@as(u64, 3628800), factorial(10)); +} + +test "isPrime" { + try std.testing.expect(!isPrime(0)); + try std.testing.expect(!isPrime(1)); + try std.testing.expect(isPrime(2)); + try std.testing.expect(isPrime(3)); + try std.testing.expect(!isPrime(4)); + try std.testing.expect(isPrime(17)); + try std.testing.expect(!isPrime(100)); +} + +test "gcd and lcm" { + try std.testing.expectEqual(@as(u32, 6), gcd(12, 18)); + try std.testing.expectEqual(@as(u32, 1), gcd(17, 19)); + try std.testing.expectEqual(@as(u32, 36), lcm(12, 18)); +} \ No newline at end of file diff --git a/test/resources/repos/zig/test_repo/zls.json b/test/resources/repos/zig/test_repo/zls.json new file mode 100644 index 0000000..71054e7 --- /dev/null +++ b/test/resources/repos/zig/test_repo/zls.json @@ -0,0 +1,12 @@ +{ + "enable_build_on_save": true, + "build_on_save_args": ["build"], + "enable_autofix": false, + "semantic_tokens": "full", + "enable_inlay_hints": true, + "inlay_hints_show_builtin": true, + "inlay_hints_exclude_single_argument": true, + "inlay_hints_show_parameter_name": true, + "skip_std_references": false, + "max_detail_length": 1048576 +} diff --git a/test/serena/__init__.py b/test/serena/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/test/serena/__init__.py @@ -0,0 +1 @@ + diff --git a/test/serena/__snapshots__/test_symbol_editing.ambr b/test/serena/__snapshots__/test_symbol_editing.ambr new file mode 100644 index 0000000..ec23cf3 --- /dev/null +++ b/test/serena/__snapshots__/test_symbol_editing.ambr @@ -0,0 +1,2380 @@ +# serializer version: 1 +# name: test_delete_symbol[test_case0] + ''' + """ + Test module for variable declarations and usage. + + This module tests various types of variable declarations and usages including: + - Module-level variables + - Class-level variables + - Instance variables + - Variable reassignments + """ + + from dataclasses import dataclass, field + + # Module-level variables + module_var = "Initial module value" + + reassignable_module_var = 10 + reassignable_module_var = 20 # Reassigned + + # Module-level variable with type annotation + typed_module_var: int = 42 + + + # Regular class with class and instance variables + + + + # Dataclass with variables + @dataclass + class VariableDataclass: + """Dataclass that contains various fields.""" + + # Field variables with type annotations + id: int + name: str + items: list[str] = field(default_factory=list) + metadata: dict[str, str] = field(default_factory=dict) + optional_value: float | None = None + + # This will be reassigned in various places + status: str = "pending" + + + # Function that uses the module variables + def use_module_variables(): + """Function that uses module-level variables.""" + result = module_var + " used in function" + other_result = reassignable_module_var * 2 + return result, other_result + + + # Create instances and use variables + dataclass_instance = VariableDataclass(id=1, name="Test") + dataclass_instance.status = "active" # Reassign dataclass field + + # Use variables at module level + module_result = module_var + " used at module level" + other_module_result = reassignable_module_var + 30 + + # Create a second dataclass instance with different status + second_dataclass = VariableDataclass(id=2, name="Another Test") + second_dataclass.status = "completed" # Another reassignment of status + + ''' +# --- +# name: test_delete_symbol[test_case1] + ''' + import { ConsoleGreeter, Greeter } from "./formatters"; + + + + export function helperFunction() { + const demo = new DemoClass(42); + demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); + } + + helperFunction(); + + export function unusedStandaloneFunction(): string { + return "This function is not referenced anywhere"; + } + + ''' +# --- +# name: test_delete_symbol_vue[test_case0] + ''' + + + + + + + ''' +# --- +# name: test_insert_in_rel_to_symbol[test_case0-after] + ''' + """ + Test module for variable declarations and usage. + + This module tests various types of variable declarations and usages including: + - Module-level variables + - Class-level variables + - Instance variables + - Variable reassignments + """ + + from dataclasses import dataclass, field + + # Module-level variables + module_var = "Initial module value" + + reassignable_module_var = 10 + reassignable_module_var = 20 # Reassigned + + # Module-level variable with type annotation + typed_module_var: int = 42 + + + # Regular class with class and instance variables + class VariableContainer: + """Class that contains various variables.""" + + # Class-level variables + class_var = "Initial class value" + + reassignable_class_var = True + reassignable_class_var = False # Reassigned #noqa: PIE794 + + # Class-level variable with type annotation + typed_class_var: str = "typed value" + + def __init__(self): + # Instance variables + self.instance_var = "Initial instance value" + self.reassignable_instance_var = 100 + + # Instance variable with type annotation + self.typed_instance_var: list[str] = ["item1", "item2"] + + def modify_instance_var(self): + # Reassign instance variable + self.instance_var = "Modified instance value" + self.reassignable_instance_var = 200 # Reassigned + + def use_module_var(self): + # Use module-level variables + result = module_var + " used in method" + other_result = reassignable_module_var + 5 + return result, other_result + + def use_class_var(self): + # Use class-level variables + result = VariableContainer.class_var + " used in method" + other_result = VariableContainer.reassignable_class_var + return result, other_result + + + # Dataclass with variables + @dataclass + class VariableDataclass: + """Dataclass that contains various fields.""" + + # Field variables with type annotations + id: int + name: str + items: list[str] = field(default_factory=list) + metadata: dict[str, str] = field(default_factory=dict) + optional_value: float | None = None + + # This will be reassigned in various places + status: str = "pending" + + + # Function that uses the module variables + def use_module_variables(): + """Function that uses module-level variables.""" + result = module_var + " used in function" + other_result = reassignable_module_var * 2 + return result, other_result + + def new_inserted_function(): + print("This is a new function inserted before another.") + + + # Create instances and use variables + dataclass_instance = VariableDataclass(id=1, name="Test") + dataclass_instance.status = "active" # Reassign dataclass field + + # Use variables at module level + module_result = module_var + " used at module level" + other_module_result = reassignable_module_var + 30 + + # Create a second dataclass instance with different status + second_dataclass = VariableDataclass(id=2, name="Another Test") + second_dataclass.status = "completed" # Another reassignment of status + + ''' +# --- +# name: test_insert_in_rel_to_symbol[test_case0-before] + ''' + """ + Test module for variable declarations and usage. + + This module tests various types of variable declarations and usages including: + - Module-level variables + - Class-level variables + - Instance variables + - Variable reassignments + """ + + from dataclasses import dataclass, field + + # Module-level variables + module_var = "Initial module value" + + reassignable_module_var = 10 + reassignable_module_var = 20 # Reassigned + + # Module-level variable with type annotation + typed_module_var: int = 42 + + + # Regular class with class and instance variables + class VariableContainer: + """Class that contains various variables.""" + + # Class-level variables + class_var = "Initial class value" + + reassignable_class_var = True + reassignable_class_var = False # Reassigned #noqa: PIE794 + + # Class-level variable with type annotation + typed_class_var: str = "typed value" + + def __init__(self): + # Instance variables + self.instance_var = "Initial instance value" + self.reassignable_instance_var = 100 + + # Instance variable with type annotation + self.typed_instance_var: list[str] = ["item1", "item2"] + + def modify_instance_var(self): + # Reassign instance variable + self.instance_var = "Modified instance value" + self.reassignable_instance_var = 200 # Reassigned + + def use_module_var(self): + # Use module-level variables + result = module_var + " used in method" + other_result = reassignable_module_var + 5 + return result, other_result + + def use_class_var(self): + # Use class-level variables + result = VariableContainer.class_var + " used in method" + other_result = VariableContainer.reassignable_class_var + return result, other_result + + + # Dataclass with variables + @dataclass + class VariableDataclass: + """Dataclass that contains various fields.""" + + # Field variables with type annotations + id: int + name: str + items: list[str] = field(default_factory=list) + metadata: dict[str, str] = field(default_factory=dict) + optional_value: float | None = None + + # This will be reassigned in various places + status: str = "pending" + + + # Function that uses the module variables + def new_inserted_function(): + print("This is a new function inserted before another.") + + def use_module_variables(): + """Function that uses module-level variables.""" + result = module_var + " used in function" + other_result = reassignable_module_var * 2 + return result, other_result + + + # Create instances and use variables + dataclass_instance = VariableDataclass(id=1, name="Test") + dataclass_instance.status = "active" # Reassign dataclass field + + # Use variables at module level + module_result = module_var + " used at module level" + other_module_result = reassignable_module_var + 30 + + # Create a second dataclass instance with different status + second_dataclass = VariableDataclass(id=2, name="Another Test") + second_dataclass.status = "completed" # Another reassignment of status + + ''' +# --- +# name: test_insert_in_rel_to_symbol[test_case1-after] + ''' + import { ConsoleGreeter, Greeter } from "./formatters"; + + export class DemoClass { + value: number; + constructor(value: number) { + this.value = value; + } + printValue() { + console.log(this.value); + } + } + + function newFunctionAfterClass(): void { + console.log("This function is after DemoClass."); + } + + export function helperFunction() { + const demo = new DemoClass(42); + demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); + } + + helperFunction(); + + export function unusedStandaloneFunction(): string { + return "This function is not referenced anywhere"; + } + + ''' +# --- +# name: test_insert_in_rel_to_symbol[test_case1-before] + ''' + import { ConsoleGreeter, Greeter } from "./formatters"; + + function newFunctionAfterClass(): void { + console.log("This function is after DemoClass."); + } + + export class DemoClass { + value: number; + constructor(value: number) { + this.value = value; + } + printValue() { + console.log(this.value); + } + } + + export function helperFunction() { + const demo = new DemoClass(42); + demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); + } + + helperFunction(); + + export function unusedStandaloneFunction(): string { + return "This function is not referenced anywhere"; + } + + ''' +# --- +# name: test_insert_in_rel_to_symbol[test_case2-after] + ''' + import { ConsoleGreeter, Greeter } from "./formatters"; + + export class DemoClass { + value: number; + constructor(value: number) { + this.value = value; + } + printValue() { + console.log(this.value); + } + } + + export function helperFunction() { + const demo = new DemoClass(42); + demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); + } + + function newInsertedFunction(): void { + console.log("This is a new function inserted before another."); + } + + helperFunction(); + + export function unusedStandaloneFunction(): string { + return "This function is not referenced anywhere"; + } + + ''' +# --- +# name: test_insert_in_rel_to_symbol[test_case2-before] + ''' + import { ConsoleGreeter, Greeter } from "./formatters"; + + export class DemoClass { + value: number; + constructor(value: number) { + this.value = value; + } + printValue() { + console.log(this.value); + } + } + + function newInsertedFunction(): void { + console.log("This is a new function inserted before another."); + } + + export function helperFunction() { + const demo = new DemoClass(42); + demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); + } + + helperFunction(); + + export function unusedStandaloneFunction(): string { + return "This function is not referenced anywhere"; + } + + ''' +# --- +# name: test_insert_in_rel_to_symbol_vue[test_case0-after] + ''' + + + + + + + ''' +# --- +# name: test_insert_in_rel_to_symbol_vue[test_case0-before] + ''' + + + + + + + ''' +# --- +# name: test_insert_python_class_after + ''' + """ + Test module for variable declarations and usage. + + This module tests various types of variable declarations and usages including: + - Module-level variables + - Class-level variables + - Instance variables + - Variable reassignments + """ + + from dataclasses import dataclass, field + + # Module-level variables + module_var = "Initial module value" + + reassignable_module_var = 10 + reassignable_module_var = 20 # Reassigned + + # Module-level variable with type annotation + typed_module_var: int = 42 + + + # Regular class with class and instance variables + class VariableContainer: + """Class that contains various variables.""" + + # Class-level variables + class_var = "Initial class value" + + reassignable_class_var = True + reassignable_class_var = False # Reassigned #noqa: PIE794 + + # Class-level variable with type annotation + typed_class_var: str = "typed value" + + def __init__(self): + # Instance variables + self.instance_var = "Initial instance value" + self.reassignable_instance_var = 100 + + # Instance variable with type annotation + self.typed_instance_var: list[str] = ["item1", "item2"] + + def modify_instance_var(self): + # Reassign instance variable + self.instance_var = "Modified instance value" + self.reassignable_instance_var = 200 # Reassigned + + def use_module_var(self): + # Use module-level variables + result = module_var + " used in method" + other_result = reassignable_module_var + 5 + return result, other_result + + def use_class_var(self): + # Use class-level variables + result = VariableContainer.class_var + " used in method" + other_result = VariableContainer.reassignable_class_var + return result, other_result + + + # Dataclass with variables + @dataclass + class VariableDataclass: + """Dataclass that contains various fields.""" + + # Field variables with type annotations + id: int + name: str + items: list[str] = field(default_factory=list) + metadata: dict[str, str] = field(default_factory=dict) + optional_value: float | None = None + + # This will be reassigned in various places + status: str = "pending" + + + class NewInsertedClass: + pass + + + # Function that uses the module variables + def use_module_variables(): + """Function that uses module-level variables.""" + result = module_var + " used in function" + other_result = reassignable_module_var * 2 + return result, other_result + + + # Create instances and use variables + dataclass_instance = VariableDataclass(id=1, name="Test") + dataclass_instance.status = "active" # Reassign dataclass field + + # Use variables at module level + module_result = module_var + " used at module level" + other_module_result = reassignable_module_var + 30 + + # Create a second dataclass instance with different status + second_dataclass = VariableDataclass(id=2, name="Another Test") + second_dataclass.status = "completed" # Another reassignment of status + + ''' +# --- +# name: test_insert_python_class_before + ''' + """ + Test module for variable declarations and usage. + + This module tests various types of variable declarations and usages including: + - Module-level variables + - Class-level variables + - Instance variables + - Variable reassignments + """ + + from dataclasses import dataclass, field + + # Module-level variables + module_var = "Initial module value" + + reassignable_module_var = 10 + reassignable_module_var = 20 # Reassigned + + # Module-level variable with type annotation + typed_module_var: int = 42 + + + # Regular class with class and instance variables + class VariableContainer: + """Class that contains various variables.""" + + # Class-level variables + class_var = "Initial class value" + + reassignable_class_var = True + reassignable_class_var = False # Reassigned #noqa: PIE794 + + # Class-level variable with type annotation + typed_class_var: str = "typed value" + + def __init__(self): + # Instance variables + self.instance_var = "Initial instance value" + self.reassignable_instance_var = 100 + + # Instance variable with type annotation + self.typed_instance_var: list[str] = ["item1", "item2"] + + def modify_instance_var(self): + # Reassign instance variable + self.instance_var = "Modified instance value" + self.reassignable_instance_var = 200 # Reassigned + + def use_module_var(self): + # Use module-level variables + result = module_var + " used in method" + other_result = reassignable_module_var + 5 + return result, other_result + + def use_class_var(self): + # Use class-level variables + result = VariableContainer.class_var + " used in method" + other_result = VariableContainer.reassignable_class_var + return result, other_result + + + # Dataclass with variables + class NewInsertedClass: + pass + + + @dataclass + class VariableDataclass: + """Dataclass that contains various fields.""" + + # Field variables with type annotations + id: int + name: str + items: list[str] = field(default_factory=list) + metadata: dict[str, str] = field(default_factory=dict) + optional_value: float | None = None + + # This will be reassigned in various places + status: str = "pending" + + + # Function that uses the module variables + def use_module_variables(): + """Function that uses module-level variables.""" + result = module_var + " used in function" + other_result = reassignable_module_var * 2 + return result, other_result + + + # Create instances and use variables + dataclass_instance = VariableDataclass(id=1, name="Test") + dataclass_instance.status = "active" # Reassign dataclass field + + # Use variables at module level + module_result = module_var + " used at module level" + other_module_result = reassignable_module_var + 30 + + # Create a second dataclass instance with different status + second_dataclass = VariableDataclass(id=2, name="Another Test") + second_dataclass.status = "completed" # Another reassignment of status + + ''' +# --- +# name: test_nix_symbol_replacement_no_double_semicolon + ''' + # default.nix - Traditional Nix expression for backwards compatibility + { pkgs ? import { } }: + + let + # Import library functions + lib = pkgs.lib; + stdenv = pkgs.stdenv; + + # Import our custom utilities + utils = import ./lib/utils.nix { inherit lib; }; + + # Custom function to create a greeting + makeGreeting = name: "Hello, ${name}!"; + + # List manipulation functions (using imported utils) + listUtils = { + double = list: map (x: x * 2) list; + sum = list: lib.foldl' (acc: x: acc + x) 0 list; + average = list: + if list == [ ] + then 0 + else (listUtils.sum list) / (builtins.length list); + # Use function from imported utils + unique = utils.lists.unique; + }; + + # String utilities + stringUtils = rec { + capitalize = str: + let + first = lib.substring 0 1 str; + rest = lib.substring 1 (-1) str; + in + (lib.toUpper first) + rest; + + repeat = n: str: lib.concatStrings (lib.genList (_: str) n); + + padLeft = width: char: str: + let + len = lib.stringLength str; + padding = if len >= width then 0 else width - len; + in + (repeat padding char) + str; + }; + + # Package builder helper + buildSimplePackage = { name, version, script }: + stdenv.mkDerivation { + pname = name; + inherit version; + + phases = [ "installPhase" ]; + + installPhase = '' + mkdir -p $out/bin + cat > $out/bin/${name} << EOF + #!/usr/bin/env bash + ${script} + EOF + chmod +x $out/bin/${name} + ''; + }; + + in + rec { + # Export utilities + inherit listUtils stringUtils makeGreeting; + + # Export imported utilities directly + inherit (utils) math strings; + + # Example packages + hello = buildSimplePackage { + name = "hello"; + version = "1.0"; + script = '' + echo "${makeGreeting "World"}" + ''; + }; + + calculator = buildSimplePackage { + name = "calculator"; + version = "0.1"; + script = '' + if [ $# -ne 3 ]; then + echo "Usage: calculator " + exit 1 + fi + + case $2 in + +) echo $(($1 + $3)) ;; + -) echo $(($1 - $3)) ;; + x) echo $(($1 * $3)) ;; + /) echo $(($1 / $3)) ;; + *) echo "Unknown operator: $2" ;; + esac + ''; + }; + + # Environment with multiple packages + devEnv = pkgs.buildEnv { + name = "dev-environment"; + paths = with pkgs; [ + git + vim + bash + hello + calculator + ]; + }; + + # Shell derivation + shell = pkgs.mkShell { + buildInputs = with pkgs; [ + bash + coreutils + findutils + gnugrep + gnused + ]; + + shellHook = '' + echo "Entering Nix shell environment" + echo "Available custom functions: makeGreeting, listUtils, stringUtils" + ''; + }; + + # Configuration example + config = { + system = { + stateVersion = "23.11"; + enable = true; + }; + + services = { + nginx = { + enable = false; + virtualHosts = { + "example.com" = { + root = "/var/www/example"; + locations."/" = { + index = "index.html"; + }; + }; + }; + }; + }; + + users = { + c = 3; + }; + }; + + # Recursive attribute set example + tree = { + root = { + value = 1; + left = { + value = 2; + left = { value = 4; }; + right = { value = 5; }; + }; + right = { + value = 3; + left = { value = 6; }; + right = { value = 7; }; + }; + }; + + # Tree traversal function + traverse = node: + if node ? left && node ? right + then [ node.value ] ++ (tree.traverse node.left) ++ (tree.traverse node.right) + else if node ? value + then [ node.value ] + else [ ]; + }; + } + + ''' +# --- +# name: test_rename_symbol + ''' + """ + Test module for variable declarations and usage. + + This module tests various types of variable declarations and usages including: + - Module-level variables + - Class-level variables + - Instance variables + - Variable reassignments + """ + + from dataclasses import dataclass, field + + # Module-level variables + module_var = "Initial module value" + + reassignable_module_var = 10 + reassignable_module_var = 20 # Reassigned + + # Module-level variable with type annotation + renamed_typed_module_var: int = 42 + + + # Regular class with class and instance variables + class VariableContainer: + """Class that contains various variables.""" + + # Class-level variables + class_var = "Initial class value" + + reassignable_class_var = True + reassignable_class_var = False # Reassigned #noqa: PIE794 + + # Class-level variable with type annotation + typed_class_var: str = "typed value" + + def __init__(self): + # Instance variables + self.instance_var = "Initial instance value" + self.reassignable_instance_var = 100 + + # Instance variable with type annotation + self.typed_instance_var: list[str] = ["item1", "item2"] + + def modify_instance_var(self): + # Reassign instance variable + self.instance_var = "Modified instance value" + self.reassignable_instance_var = 200 # Reassigned + + def use_module_var(self): + # Use module-level variables + result = module_var + " used in method" + other_result = reassignable_module_var + 5 + return result, other_result + + def use_class_var(self): + # Use class-level variables + result = VariableContainer.class_var + " used in method" + other_result = VariableContainer.reassignable_class_var + return result, other_result + + + # Dataclass with variables + @dataclass + class VariableDataclass: + """Dataclass that contains various fields.""" + + # Field variables with type annotations + id: int + name: str + items: list[str] = field(default_factory=list) + metadata: dict[str, str] = field(default_factory=dict) + optional_value: float | None = None + + # This will be reassigned in various places + status: str = "pending" + + + # Function that uses the module variables + def use_module_variables(): + """Function that uses module-level variables.""" + result = module_var + " used in function" + other_result = reassignable_module_var * 2 + return result, other_result + + + # Create instances and use variables + dataclass_instance = VariableDataclass(id=1, name="Test") + dataclass_instance.status = "active" # Reassign dataclass field + + # Use variables at module level + module_result = module_var + " used at module level" + other_module_result = reassignable_module_var + 30 + + # Create a second dataclass instance with different status + second_dataclass = VariableDataclass(id=2, name="Another Test") + second_dataclass.status = "completed" # Another reassignment of status + + ''' +# --- +# name: test_replace_body[test_case0] + ''' + """ + Test module for variable declarations and usage. + + This module tests various types of variable declarations and usages including: + - Module-level variables + - Class-level variables + - Instance variables + - Variable reassignments + """ + + from dataclasses import dataclass, field + + # Module-level variables + module_var = "Initial module value" + + reassignable_module_var = 10 + reassignable_module_var = 20 # Reassigned + + # Module-level variable with type annotation + typed_module_var: int = 42 + + + # Regular class with class and instance variables + class VariableContainer: + """Class that contains various variables.""" + + # Class-level variables + class_var = "Initial class value" + + reassignable_class_var = True + reassignable_class_var = False # Reassigned #noqa: PIE794 + + # Class-level variable with type annotation + typed_class_var: str = "typed value" + + def __init__(self): + # Instance variables + self.instance_var = "Initial instance value" + self.reassignable_instance_var = 100 + + # Instance variable with type annotation + self.typed_instance_var: list[str] = ["item1", "item2"] + + def modify_instance_var(self): + # This body has been replaced + self.instance_var = "Replaced!" + self.reassignable_instance_var = 999 # Reassigned + + def use_module_var(self): + # Use module-level variables + result = module_var + " used in method" + other_result = reassignable_module_var + 5 + return result, other_result + + def use_class_var(self): + # Use class-level variables + result = VariableContainer.class_var + " used in method" + other_result = VariableContainer.reassignable_class_var + return result, other_result + + + # Dataclass with variables + @dataclass + class VariableDataclass: + """Dataclass that contains various fields.""" + + # Field variables with type annotations + id: int + name: str + items: list[str] = field(default_factory=list) + metadata: dict[str, str] = field(default_factory=dict) + optional_value: float | None = None + + # This will be reassigned in various places + status: str = "pending" + + + # Function that uses the module variables + def use_module_variables(): + """Function that uses module-level variables.""" + result = module_var + " used in function" + other_result = reassignable_module_var * 2 + return result, other_result + + + # Create instances and use variables + dataclass_instance = VariableDataclass(id=1, name="Test") + dataclass_instance.status = "active" # Reassign dataclass field + + # Use variables at module level + module_result = module_var + " used at module level" + other_module_result = reassignable_module_var + 30 + + # Create a second dataclass instance with different status + second_dataclass = VariableDataclass(id=2, name="Another Test") + second_dataclass.status = "completed" # Another reassignment of status + + ''' +# --- +# name: test_replace_body[test_case1] + ''' + import { ConsoleGreeter, Greeter } from "./formatters"; + + export class DemoClass { + value: number; + constructor(value: number) { + this.value = value; + } + function printValue() { + // This body has been replaced + console.warn("New value: " + this.value); + } + } + + export function helperFunction() { + const demo = new DemoClass(42); + demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); + } + + helperFunction(); + + export function unusedStandaloneFunction(): string { + return "This function is not referenced anywhere"; + } + + ''' +# --- +# name: test_replace_body_vue[test_case0] + ''' + + + + + + + ''' +# --- +# name: test_replace_body_vue_ts_file[test_case0] + ''' + import { defineStore } from 'pinia' + import type { HistoryEntry, Operation, CalculatorState } from '@/types' + + export const useCalculatorStore = defineStore('calculator', { + state: (): CalculatorState => ({ + currentValue: 0, + previousValue: null, + operation: null, + history: [], + displayValue: '0' + }), + + getters: { + /** + * Get the most recent history entries (last 10) + */ + recentHistory: (state): HistoryEntry[] => { + return state.history.slice(-10).reverse() + }, + + /** + * Check if calculator has any history + */ + hasHistory: (state): boolean => { + return state.history.length > 0 + }, + + /** + * Get the current display text + */ + display: (state): string => { + return state.displayValue + } + }, + + actions: { + /** + * Set a number value + */ + setNumber(value: number) { + this.currentValue = value + this.displayValue = value.toString() + }, + + /** + * Append a digit to the current value + */ + appendDigit(digit: number) { + if (this.displayValue === '0') { + this.displayValue = digit.toString() + } else { + this.displayValue += digit.toString() + } + this.currentValue = parseFloat(this.displayValue) + }, + + /** + * Add two numbers + */ + add() { + if (this.previousValue !== null && this.operation) { + this.executeOperation() + } + this.previousValue = this.currentValue + this.operation = 'add' + this.displayValue = '0' + }, + + /** + * Subtract two numbers + */ + subtract() { + if (this.previousValue !== null && this.operation) { + this.executeOperation() + } + this.previousValue = this.currentValue + this.operation = 'subtract' + this.displayValue = '0' + }, + + /** + * Multiply two numbers + */ + multiply() { + if (this.previousValue !== null && this.operation) { + this.executeOperation() + } + this.previousValue = this.currentValue + this.operation = 'multiply' + this.displayValue = '0' + }, + + /** + * Divide two numbers + */ + divide() { + if (this.previousValue !== null && this.operation) { + this.executeOperation() + } + this.previousValue = this.currentValue + this.operation = 'divide' + this.displayValue = '0' + }, + + /** + * Execute the pending operation + */ + executeOperation() { + if (this.previousValue === null || this.operation === null) { + return + } + + let result = 0 + const prev = this.previousValue + const current = this.currentValue + let expression = '' + + switch (this.operation) { + case 'add': + result = prev + current + expression = `${prev} + ${current}` + break + case 'subtract': + result = prev - current + expression = `${prev} - ${current}` + break + case 'multiply': + result = prev * current + expression = `${prev} × ${current}` + break + case 'divide': + if (current === 0) { + this.displayValue = 'Error' + this.clear() + return + } + result = prev / current + expression = `${prev} ÷ ${current}` + break + } + + // Add to history + this.history.push({ + expression, + result, + timestamp: new Date() + }) + + this.currentValue = result + this.displayValue = result.toString() + this.previousValue = null + this.operation = null + }, + + /** + * Calculate the equals operation + */ + equals() { + this.executeOperation() + }, + + /** + * Clear the calculator state + */ + function clear() { + // Modified: Reset to initial state with a log + console.log('Clearing calculator state'); + displayValue.value = '0'; + expression.value = ''; + operationHistory.value = []; + lastResult.value = undefined; + }, + + /** + * Clear all history + */ + clearHistory() { + this.history = [] + } + } + }) + + ''' +# --- +# name: test_replace_body_vue_with_disambiguation[test_case0] + ''' + + + + + + + ''' +# --- diff --git a/test/serena/config/__init__.py b/test/serena/config/__init__.py new file mode 100644 index 0000000..6b95e0d --- /dev/null +++ b/test/serena/config/__init__.py @@ -0,0 +1 @@ +# Empty init file for test package diff --git a/test/serena/config/test_global_ignored_paths.py b/test/serena/config/test_global_ignored_paths.py new file mode 100644 index 0000000..862ac2f --- /dev/null +++ b/test/serena/config/test_global_ignored_paths.py @@ -0,0 +1,269 @@ +import os +import shutil +import tempfile +from pathlib import Path + +from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig +from serena.project import Project +from solidlsp.ls_config import Language + + +def _create_test_project( + project_root: Path, + project_ignored_paths: list[str] | None = None, + global_ignored_paths: list[str] | None = None, +) -> Project: + """Helper to create a Project with the given ignored paths configuration.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=project_ignored_paths or [], + ignore_all_files_in_gitignore=False, + ) + serena_config = SerenaConfig(ignored_paths=global_ignored_paths).with_headless_mode_overrides() + return Project( + project_root=str(project_root), + project_config=config, + serena_config=serena_config, + ) + + +class TestGlobalIgnoredPaths: + """Tests for system-global ignored_paths feature.""" + + def setup_method(self) -> None: + self.test_dir = tempfile.mkdtemp() + self.project_path = Path(self.test_dir) + # Create some test files and directories + (self.project_path / "main.py").write_text("print('hello')") + os.makedirs(self.project_path / "node_modules" / "pkg", exist_ok=True) + (self.project_path / "node_modules" / "pkg" / "index.js").write_text("module.exports = {}") + os.makedirs(self.project_path / "build", exist_ok=True) + (self.project_path / "build" / "output.js").write_text("compiled") + os.makedirs(self.project_path / "src", exist_ok=True) + (self.project_path / "src" / "app.py").write_text("def app(): pass") + (self.project_path / "debug.log").write_text("log data") + + def teardown_method(self) -> None: + shutil.rmtree(self.test_dir) + + def test_global_ignored_paths_are_applied(self) -> None: + """Global ignored_paths from SerenaConfig are respected by Project.is_ignored_path().""" + project = _create_test_project( + self.project_path, + global_ignored_paths=["node_modules"], + ) + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + assert not project.is_ignored_path(str(self.project_path / "src" / "app.py")) + + def test_additive_merge_of_global_and_project_patterns(self) -> None: + """Global + project patterns are merged additively (both applied).""" + project = _create_test_project( + self.project_path, + project_ignored_paths=["build"], + global_ignored_paths=["node_modules"], + ) + # Global pattern should be applied + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + # Project pattern should also be applied + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + # Non-ignored files should not be affected + assert not project.is_ignored_path(str(self.project_path / "src" / "app.py")) + + def test_empty_global_ignored_paths_has_no_effect(self) -> None: + """Empty global ignored_paths (default) has no effect on existing behavior.""" + project = _create_test_project( + self.project_path, + project_ignored_paths=["build"], + global_ignored_paths=[], + ) + # Project pattern still works + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + # Non-ignored files still accessible + assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + + def test_duplicate_patterns_across_global_and_project(self) -> None: + """Duplicate patterns across global and project do not cause errors.""" + project = _create_test_project( + self.project_path, + project_ignored_paths=["node_modules", "build"], + global_ignored_paths=["node_modules", "build"], + ) + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + assert not project.is_ignored_path(str(self.project_path / "src" / "app.py")) + + def test_glob_patterns_in_global_ignored_paths(self) -> None: + """Global ignored_paths support gitignore-style glob patterns.""" + project = _create_test_project( + self.project_path, + global_ignored_paths=["*.log"], + ) + assert project.is_ignored_path(str(self.project_path / "debug.log")) + assert not project.is_ignored_path(str(self.project_path / "main.py")) + + def test_nonexistent_path_not_ignored(self) -> None: + """Non-existent paths should return False (not ignored), not raise FileNotFoundError.""" + project = _create_test_project(self.project_path) + nonexistent = str(self.project_path / "src" / "new_file.py") + assert not os.path.exists(nonexistent) + assert not project.is_ignored_path(nonexistent) + + def test_nonexistent_path_with_ignore_patterns(self) -> None: + """Non-existent paths matching ignore patterns still return False (cannot check).""" + project = _create_test_project( + self.project_path, + global_ignored_paths=["*.log"], + ) + nonexistent = str(self.project_path / "nonexistent.log") + assert not os.path.exists(nonexistent) + # Should not raise, should not match the *.log pattern + assert not project.is_ignored_path(nonexistent) + + def test_validate_relative_path_allows_nonexistent(self) -> None: + """validate_relative_path with require_not_ignored should not raise for non-existent paths.""" + project = _create_test_project(self.project_path) + nonexistent = "src/new_file.py" + # Should not raise + project.validate_relative_path(nonexistent, require_not_ignored=True) + + +class TestRegisteredProjectGlobalIgnoredPaths: + """RegisteredProject.get_project_instance() correctly passes global patterns to Project.""" + + def setup_method(self) -> None: + self.test_dir = tempfile.mkdtemp() + self.project_path = Path(self.test_dir).resolve() + (self.project_path / "main.py").write_text("print('hello')") + os.makedirs(self.project_path / "node_modules", exist_ok=True) + (self.project_path / "node_modules" / "pkg.js").write_text("module") + + def teardown_method(self) -> None: + shutil.rmtree(self.test_dir) + + def test_get_project_instance_passes_global_ignored_paths(self) -> None: + """RegisteredProject.get_project_instance() passes global_ignored_paths to Project.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=[], + ignore_all_files_in_gitignore=False, + ) + serena_config = SerenaConfig(ignored_paths=["node_modules"]).with_headless_mode_overrides() + registered = RegisteredProject( + project_root=str(self.project_path), + project_config=config, + ) + project = registered.get_project_instance(serena_config=serena_config) + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + + def test_get_project_instance_without_global_ignored_paths(self) -> None: + """RegisteredProject without global_ignored_paths defaults to empty.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=[], + ignore_all_files_in_gitignore=False, + ) + registered = RegisteredProject( + project_root=str(self.project_path), + project_config=config, + ) + serena_config = SerenaConfig(ignored_paths=[]).with_headless_mode_overrides() + project = registered.get_project_instance(serena_config=serena_config) + assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + + def test_from_project_root_passes_global_ignored_paths(self) -> None: + """RegisteredProject.from_project_root() threads global_ignored_paths to Project.""" + # Create a minimal project.yml so from_project_root can load config + serena_dir = self.project_path / ".serena" + serena_dir.mkdir(exist_ok=True) + (serena_dir / "project.yml").write_text( + 'project_name: "test_project"\nlanguages: ["python"]\nignored_paths: []\nignore_all_files_in_gitignore: false\n' + ) + serena_config = SerenaConfig(ignored_paths=["node_modules"]).with_headless_mode_overrides() + registered = RegisteredProject.from_project_root( + str(self.project_path), + serena_config=serena_config, + ) + project = registered.get_project_instance(serena_config=serena_config) + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + + def test_from_project_instance_passes_global_ignored_paths(self) -> None: + """RegisteredProject.from_project_instance() threads global_ignored_paths to Project.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=[], + ignore_all_files_in_gitignore=False, + ) + serena_config = SerenaConfig(ignored_paths=["node_modules"]).with_headless_mode_overrides() + project = Project( + project_root=str(self.project_path), + project_config=config, + serena_config=serena_config, + ) + registered = RegisteredProject.from_project_instance(project) + # The registered project already has a project_instance, so get_project_instance() returns it directly + retrieved = registered.get_project_instance(serena_config=serena_config) + assert retrieved.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + + +class TestGlobalIgnoredPathsWithGitignore: + """Global ignored_paths combined with ignore_all_files_in_gitignore produces correct three-way merge.""" + + def setup_method(self) -> None: + self.test_dir = tempfile.mkdtemp() + self.project_path = Path(self.test_dir).resolve() + # Create test files + (self.project_path / "main.py").write_text("print('hello')") + os.makedirs(self.project_path / "node_modules", exist_ok=True) + (self.project_path / "node_modules" / "pkg.js").write_text("module") + os.makedirs(self.project_path / "dist", exist_ok=True) + (self.project_path / "dist" / "bundle.js").write_text("bundled") + os.makedirs(self.project_path / "build", exist_ok=True) + (self.project_path / "build" / "output.js").write_text("compiled") + # Create .gitignore that ignores dist/ + (self.project_path / ".gitignore").write_text("dist/\n") + + def teardown_method(self) -> None: + shutil.rmtree(self.test_dir) + + def test_three_way_merge_global_project_and_gitignore(self) -> None: + """Global patterns, project patterns, and .gitignore patterns are all applied together.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=["build"], + ignore_all_files_in_gitignore=True, + ) + serena_config = SerenaConfig(ignored_paths=["node_modules"]).with_headless_mode_overrides() + project = Project( + project_root=str(self.project_path), + project_config=config, + serena_config=serena_config, + ) + # Global pattern: node_modules + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + # Project pattern: build + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + # Gitignore pattern: dist/ + assert project.is_ignored_path(str(self.project_path / "dist" / "bundle.js")) + # Non-ignored file + assert not project.is_ignored_path(str(self.project_path / "main.py")) + + +class TestSerenaConfigIgnoredPaths: + """Config loading with ignored_paths in serena_config.yml works correctly.""" + + def test_serena_config_default_ignored_paths(self) -> None: + """SerenaConfig defaults to empty ignored_paths.""" + config = SerenaConfig().with_headless_mode_overrides() + assert config.ignored_paths == [] + + def test_serena_config_with_ignored_paths(self) -> None: + """SerenaConfig can be created with explicit ignored_paths.""" + config = SerenaConfig( + ignored_paths=["node_modules", "*.log", "build"], + ).with_headless_mode_overrides() + assert config.ignored_paths == ["node_modules", "*.log", "build"] diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py new file mode 100644 index 0000000..e66ad32 --- /dev/null +++ b/test/serena/config/test_serena_config.py @@ -0,0 +1,657 @@ +import logging +import os +import shutil +import tempfile +from pathlib import Path + +import pytest + +from serena.agent import SerenaAgent +from serena.config.serena_config import ( + DEFAULT_PROJECT_SERENA_FOLDER_LOCATION, + LanguageBackend, + ProjectConfig, + RegisteredProject, + SerenaConfig, + SerenaConfigError, +) +from serena.constants import PROJECT_TEMPLATE_FILE, SERENA_MANAGED_DIR_NAME +from serena.project import MemoryManager, Project +from solidlsp.ls_config import Language +from test.conftest import create_default_serena_config + + +class TestProjectConfigAutogenerate: + """Test class for ProjectConfig autogeneration functionality.""" + + def setup_method(self): + """Set up test environment before each test method.""" + # Create a temporary directory for testing + self.test_dir = tempfile.mkdtemp() + self.serena_config = create_default_serena_config() + self.project_path = Path(self.test_dir) + + def teardown_method(self): + """Clean up test environment after each test method.""" + # Remove the temporary directory + shutil.rmtree(self.test_dir) + + def test_autogenerate_empty_directory(self): + """Test that autogenerate succeeds with empty languages list for an empty directory.""" + config = ProjectConfig.autogenerate(self.project_path, self.serena_config, save_to_disk=False) + + assert config.project_name == self.project_path.name + assert config.languages == [] + + def test_autogenerate_empty_directory_logs_warning(self, caplog): + """Test that autogenerate logs a warning when no language files are found.""" + with caplog.at_level(logging.WARNING): + ProjectConfig.autogenerate(self.project_path, self.serena_config, save_to_disk=False) + + assert any("No source files for supported language servers were found" in msg for msg in caplog.messages) + + def test_autogenerate_with_python_files(self): + """Test successful autogeneration with Python source files.""" + # Create a Python file + python_file = self.project_path / "main.py" + python_file.write_text("def hello():\n print('Hello, world!')\n") + + # Run autogenerate + config = ProjectConfig.autogenerate(self.project_path, self.serena_config, save_to_disk=False) + + # Verify the configuration + assert config.project_name == self.project_path.name + assert config.languages == [Language.PYTHON] + + def test_autogenerate_with_js_files(self): + """Test successful autogeneration with JavaScript source files.""" + # Create files for multiple languages + (self.project_path / "small.js").write_text("console.log('JS');") + + # Run autogenerate - should pick Python as dominant + config = ProjectConfig.autogenerate(self.project_path, self.serena_config, save_to_disk=False) + + assert config.languages == [Language.TYPESCRIPT] + + def test_autogenerate_with_multiple_languages(self): + """Test autogeneration picks dominant language when multiple are present.""" + # Create files for multiple languages + (self.project_path / "main.py").write_text("print('Python')") + (self.project_path / "util.py").write_text("def util(): pass") + (self.project_path / "small.js").write_text("console.log('JS');") + + # Run autogenerate - should pick Python as dominant + config = ProjectConfig.autogenerate(self.project_path, self.serena_config, save_to_disk=False) + + assert config.languages == [Language.PYTHON] + + def test_autogenerate_saves_to_disk(self): + """Test that autogenerate can save the configuration to disk.""" + # Create a Go file + go_file = self.project_path / "main.go" + go_file.write_text("package main\n\nfunc main() {}\n") + + # Run autogenerate with save_to_disk=True + config = ProjectConfig.autogenerate(self.project_path, self.serena_config, save_to_disk=True) + + # Verify the configuration file was created + config_path = self.project_path / ".serena" / "project.yml" + assert config_path.exists() + + # Verify the content + assert config.languages == [Language.GO] + + def test_autogenerate_nonexistent_path(self): + """Test that autogenerate raises FileNotFoundError for non-existent path.""" + non_existent = self.project_path / "does_not_exist" + + with pytest.raises(FileNotFoundError) as exc_info: + ProjectConfig.autogenerate(non_existent, self.serena_config, save_to_disk=False) + + assert "Project root not found" in str(exc_info.value) + + def test_autogenerate_with_gitignored_files_only(self): + """Test autogenerate creates a project with empty languages when only gitignored files exist.""" + # Create a .gitignore that ignores all Python files + gitignore = self.project_path / ".gitignore" + gitignore.write_text("*.py\n") + + # Create Python files that will be ignored + (self.project_path / "ignored.py").write_text("print('ignored')") + + # Should succeed with empty languages (gitignored files are not counted) + config = ProjectConfig.autogenerate(self.project_path, self.serena_config, save_to_disk=False) + + assert config.project_name == self.project_path.name + assert config.languages == [] + + def test_autogenerate_custom_project_name(self): + """Test autogenerate with custom project name.""" + # Create a TypeScript file + ts_file = self.project_path / "index.ts" + ts_file.write_text("const greeting: string = 'Hello';\n") + + # Run autogenerate with custom name + custom_name = "my-custom-project" + config = ProjectConfig.autogenerate(self.project_path, self.serena_config, project_name=custom_name, save_to_disk=False) + + assert config.project_name == custom_name + assert config.languages == [Language.TYPESCRIPT] + + +class TestProjectConfig: + def test_template_is_complete(self): + _, is_complete = ProjectConfig._load_yaml_dict(PROJECT_TEMPLATE_FILE) + assert is_complete, "Project template YAML is incomplete; all fields must be present (with descriptions)." + + +class TestProjectConfigLanguageBackend: + """Tests for the per-project language_backend field.""" + + def test_language_backend_defaults_to_none(self): + config = ProjectConfig( + project_name="test", + languages=[Language.PYTHON], + ) + assert config.language_backend is None + + def test_language_backend_can_be_set(self): + config = ProjectConfig( + project_name="test", + languages=[Language.PYTHON], + language_backend=LanguageBackend.JETBRAINS, + ) + assert config.language_backend == LanguageBackend.JETBRAINS + + def test_language_backend_roundtrips_through_yaml(self): + config = ProjectConfig( + project_name="test", + languages=[Language.PYTHON], + language_backend=LanguageBackend.JETBRAINS, + ) + d = config._to_yaml_dict() + assert d["language_backend"] == "JetBrains" + + def test_language_backend_none_roundtrips_through_yaml(self): + config = ProjectConfig( + project_name="test", + languages=[Language.PYTHON], + ) + d = config._to_yaml_dict() + assert d["language_backend"] is None + + def test_language_backend_parsed_from_dict(self): + """Test that _from_dict parses language_backend correctly.""" + template_path = PROJECT_TEMPLATE_FILE + data, _ = ProjectConfig._load_yaml_dict(template_path) + data["project_name"] = "test" + data["languages"] = ["python"] + data["language_backend"] = "JetBrains" + config = ProjectConfig._from_dict(data, local_override_keys=[]) + assert config.language_backend == LanguageBackend.JETBRAINS + + def test_language_backend_none_when_missing_from_dict(self): + """Test that _from_dict handles missing language_backend gracefully.""" + template_path = PROJECT_TEMPLATE_FILE + data, _ = ProjectConfig._load_yaml_dict(template_path) + data["project_name"] = "test" + data["languages"] = ["python"] + data.pop("language_backend", None) + config = ProjectConfig._from_dict(data, local_override_keys=[]) + assert config.language_backend is None + + +def _make_config_with_project( + project_name: str, + language_backend: LanguageBackend | None = None, + global_backend: LanguageBackend = LanguageBackend.LSP, +) -> tuple[SerenaConfig, str]: + """Create a SerenaConfig with a single registered project and return (config, project_name).""" + config = SerenaConfig( + log_level=logging.ERROR, + language_backend=global_backend, + ).with_headless_mode_overrides() + project = Project( + project_root=str(Path(__file__).parent.parent / "resources" / "repos" / "python" / "test_repo"), + project_config=ProjectConfig( + project_name=project_name, + languages=[Language.PYTHON], + language_backend=language_backend, + ), + serena_config=config, + ) + config.projects = [RegisteredProject.from_project_instance(project)] + return config, project_name + + +class TestEffectiveLanguageBackend: + """Tests for per-project language_backend override logic in SerenaAgent.""" + + def test_default_backend_is_global(self): + """When no project override, effective backend matches global config.""" + config, name = _make_config_with_project("test_proj", language_backend=None, global_backend=LanguageBackend.LSP) + agent = SerenaAgent(project=name, serena_config=config) + try: + assert agent.get_language_backend().is_lsp() + finally: + agent.on_shutdown(timeout=5) + + def test_project_overrides_global_backend(self): + """When startup project has language_backend set, it overrides the global.""" + config, name = _make_config_with_project( + "test_jetbrains", language_backend=LanguageBackend.JETBRAINS, global_backend=LanguageBackend.LSP + ) + agent = SerenaAgent(project=name, serena_config=config) + try: + assert agent.get_language_backend().is_jetbrains() + finally: + agent.on_shutdown(timeout=5) + + def test_no_project_uses_global_backend(self): + """When no startup project is provided, effective backend is the global one.""" + config = SerenaConfig( + log_level=logging.ERROR, + language_backend=LanguageBackend.LSP, + ).with_headless_mode_overrides() + agent = SerenaAgent(project=None, serena_config=config) + try: + assert agent.get_language_backend() == LanguageBackend.LSP + finally: + agent.on_shutdown(timeout=5) + + def test_activate_project_rejects_backend_mismatch(self): + """Post-init activation of a project with mismatched backend raises ValueError.""" + # Start with LSP backend + config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=LanguageBackend.LSP) + + # Add a second project that requires JetBrains + jb_project = Project( + project_root=str(Path(__file__).parent.parent / "resources" / "repos" / "java" / "test_repo"), + project_config=ProjectConfig( + project_name="jb_proj", + languages=[Language.JAVA], + language_backend=LanguageBackend.JETBRAINS, + ), + serena_config=config, + ) + config.projects.append(RegisteredProject.from_project_instance(jb_project)) + + agent = SerenaAgent(project=name, serena_config=config) + try: + with pytest.raises(ValueError, match="Cannot activate project"): + agent.activate_project_from_path_or_name("jb_proj") + finally: + agent.on_shutdown(timeout=5) + + def test_activate_project_allows_matching_backend(self): + """Post-init activation of a project with matching backend succeeds.""" + config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=LanguageBackend.LSP) + + # Add a second project that also uses LSP + lsp_project2 = Project( + project_root=str(Path(__file__).parent.parent / "resources" / "repos" / "python" / "test_repo"), + project_config=ProjectConfig( + project_name="lsp_proj2", + languages=[Language.PYTHON], + language_backend=LanguageBackend.LSP, + ), + serena_config=config, + ) + config.projects.append(RegisteredProject.from_project_instance(lsp_project2)) + + agent = SerenaAgent(project=name, serena_config=config) + try: + # Should not raise + agent.activate_project_from_path_or_name("lsp_proj2") + finally: + agent.on_shutdown(timeout=5) + + def test_activate_project_allows_none_backend(self): + """Post-init activation of a project with no backend override succeeds.""" + config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=LanguageBackend.LSP) + + # Add a second project with no backend override + proj2 = Project( + project_root=str(Path(__file__).parent.parent / "resources" / "repos" / "python" / "test_repo"), + project_config=ProjectConfig( + project_name="proj2", + languages=[Language.PYTHON], + language_backend=None, + ), + serena_config=config, + ) + config.projects.append(RegisteredProject.from_project_instance(proj2)) + + agent = SerenaAgent(project=name, serena_config=config) + try: + # Should not raise — None means "inherit session backend" + agent.activate_project_from_path_or_name("proj2") + finally: + agent.on_shutdown(timeout=5) + + +class TestGetConfiguredProjectSerenaFolder: + """Tests for SerenaConfig.get_configured_project_serena_folder (pure template resolution).""" + + def test_default_location(self): + config = SerenaConfig().with_headless_mode_overrides() + result = config.get_configured_project_serena_folder("/home/user/myproject") + assert result == os.path.abspath("/home/user/myproject/.serena") + + def test_custom_location_with_project_folder_name(self): + config = SerenaConfig( + project_serena_folder_location="/projects-metadata/$projectFolderName/.serena", + ).with_headless_mode_overrides() + result = config.get_configured_project_serena_folder("/home/user/myproject") + assert result == os.path.abspath("/projects-metadata/myproject/.serena") + + def test_custom_location_with_project_dir(self): + config = SerenaConfig( + project_serena_folder_location="$projectDir/.custom-serena", + ).with_headless_mode_overrides() + result = config.get_configured_project_serena_folder("/home/user/myproject") + assert result == os.path.abspath("/home/user/myproject/.custom-serena") + + def test_custom_location_with_both_placeholders(self): + config = SerenaConfig( + project_serena_folder_location="/data/$projectFolderName/$projectDir/.serena", + ).with_headless_mode_overrides() + result = config.get_configured_project_serena_folder("/home/user/proj") + assert result == os.path.abspath("/data/proj/home/user/proj/.serena") + + def test_default_field_value(self): + config = SerenaConfig().with_headless_mode_overrides() + assert config.project_serena_folder_location == DEFAULT_PROJECT_SERENA_FOLDER_LOCATION + + def test_rejects_unknown_placeholder(self): + config = SerenaConfig( + project_serena_folder_location="$projectDir/$unknownVar/.serena", + ).with_headless_mode_overrides() + with pytest.raises(SerenaConfigError, match=r"Unknown placeholder '\$unknownVar'"): + config.get_configured_project_serena_folder("/home/user/myproject") + + def test_rejects_typo_projectDirs(self): + """$projectDirs should not be silently treated as $projectDir + 's'.""" + config = SerenaConfig( + project_serena_folder_location="$projectDirs/.serena", + ).with_headless_mode_overrides() + with pytest.raises(SerenaConfigError, match=r"Unknown placeholder '\$projectDirs'"): + config.get_configured_project_serena_folder("/home/user/myproject") + + def test_rejects_typo_projectfoldername_lowercase(self): + config = SerenaConfig( + project_serena_folder_location="/data/$projectfoldername/.serena", + ).with_headless_mode_overrides() + with pytest.raises(SerenaConfigError, match=r"Unknown placeholder '\$projectfoldername'"): + config.get_configured_project_serena_folder("/home/user/myproject") + + def test_no_placeholders_is_valid(self): + config = SerenaConfig( + project_serena_folder_location="/fixed/path/.serena", + ).with_headless_mode_overrides() + result = config.get_configured_project_serena_folder("/home/user/myproject") + assert result == os.path.abspath("/fixed/path/.serena") + + def test_error_message_lists_supported_placeholders(self): + config = SerenaConfig( + project_serena_folder_location="$bogus/.serena", + ).with_headless_mode_overrides() + with pytest.raises(SerenaConfigError, match=r"\$projectDir.*\$projectFolderName|\$projectFolderName.*\$projectDir"): + config.get_configured_project_serena_folder("/home/user/myproject") + + +class TestProjectSerenaDataFolder: + """Tests for SerenaConfig.get_project_serena_folder fallback logic (via Project).""" + + def setup_method(self): + self.test_dir = tempfile.mkdtemp() + self.project_path = Path(self.test_dir) / "myproject" + self.project_path.mkdir() + (self.project_path / "main.py").write_text("print('hello')\n") + + def teardown_method(self): + shutil.rmtree(self.test_dir) + + def _make_project(self, serena_config: "SerenaConfig | None" = None) -> Project: + project_config = ProjectConfig( + project_name="myproject", + languages=[Language.PYTHON], + ) + project = Project( + project_root=str(self.project_path), + project_config=project_config, + serena_config=serena_config, + ) + project._ignore_spec_available.wait() + return project + + def test_default_config_creates_in_project_dir(self): + config = SerenaConfig().with_headless_mode_overrides() + project = self._make_project(config) + expected = os.path.abspath(str(self.project_path / SERENA_MANAGED_DIR_NAME)) + assert project.path_to_serena_data_folder() == expected + + def test_custom_location_creates_outside_project(self): + custom_base = Path(self.test_dir) / "metadata" + custom_base.mkdir() + config = SerenaConfig( + project_serena_folder_location=str(custom_base) + "/$projectFolderName/.serena", + ).with_headless_mode_overrides() + project = self._make_project(config) + expected = os.path.abspath(str(custom_base / "myproject" / ".serena")) + assert project.path_to_serena_data_folder() == expected + + def test_fallback_to_existing_project_dir(self): + """If config points to a non-existent path but .serena exists in the project root, use the existing one.""" + existing_serena = self.project_path / SERENA_MANAGED_DIR_NAME + existing_serena.mkdir() + config = SerenaConfig( + project_serena_folder_location="/nonexistent/path/$projectFolderName/.serena", + ).with_headless_mode_overrides() + project = self._make_project(config) + assert project.path_to_serena_data_folder() == str(existing_serena) + + def test_configured_path_takes_precedence_when_exists(self): + """If both config path and project root path exist, use the config path.""" + existing_serena = self.project_path / SERENA_MANAGED_DIR_NAME + existing_serena.mkdir() + + custom_base = Path(self.test_dir) / "metadata" + custom_serena = custom_base / "myproject" / ".serena" + custom_serena.mkdir(parents=True) + + config = SerenaConfig( + project_serena_folder_location=str(custom_base) + "/$projectFolderName/.serena", + ).with_headless_mode_overrides() + project = self._make_project(config) + assert project.path_to_serena_data_folder() == str(custom_serena) + + +class TestSerenaConfigFromConfigFileRobustness: + """Tests that ``SerenaConfig.from_config_file`` does not abort the whole + loader when a single registered project has a broken ``project.yml``. + """ + + def setup_method(self): + self.test_dir = Path(tempfile.mkdtemp()) + self.master_config_path = self.test_dir / "serena_config.yml" + + def teardown_method(self): + shutil.rmtree(self.test_dir) + + def _make_project_dir(self, name: str, project_yml_body: str) -> Path: + project_dir = self.test_dir / name + (project_dir / SERENA_MANAGED_DIR_NAME).mkdir(parents=True) + (project_dir / SERENA_MANAGED_DIR_NAME / "project.yml").write_text(project_yml_body) + return project_dir + + def _write_master_config(self, project_paths: list[Path]) -> None: + body_lines = ["projects:"] + for p in project_paths: + body_lines.append(f" - {p}") + self.master_config_path.write_text("\n".join(body_lines) + "\n") + + def test_empty_projects_key_is_treated_as_empty_list(self, monkeypatch): + """A bare ``projects:`` key should not abort config loading.""" + self.master_config_path.write_text("projects:\n") + + monkeypatch.setattr( + SerenaConfig, + "_determine_config_file_path", + classmethod(lambda cls: str(self.master_config_path)), + ) + + config = SerenaConfig.from_config_file(generate_if_missing=False) + + assert config.projects == [] + + def test_malformed_project_is_skipped_with_warning(self, caplog, monkeypatch): + """A malformed project.yml must not abort loading of the others.""" + good_project = self._make_project_dir( + "good_project", + 'project_name: "good_project"\nlanguages: ["python"]\n', + ) + # Invalid YAML: a stray colon at the start of a mapping value. + bad_project = self._make_project_dir( + "bad_project", + ": this is not : valid : yaml :\n", + ) + self._write_master_config([good_project, bad_project]) + + # SerenaPaths is a process-wide singleton, so we cannot reliably + # redirect it via SERENA_HOME after the fact. Instead, redirect the + # config-file-path resolver directly. + monkeypatch.setattr( + SerenaConfig, + "_determine_config_file_path", + classmethod(lambda cls: str(self.master_config_path)), + ) + + with caplog.at_level(logging.ERROR): + config = SerenaConfig.from_config_file(generate_if_missing=False) + + registered_roots = {Path(p.project_root).resolve() for p in config.projects} + assert registered_roots == {good_project.resolve()}, f"Expected only the good project to be registered, got {registered_roots}" + assert any("Failed to load project configuration" in msg and str(bad_project.resolve()) in msg for msg in caplog.messages), ( + f"Expected a warning naming {bad_project.resolve()}, got: {caplog.messages}" + ) + + +class TestGetRegisteredProjectWithDanglingProject: + """A registered project whose root directory was deleted (e.g. a removed git + worktree) must not break lookup/activation of other, valid projects. + + Reproduces the bug where ``get_registered_project`` iterates over every + registered project and ``RegisteredProject.matches_root_path`` raises + ``FileNotFoundError`` for the dangling project, aborting the whole lookup. + """ + + def setup_method(self): + self.test_dir = Path(tempfile.mkdtemp()) + + def teardown_method(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def _make_project_dir(self, name: str) -> Path: + project_dir = self.test_dir / name + (project_dir / SERENA_MANAGED_DIR_NAME).mkdir(parents=True) + (project_dir / SERENA_MANAGED_DIR_NAME / "project.yml").write_text(f'project_name: "{name}"\nlanguages: ["python"]\n') + return project_dir + + def test_dangling_project_does_not_break_lookup_of_valid_project(self): + config = create_default_serena_config() + dangling_dir = self._make_project_dir("dangling_project") + valid_dir = self._make_project_dir("valid_project") + + # Register the dangling project FIRST so the path-matching loop hits the + # deleted directory before reaching the valid project. + config.projects = [ + RegisteredProject.from_project_root(dangling_dir, serena_config=config), + RegisteredProject.from_project_root(valid_dir, serena_config=config), + ] + + # Delete the dangling project's directory out from under Serena. + shutil.rmtree(dangling_dir) + + # Looking up the valid project by path must succeed, not raise + # FileNotFoundError on the deleted dangling project's path. + result = config.get_registered_project(str(valid_dir)) + assert result is not None + assert result.project_name == "valid_project" + + +class TestMemoriesManagerCustomPath: + """Tests for MemoriesManager with a custom serena data folder.""" + + def setup_method(self): + self.test_dir = tempfile.mkdtemp() + self.data_folder = Path(self.test_dir) / "custom_serena" + + def teardown_method(self): + shutil.rmtree(self.test_dir) + + def test_memories_subdir_is_created(self): + assert not self.data_folder.exists() + MemoryManager(str(self.data_folder)) + assert (self.data_folder / "memories").exists() + + def test_save_and_load_memory(self): + manager = MemoryManager(str(self.data_folder)) + manager.save_memory("test_topic", "test content", is_tool_context=False) + content = manager.load_memory("test_topic") + assert content == "test content" + + def test_list_memories(self): + manager = MemoryManager(str(self.data_folder)) + manager.save_memory("topic_a", "content a", is_tool_context=False) + manager.save_memory("topic_b", "content b", is_tool_context=False) + memories = manager.list_project_memories() + assert sorted(memories.get_full_list()) == ["topic_a", "topic_b"] + + +class TestProjectConfigActivationCommand: + """Tests for the activation_command and activation_command_timeout fields.""" + + def _base_data(self) -> dict: + data, _ = ProjectConfig._load_yaml_dict(PROJECT_TEMPLATE_FILE) + data["project_name"] = "test" + data["languages"] = ["python"] + return data + + def test_activation_command_defaults_to_none(self): + config = ProjectConfig(project_name="test", languages=[Language.PYTHON]) + assert config.activation_command is None + + def test_activation_command_timeout_default(self): + config = ProjectConfig(project_name="test", languages=[Language.PYTHON]) + assert config.activation_command_timeout == 180.0 + + def test_activation_command_parsed_from_dict(self): + data = self._base_data() + data["activation_command"] = "npx nx run-many -t build" + data["activation_command_timeout"] = 300 + config = ProjectConfig._from_dict(data, local_override_keys=[]) + assert config.activation_command == "npx nx run-many -t build" + assert config.activation_command_timeout == 300.0 + + def test_activation_command_defaults_when_absent(self): + data = self._base_data() + data.pop("activation_command", None) + data.pop("activation_command_timeout", None) + config = ProjectConfig._from_dict(data, local_override_keys=[]) + assert config.activation_command is None + assert config.activation_command_timeout == 180.0 + + def test_activation_command_timeout_zero_raises(self): + data = self._base_data() + data["activation_command"] = "echo hi" + data["activation_command_timeout"] = 0 + with pytest.raises(ValueError, match="activation_command_timeout must be positive"): + ProjectConfig._from_dict(data, local_override_keys=[]) + + def test_activation_command_timeout_negative_raises(self): + data = self._base_data() + data["activation_command"] = "echo hi" + data["activation_command_timeout"] = -10 + with pytest.raises(ValueError, match="activation_command_timeout must be positive"): + ProjectConfig._from_dict(data, local_override_keys=[]) diff --git a/test/serena/test_activation_command.py b/test/serena/test_activation_command.py new file mode 100644 index 0000000..39bca27 --- /dev/null +++ b/test/serena/test_activation_command.py @@ -0,0 +1,100 @@ +"""Tests for SerenaAgent._run_activation_command.""" + +import logging +import sys +from pathlib import Path + +import pytest + +from serena.config.serena_config import ProjectConfig, SerenaConfig +from serena.project import Project +from solidlsp.ls_config import Language + + +def _make_project( + project_root: Path, + activation_command: str | None = None, + activation_command_timeout: float = 180.0, + trusted: bool = True, +) -> Project: + """Create a minimal Project pointing at project_root with the given activation settings.""" + serena_config = SerenaConfig( + gui_log_window=False, + web_dashboard=False, + trusted_project_path_patterns=["**"] if trusted else [], + ) + project_config = ProjectConfig( + project_name="test-activation", + languages=[Language.PYTHON], + activation_command=activation_command, + activation_command_timeout=activation_command_timeout, + ) + return Project( + project_root=str(project_root), + project_config=project_config, + serena_config=serena_config, + ) + + +class TestRunActivationCommand: + """Unit tests for SerenaAgent._run_activation_command.""" + + def _make_agent(self, project: Project): + from serena.agent import SerenaAgent + + agent = SerenaAgent.__new__(SerenaAgent) + # wire up only what _run_activation_command needs + agent.serena_config = project.serena_config + return agent + + def test_no_command_does_nothing(self, tmp_path: Path, caplog): + project = _make_project(tmp_path, activation_command=None) + agent = self._make_agent(project) + with caplog.at_level(logging.INFO): + agent._run_project_activation_command(project) + assert "activation_command" not in caplog.text + + def test_trusted_command_runs_in_project_root(self, tmp_path: Path): + sentinel = tmp_path / "sentinel.txt" + # write a sentinel file from within the command to confirm cwd is project_root + if sys.platform == "win32": + cmd = "type nul > sentinel.txt" + else: + cmd = "touch sentinel.txt" + project = _make_project(tmp_path, activation_command=cmd, trusted=True) + agent = self._make_agent(project) + agent._run_project_activation_command(project) + assert sentinel.exists(), "Command did not run in project root (sentinel file missing)" + + def test_untrusted_project_skips_command(self, tmp_path: Path, caplog): + sentinel = tmp_path / "sentinel.txt" + cmd = "touch sentinel.txt" if sys.platform != "win32" else "type nul > sentinel.txt" + project = _make_project(tmp_path, activation_command=cmd, trusted=False) + agent = self._make_agent(project) + with caplog.at_level(logging.WARNING): + agent._run_project_activation_command(project) + assert not sentinel.exists(), "Command must not run for untrusted project" + assert "not trusted" in caplog.text + + def test_failing_command_logs_error_and_continues(self, tmp_path: Path, caplog): + cmd = "exit 1" if sys.platform != "win32" else "cmd /c exit 1" + project = _make_project(tmp_path, activation_command=cmd, trusted=True) + agent = self._make_agent(project) + with caplog.at_level(logging.ERROR): + agent._run_project_activation_command(project) # must not raise + assert any("failed" in r.message.lower() for r in caplog.records if r.levelno == logging.ERROR) + + @pytest.mark.slow + def test_timeout_kills_process_and_continues(self, tmp_path: Path, caplog): + """A hanging command must be killed and activation must continue.""" + cmd = 'python3 -c "import time; time.sleep(60)"' + project = _make_project( + tmp_path, + activation_command=cmd, + activation_command_timeout=1.0, # 1s — fast for the test + trusted=True, + ) + agent = self._make_agent(project) + with caplog.at_level(logging.ERROR): + agent._run_project_activation_command(project) # must not hang or raise + assert any("timed out" in r.message.lower() for r in caplog.records if r.levelno == logging.ERROR) diff --git a/test/serena/test_cli_project_commands.py b/test/serena/test_cli_project_commands.py new file mode 100644 index 0000000..ff6fd7e --- /dev/null +++ b/test/serena/test_cli_project_commands.py @@ -0,0 +1,375 @@ +"""Tests for CLI project commands (create, index).""" + +import os +import shutil +import tempfile +import time +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from serena.cli import ProjectCommands, TopLevelCommands, find_project_root +from serena.config.serena_config import ProjectConfig + +pytestmark = pytest.mark.filterwarnings("ignore::UserWarning") + + +@pytest.fixture +def temp_project_dir(): + """Create a temporary directory for testing.""" + tmpdir = tempfile.mkdtemp() + try: + yield tmpdir + finally: + # if windows, wait a bit to avoid PermissionError on cleanup + if os.name == "nt": + time.sleep(0.2) + shutil.rmtree(tmpdir, ignore_errors=True) + + +@pytest.fixture +def temp_project_dir_with_python_file(): + """Create a temporary directory with a Python file for testing.""" + tmpdir = tempfile.mkdtemp() + try: + # Create a simple Python file so language detection works + py_file = os.path.join(tmpdir, "test.py") + with open(py_file, "w") as f: + f.write("def hello():\n pass\n") + yield tmpdir + finally: + # if windows, wait a bit to avoid PermissionError on cleanup + if os.name == "nt": + time.sleep(0.2) + shutil.rmtree(tmpdir, ignore_errors=True) + + +@pytest.fixture +def cli_runner(): + """Create a CliRunner for testing Click commands.""" + return CliRunner() + + +class TestProjectCreate: + """Tests for 'project create' command.""" + + def test_create_basic_with_language(self, cli_runner, temp_project_dir): + """Test basic project creation with explicit language.""" + result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"]) + assert result.exit_code == 0, f"Command failed: {result.output}" + assert "Generated project" in result.output + assert "python" in result.output.lower() + + # Verify project.yml was created + yml_path = os.path.join(temp_project_dir, ".serena", "project.yml") + assert os.path.exists(yml_path), f"project.yml not found at {yml_path}" + + def test_create_auto_detect_language(self, cli_runner, temp_project_dir_with_python_file): + """Test project creation with auto-detected language.""" + result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir_with_python_file]) + assert result.exit_code == 0, f"Command failed: {result.output}" + assert "Generated project" in result.output + assert "python" in result.output.lower() + + # Verify project.yml was created + yml_path = os.path.join(temp_project_dir_with_python_file, ".serena", "project.yml") + assert os.path.exists(yml_path) + + def test_create_with_name(self, cli_runner, temp_project_dir): + """Test project creation with custom name and explicit language.""" + result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--name", "my-custom-project", "--language", "python"]) + assert result.exit_code == 0, f"Command failed: {result.output}" + assert "Generated project" in result.output + + # Verify project.yml was created + yml_path = os.path.join(temp_project_dir, ".serena", "project.yml") + assert os.path.exists(yml_path) + + def test_create_with_language(self, cli_runner, temp_project_dir): + """Test project creation with specified language.""" + result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"]) + assert result.exit_code == 0, f"Command failed: {result.output}" + assert "Generated project" in result.output + assert "python" in result.output.lower() + + def test_create_with_multiple_languages(self, cli_runner, temp_project_dir): + """Test project creation with multiple languages.""" + result = cli_runner.invoke( + ProjectCommands.create, + [temp_project_dir, "--language", "python", "--language", "typescript"], + ) + assert result.exit_code == 0, f"Command failed: {result.output}" + assert "Generated project" in result.output + + def test_create_with_invalid_language(self, cli_runner, temp_project_dir): + """Test project creation with invalid language raises error.""" + result = cli_runner.invoke( + ProjectCommands.create, + [temp_project_dir, "--language", "invalid-lang"], + ) + assert result.exit_code != 0, "Should fail with invalid language" + assert "Unknown language" in result.output or "invalid-lang" in result.output + + def test_create_already_exists(self, cli_runner, temp_project_dir): + """Test that creating a project twice fails gracefully.""" + # Create once with explicit language + result1 = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"]) + assert result1.exit_code == 0 + + # Try to create again - should fail gracefully + result2 = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"]) + assert result2.exit_code != 0, "Should fail when project.yml already exists" + assert "already exists" in result2.output.lower() + assert "Error:" in result2.output # Should be user-friendly error + + def test_create_with_index_flag(self, cli_runner, temp_project_dir_with_python_file): + """Test project creation with --index flag performs indexing.""" + result = cli_runner.invoke( + ProjectCommands.create, + [temp_project_dir_with_python_file, "--language", "python", "--index", "--log-level", "ERROR", "--timeout", "5"], + ) + assert result.exit_code == 0, f"Command failed: {result.output}" + assert "Generated project" in result.output + assert "Indexing project" in result.output + + # Verify project.yml was created + yml_path = os.path.join(temp_project_dir_with_python_file, ".serena", "project.yml") + assert os.path.exists(yml_path) + + # Verify cache directory was created (proof of indexing) + cache_dir = os.path.join(temp_project_dir_with_python_file, ".serena", "cache") + assert os.path.exists(cache_dir), "Cache directory should exist after indexing" + + def test_create_without_index_flag(self, cli_runner, temp_project_dir): + """Test that project creation without --index does NOT perform indexing.""" + result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"]) + assert result.exit_code == 0 + assert "Generated project" in result.output + assert "Indexing" not in result.output + + # Verify cache directory was NOT created + cache_dir = os.path.join(temp_project_dir, ".serena", "cache") + assert not os.path.exists(cache_dir), "Cache directory should not exist without --index" + + +class TestProjectIndex: + """Tests for 'project index' command.""" + + def test_index_auto_creates_project_with_files(self, cli_runner, temp_project_dir_with_python_file): + """Test that index command auto-creates project.yml if it doesn't exist (with source files).""" + result = cli_runner.invoke(ProjectCommands.index, [temp_project_dir_with_python_file, "--log-level", "ERROR", "--timeout", "5"]) + # Should succeed and perform indexing + assert result.exit_code == 0, f"Command failed: {result.output}" + assert "Auto-creating" in result.output or "Indexing" in result.output + + # Verify project.yml was auto-created + yml_path = os.path.join(temp_project_dir_with_python_file, ".serena", "project.yml") + assert os.path.exists(yml_path), "project.yml should be auto-created" + + def test_index_with_explicit_language(self, cli_runner, temp_project_dir): + """Test index with explicit --language for empty directory.""" + result = cli_runner.invoke( + ProjectCommands.index, + [temp_project_dir, "--language", "python", "--log-level", "ERROR", "--timeout", "5"], + ) + # Should succeed even without source files if language is explicit + assert result.exit_code == 0, f"Command failed: {result.output}" + + yml_path = os.path.join(temp_project_dir, ".serena", "project.yml") + assert os.path.exists(yml_path) + + def test_index_with_language_auto_creates(self, cli_runner, temp_project_dir): + """Test index with --language option for auto-creation.""" + result = cli_runner.invoke( + ProjectCommands.index, + [temp_project_dir, "--language", "python", "--log-level", "ERROR"], + ) + assert result.exit_code == 0 or "Indexing" in result.output + + yml_path = os.path.join(temp_project_dir, ".serena", "project.yml") + assert os.path.exists(yml_path) + + def test_index_is_equivalent_to_create_with_index(self, cli_runner, temp_project_dir_with_python_file): + """Test that 'index' behaves like 'create --index' for new projects.""" + # Use manual temp directory creation with Windows-safe cleanup + # to avoid PermissionError on Windows CI when language servers hold file locks + dir1 = tempfile.mkdtemp() + dir2 = tempfile.mkdtemp() + + try: + # Setup both directories with same file + for d in [dir1, dir2]: + with open(os.path.join(d, "test.py"), "w") as f: + f.write("def hello():\n pass\n") + + # Run 'create --index' on dir1 + result1 = cli_runner.invoke( + ProjectCommands.create, [dir1, "--language", "python", "--index", "--log-level", "ERROR", "--timeout", "5"] + ) + + # Run 'index' on dir2 + result2 = cli_runner.invoke(ProjectCommands.index, [dir2, "--language", "python", "--log-level", "ERROR", "--timeout", "5"]) + + # Both should succeed + assert result1.exit_code == 0, f"create --index failed: {result1.output}" + assert result2.exit_code == 0, f"index failed: {result2.output}" + + # Both should create project.yml + assert os.path.exists(os.path.join(dir1, ".serena", "project.yml")) + assert os.path.exists(os.path.join(dir2, ".serena", "project.yml")) + + # Both should create cache (proof of indexing) + assert os.path.exists(os.path.join(dir1, ".serena", "cache")) + assert os.path.exists(os.path.join(dir2, ".serena", "cache")) + finally: + # Windows-safe cleanup: wait for file handles to be released + if os.name == "nt": + time.sleep(0.2) + # Use ignore_errors to handle lingering file locks on Windows + shutil.rmtree(dir1, ignore_errors=True) + shutil.rmtree(dir2, ignore_errors=True) + + +class TestProjectCreateHelper: + """Tests for _create_project helper method.""" + + def test_create_project_helper_returns_config(self, temp_project_dir): + """Test that _create_project returns a ProjectConfig with explicit language.""" + config = ProjectCommands._create_project(temp_project_dir, "test-project", ("python",)).project_config + assert isinstance(config, ProjectConfig) + assert config.project_name == "test-project" + + def test_create_project_helper_with_auto_detect(self, temp_project_dir_with_python_file): + """Test _create_project with auto-detected language.""" + config = ProjectCommands._create_project(temp_project_dir_with_python_file, "my-project", ()).project_config + assert isinstance(config, ProjectConfig) + assert config.project_name == "my-project" + assert len(config.languages) >= 1 + + def test_create_project_helper_with_languages(self, temp_project_dir): + """Test _create_project with language specification.""" + config = ProjectCommands._create_project(temp_project_dir, None, ("python", "typescript")).project_config + assert isinstance(config, ProjectConfig) + assert len(config.languages) >= 1 + + def test_create_project_helper_file_exists_error(self, temp_project_dir): + """Test _create_project raises error if project.yml exists.""" + # Create project first with explicit language + ProjectCommands._create_project(temp_project_dir, None, ("python",)) + + # Try to create again - should raise FileExistsError + with pytest.raises(FileExistsError): + ProjectCommands._create_project(temp_project_dir, None, ("python",)) + + +class TestFindProjectRoot: + """Tests for find_project_root helper with virtual chroot boundary.""" + + def test_finds_serena_from_subdirectory(self, temp_project_dir): + """Test that .serena/project.yml is found when searching from a subdirectory.""" + serena_dir = os.path.join(temp_project_dir, ".serena") + os.makedirs(serena_dir) + Path(os.path.join(serena_dir, "project.yml")).touch() + subdir = os.path.join(temp_project_dir, "src", "nested") + os.makedirs(subdir) + + original_cwd = os.getcwd() + try: + os.chdir(subdir) + result = find_project_root(root=temp_project_dir) + assert result is not None + assert os.path.samefile(result, temp_project_dir) + finally: + os.chdir(original_cwd) + + def test_serena_preferred_over_git(self, temp_project_dir): + """Test that .serena/project.yml takes priority over .git at the same level.""" + serena_dir = os.path.join(temp_project_dir, ".serena") + os.makedirs(serena_dir) + Path(os.path.join(serena_dir, "project.yml")).touch() + os.makedirs(os.path.join(temp_project_dir, ".git")) + + original_cwd = os.getcwd() + try: + os.chdir(temp_project_dir) + result = find_project_root(root=temp_project_dir) + assert result is not None + assert os.path.isdir(os.path.join(result, ".serena")) + assert os.path.samefile(result, temp_project_dir) + finally: + os.chdir(original_cwd) + + def test_git_used_as_fallback(self, temp_project_dir): + """Test that .git is found when no .serena exists.""" + os.makedirs(os.path.join(temp_project_dir, ".git")) + subdir = os.path.join(temp_project_dir, "src") + os.makedirs(subdir) + + original_cwd = os.getcwd() + try: + os.chdir(subdir) + result = find_project_root(root=temp_project_dir) + assert result is not None + assert os.path.samefile(result, temp_project_dir) + finally: + os.chdir(original_cwd) + + def test_falls_back_to_none_when_no_markers(self, temp_project_dir): + """Test falls back to None when no markers exist within boundary.""" + subdir = os.path.join(temp_project_dir, "src") + os.makedirs(subdir) + + original_cwd = os.getcwd() + try: + os.chdir(subdir) + result = find_project_root(root=temp_project_dir) + assert result is None + finally: + os.chdir(original_cwd) + + def test_git_worktree_not_hijacked_by_ancestor_serena(self, temp_project_dir): + """A git worktree nested under a Serena project must resolve to the worktree. + + Regression test: when a git worktree (whose .git is a pointer *file*) lives + below a directory that is an explicit Serena project (.serena/project.yml), + the worktree's own .git boundary must win over the ancestor's project marker. + The old two-pass search returned the ancestor Serena project, causing reads + and edits to land in the wrong working tree. + """ + # Ancestor directory is an explicit Serena project. + serena_dir = os.path.join(temp_project_dir, ".serena") + os.makedirs(serena_dir) + Path(os.path.join(serena_dir, "project.yml")).touch() + # Nested git worktree: .git is a gitdir pointer file, as created by `git worktree add`. + worktree = os.path.join(temp_project_dir, "nested", "worktree") + os.makedirs(worktree) + Path(os.path.join(worktree, ".git")).write_text("gitdir: /repo/.git/worktrees/wt\n") + + original_cwd = os.getcwd() + try: + os.chdir(worktree) + result = find_project_root(root=temp_project_dir) + assert result is not None + assert os.path.samefile(result, worktree) + finally: + os.chdir(original_cwd) + + +class TestProjectFromCwdMutualExclusivity: + """Tests for --project-from-cwd mutual exclusivity.""" + + def test_project_from_cwd_with_project_flag_fails(self, cli_runner): + """Test that --project-from-cwd with --project raises error.""" + result = cli_runner.invoke( + TopLevelCommands.start_mcp_server, + ["--project-from-cwd", "--project", "/some/path"], + ) + assert result.exit_code != 0 + assert "cannot be used with" in result.output + + +if __name__ == "__main__": + # For manual testing, you can run this file directly: + # uv run pytest test/serena/test_cli_project_commands.py -v + pytest.main([__file__, "-v"]) diff --git a/test/serena/test_dashboard.py b/test/serena/test_dashboard.py new file mode 100644 index 0000000..a4355e8 --- /dev/null +++ b/test/serena/test_dashboard.py @@ -0,0 +1,53 @@ +from collections.abc import Callable +from types import SimpleNamespace + +from serena.dashboard import SerenaDashboardAPI +from solidlsp.ls_config import Language + + +class _DummyMemoryLogHandler: + def get_log_messages(self, from_idx: int = 0): # pragma: no cover - simple stub + return SimpleNamespace(messages=[], max_idx=-1) + + def clear_log_messages(self) -> None: # pragma: no cover - simple stub + pass + + +class _DummyAgent: + def __init__(self, project: SimpleNamespace | None) -> None: + self._project = project + + def register_config_changed_callback(self, callback: Callable[[], None]) -> None: + pass + + def execute_task(self, func, *, logged: bool | None = None, name: str | None = None): + del logged, name + return func() + + def get_active_project(self): + return self._project + + +def _make_dashboard(project_languages: list[Language] | None) -> SerenaDashboardAPI: + project = None + if project_languages is not None: + project = SimpleNamespace(project_config=SimpleNamespace(languages=project_languages)) + agent = _DummyAgent(project) + return SerenaDashboardAPI(memory_log_handler=_DummyMemoryLogHandler(), tool_names=[], agent=agent, tool_usage_stats=None) + + +def test_available_languages_include_experimental_when_no_active_project(): + dashboard = _make_dashboard(project_languages=None) + response = dashboard._get_available_languages() + expected = sorted(lang.value for lang in Language.iter_all(include_experimental=True)) + assert response.languages == expected + + +def test_available_languages_exclude_project_languages(): + dashboard = _make_dashboard(project_languages=[Language.PYTHON, Language.MARKDOWN]) + response = dashboard._get_available_languages() + available = set(response.languages) + assert Language.PYTHON.value not in available + assert Language.MARKDOWN.value not in available + # ensure experimental languages remain available for selection + assert Language.ANSIBLE.value in available diff --git a/test/serena/test_edit_marker.py b/test/serena/test_edit_marker.py new file mode 100644 index 0000000..ce1c558 --- /dev/null +++ b/test/serena/test_edit_marker.py @@ -0,0 +1,13 @@ +from serena.tools import CreateTextFileTool, ReadFileTool, Tool + + +class TestEditMarker: + def test_tool_can_edit_method(self): + """Test that Tool.can_edit() method works correctly""" + # Non-editing tool should return False + assert issubclass(ReadFileTool, Tool) + assert not ReadFileTool.can_edit() + + # Editing tool should return True + assert issubclass(CreateTextFileTool, Tool) + assert CreateTextFileTool.can_edit() diff --git a/test/serena/test_hooks.py b/test/serena/test_hooks.py new file mode 100644 index 0000000..d728767 --- /dev/null +++ b/test/serena/test_hooks.py @@ -0,0 +1,777 @@ +import json +import pickle +from datetime import datetime, timedelta +from io import StringIO +from pathlib import Path +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from serena.hooks import ( + HookClient, + PreToolUseAutoApproveSerenaHook, + PreToolUseRemindAboutSymbolicToolsHook, + SessionEndCleanupHook, + hook_commands, +) + +ToolUseCounter = PreToolUseRemindAboutSymbolicToolsHook.ToolUseCounter + + +def _make_stdin(data: dict) -> StringIO: + return StringIO(json.dumps(data)) + + +def _base_input( + tool_name: str = "grep_search", + session_id: str = "test-session-123", + tool_input: dict | None = None, +) -> dict: + return { + "session_id": session_id, + "tool_name": tool_name, + "tool_input": tool_input if tool_input is not None else {"query": "foo"}, + } + + +def _read_input(tool_name: str = "read", session_id: str = "test-session-123", file_path: str = "src/foo.py") -> dict: + """Build a hook payload for a read-style tool call against a file.""" + return { + "session_id": session_id, + "tool_name": tool_name, + "tool_input": {"file_path": file_path}, + } + + +class TestHookClientDetection: + """Tests for the --client option propagation.""" + + def test_claude_code_client(self, tmp_path: Path): + stdin_data = _base_input() + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE) + assert hook._client == HookClient.CLAUDE_CODE + + def test_vscode_client(self, tmp_path: Path): + stdin_data = _base_input() + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.VSCODE) + assert hook._client == HookClient.VSCODE + + +class TestPreToolUseRemindAboutSerenaHook: + """Tests for the PreToolUse hook that nudges the agent toward symbolic tools.""" + + def test_missing_tool_name_raises(self, tmp_path: Path): + stdin_data = {"session_id": "s1"} + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + with pytest.raises(ValueError, match="Tool name is required"): + PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE) + + def test_missing_session_id_raises(self, tmp_path: Path): + stdin_data = {"tool_name": "grep"} + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + with pytest.raises(ValueError, match="Session ID is required"): + PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE) + + def test_grep_tool_detection_claude_code(self, tmp_path: Path): + """Claude Code uses the exact tool name ``Grep`` (lowercased to ``grep``).""" + for name, expected in [("grep", True), ("grep_search", False), ("mcp_grep", False), ("read", False)]: + with patch("sys.stdin", _make_stdin(_base_input(tool_name=name))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE) + assert hook.is_grep_call() == expected, f"is_grep_tool() wrong for {name} (claude-code)" + + def test_grep_tool_detection_non_claude_code(self, tmp_path: Path): + """Non-Claude-Code clients fall back to substring matching to cover verbose tool names.""" + for name, expected in [("grep_search", True), ("mcp_grep", True), ("read_file", False), ("serena_find", False)]: + with patch("sys.stdin", _make_stdin(_base_input(tool_name=name))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.VSCODE) + assert hook.is_grep_call() == expected, f"is_grep_tool() wrong for {name} (vscode)" + + def test_grep_tool_detection_codex_shell_commands(self, tmp_path: Path): + """Codex shell-command tools are classified by the command embedded in their payload.""" + cases = [ + ("exec_command", {"cmd": "rg -n foo README.md"}, True), + ("functions.shell_command", {"command": "rg -n foo README.md"}, True), + ("functions.shell_command", {"command": "Get-Content README.md"}, False), + ] + for tool_name, tool_input, expected in cases: + with ( + patch("sys.stdin", _make_stdin(_base_input(tool_name=tool_name, tool_input=tool_input))), + patch("serena.hooks.serena_home_dir", str(tmp_path)), + ): + hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.CODEX) + assert hook.is_grep_call() == expected, f"is_grep_tool() wrong for {tool_name} / {tool_input}" + + def test_read_file_tool_detection_claude_code(self, tmp_path: Path): + """Claude Code uses the exact tool name ``Read`` (lowercased to ``read``).""" + for name, expected in [("read", True), ("mcp__serena__read_file", True), ("grep", False), ("serena_search_for_pattern", False)]: + with ( + patch("sys.stdin", _make_stdin(_read_input(tool_name=name))), + patch("serena.hooks.serena_home_dir", str(tmp_path)), + ): + hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE) + assert hook.is_read_file_call() == expected, f"is_read_file_tool() wrong for {name} (claude-code)" + + def test_read_file_tool_detection_non_claude_code(self, tmp_path: Path): + """Non-Claude-Code clients accept any read-style verb (``read``/``view``/``open``/``show``) + combined with ``file``. + """ + cases = [ + # canonical names + ("read_file", True), + ("readFile", True), + # alternative read verbs used by other agents/editors + ("view_file", True), + ("open_file", True), + ("show_file", True), + # negatives: no "file", or "file" without a read verb, or modifying verbs + ("grep_search", False), + ("file_writer", False), + ("write_file", False), + ("edit_file", False), + ] + for name, expected in cases: + with ( + patch("sys.stdin", _make_stdin(_read_input(tool_name=name))), + patch("serena.hooks.serena_home_dir", str(tmp_path)), + ): + hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.VSCODE) + assert hook.is_read_file_call() == expected, f"is_read_file_tool() wrong for {name} (vscode)" + + def test_read_non_code_file_counts_as_file_read(self, tmp_path: Path): + """A ``Read`` call against a non-source file still counts as a file read.""" + payload = _read_input(tool_name="read", file_path="notes/todo.txt") + with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE) + assert hook.is_read_call() is True + assert hook.is_read_file_call() is True + assert hook.is_read_code_file_call() is False + + def test_read_code_file_detection(self, tmp_path: Path): + """Only source-like read targets count for the code-read reminder.""" + cases = [ + (HookClient.CLAUDE_CODE, _read_input(tool_name="read", file_path="README.md"), False), + (HookClient.CLAUDE_CODE, _read_input(tool_name="read", file_path="src/foo.py"), True), + (HookClient.CODEX, _base_input("functions.shell_command", tool_input={"command": "Get-Content README.md"}), False), + (HookClient.CODEX, _base_input("functions.shell_command", tool_input={"command": "Get-Content src/foo.py"}), True), + ] + for client, payload, expected in cases: + with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + hook = PreToolUseRemindAboutSymbolicToolsHook(client) + assert hook.is_read_code_file_call() == expected, f"is_read_code_file_call() wrong for {client} / {payload}" + + def test_read_file_tool_detection_codex_shell_commands(self, tmp_path: Path): + """Codex shell-command tools count PowerShell file reads, including non-code files.""" + cases = [ + ("exec_command", {"cmd": "cat README.md"}, True), + ("functions.shell_command", {"command": "Get-Content README.md"}, True), + ("functions.shell_command", {"command": "rg -n foo README.md"}, False), + ] + for tool_name, tool_input, expected in cases: + with ( + patch("sys.stdin", _make_stdin(_base_input(tool_name=tool_name, tool_input=tool_input))), + patch("serena.hooks.serena_home_dir", str(tmp_path)), + ): + hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.CODEX) + assert hook.is_read_file_call() == expected, f"is_read_file_tool() wrong for {tool_name} / {tool_input}" + + def test_serena_tool_detection(self, tmp_path: Path): + for name, expected in [("mcp_serena_find_symbol", True), ("serena_overview", True), ("grep_search", False)]: + with patch("sys.stdin", _make_stdin(_base_input(tool_name=name))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE) + assert hook.is_serena_symbolic_tool() == expected, f"is_serena_tool() wrong for {name}" + + def test_no_output_below_threshold(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """Below the threshold, the hook should produce no output (tool is allowed).""" + for _ in range(ToolUseCounter._GREP_USES_THRESHOLD - 1): + with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute() + assert capsys.readouterr().out == "" + + def test_deny_output_after_threshold_greps_claude_code(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """After reaching the grep threshold, the hook should output a deny.""" + for _ in range(ToolUseCounter._GREP_USES_THRESHOLD): + with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute() + + output = capsys.readouterr().out.strip() + result = json.loads(output) + hook_output = result["hookSpecificOutput"] + assert hook_output["permissionDecision"] == "deny" + assert "grep" in hook_output["additionalContext"].lower() + + def test_deny_output_after_threshold_greps_vscode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """After reaching the grep threshold, the hook should output a deny for VS Code.""" + for _ in range(ToolUseCounter._GREP_USES_THRESHOLD): + with patch("sys.stdin", _make_stdin(_base_input("grep_search"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseRemindAboutSymbolicToolsHook(HookClient.VSCODE).execute() + + output = capsys.readouterr().out.strip() + result = json.loads(output) + hook_output = result["hookSpecificOutput"] + assert hook_output["permissionDecision"] == "deny" + assert "grep" in hook_output["additionalContext"].lower() + + def test_deny_output_after_threshold_greps_codex_shell_command(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """After reaching the grep threshold, Codex ``functions.shell_command`` emits a deny.""" + payload = _base_input("functions.shell_command", tool_input={"command": "rg -n foo README.md"}) + for _ in range(ToolUseCounter._GREP_USES_THRESHOLD): + with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseRemindAboutSymbolicToolsHook(HookClient.CODEX).execute() + + output = capsys.readouterr().out.strip() + result = json.loads(output) + hook_output = result["hookSpecificOutput"] + assert hook_output["permissionDecision"] == "deny" + assert "additionalContext" not in hook_output + assert "grep" in hook_output["permissionDecisionReason"].lower() + + def test_deny_output_after_threshold_reads(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """After reaching the read file threshold, the hook should output a deny. + + ``_read_input`` supplies the direct ``file_path`` field emitted by file-read tools. + """ + for _ in range(ToolUseCounter._READ_FILE_USES_THRESHOLD): + with patch("sys.stdin", _make_stdin(_read_input("read"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute() + + output = capsys.readouterr().out.strip() + result = json.loads(output) + hook_output = result["hookSpecificOutput"] + assert hook_output["permissionDecision"] == "deny" + assert "read" in hook_output["additionalContext"].lower() + assert "files" in hook_output["additionalContext"].lower() + + def test_no_deny_after_threshold_get_content_markdown_codex(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """PowerShell ``Get-Content`` calls on markdown files do not count for the code-read reminder.""" + payload = _base_input("functions.shell_command", tool_input={"command": "Get-Content README.md"}) + for _ in range(ToolUseCounter._READ_FILE_USES_THRESHOLD): + with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseRemindAboutSymbolicToolsHook(HookClient.CODEX).execute() + + assert capsys.readouterr().out == "" + + def test_deny_output_after_threshold_get_content_code_file_codex(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """PowerShell ``Get-Content`` calls on source files count for the code-read reminder.""" + payload = _base_input("functions.shell_command", tool_input={"command": "Get-Content src/foo.py"}) + for _ in range(ToolUseCounter._READ_FILE_USES_THRESHOLD): + with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseRemindAboutSymbolicToolsHook(HookClient.CODEX).execute() + + result = json.loads(capsys.readouterr().out.strip()) + hook_output = result["hookSpecificOutput"] + assert hook_output["permissionDecision"] == "deny" + assert "additionalContext" not in hook_output + assert "read" in hook_output["permissionDecisionReason"].lower() + + def test_serena_tool_resets_counters(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """Using a Serena tool should reset counters, so the threshold is not reached.""" + for _ in range(ToolUseCounter._GREP_USES_THRESHOLD - 1): + with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute() + + with patch("sys.stdin", _make_stdin(_base_input("mcp_serena_find_symbol"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute() + + with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute() + + assert capsys.readouterr().out == "" + + def test_counter_resets_after_deny(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """After a deny is emitted, the counter is reset so the next burst starts fresh.""" + for _ in range(ToolUseCounter._GREP_USES_THRESHOLD): + with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute() + capsys.readouterr() + + with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute() + + assert capsys.readouterr().out == "" + + def test_rate_limit_gates_entire_hook_within_interval(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """While within the rate-limit window, the entire hook must be a no-op: + no deny is emitted, AND the persisted counters must remain untouched. + """ + # first burst: should emit a deny + for _ in range(ToolUseCounter._GREP_USES_THRESHOLD): + with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute() + first_output = capsys.readouterr().out.strip() + assert first_output, "first burst should have emitted a deny" + + # snapshot the persisted counter immediately after the deny was emitted + stub_for_path = object.__new__(PreToolUseRemindAboutSymbolicToolsHook) + stub_for_path.session_persistence_dir = str(tmp_path / "hook_data" / _base_input()["session_id"]) + counter_before = ToolUseCounter.load(stub_for_path) + + # second burst immediately after: within the rate-limit window, the entire + # hook must short-circuit — no deny output and no counter mutation + for _ in range(ToolUseCounter._GREP_USES_THRESHOLD): + with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute() + + assert capsys.readouterr().out == "" + + counter_after = ToolUseCounter.load(stub_for_path) + assert counter_after == counter_before, "gated hook must not mutate the persisted counter" + + def test_rate_limit_allows_deny_after_interval(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """Once the minimum deny interval has elapsed, a fresh burst emits a deny again.""" + # first burst: emits a deny + for _ in range(ToolUseCounter._GREP_USES_THRESHOLD): + with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute() + capsys.readouterr() + + # backdate the persisted last_deny_timestamp so the rate-limit window has expired + stub_for_path = object.__new__(PreToolUseRemindAboutSymbolicToolsHook) + stub_for_path.session_persistence_dir = str(tmp_path / "hook_data" / _base_input()["session_id"]) + counter = ToolUseCounter.load(stub_for_path) + assert counter.last_deny_timestamp is not None + counter.last_deny_timestamp -= timedelta(seconds=ToolUseCounter._MIN_DENY_INTERVAL_SECONDS + 1) + counter.save(stub_for_path) + + # second burst should now emit a deny again + for _ in range(ToolUseCounter._GREP_USES_THRESHOLD): + with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute() + + second_output = capsys.readouterr().out.strip() + assert second_output, "after the rate-limit window elapsed, a new burst should emit a deny" + result = json.loads(second_output) + assert result["hookSpecificOutput"]["permissionDecision"] == "deny" + + def test_non_symbolic_deny_emitted_when_combined_threshold_tripped(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """Pre-populated state trips only the combined non-symbolic counter (not per-tool ones) + so execute() must fall through to the _build_non_symbolic_deny branch. + """ + # pre-populate the pickle so only the combined counter is over threshold + session_dir = tmp_path / "hook_data" / _base_input()["session_id"] + session_dir.mkdir(parents=True) + counter = ToolUseCounter( + n_recent_grep_uses=ToolUseCounter._GREP_USES_THRESHOLD - 1, + n_recent_read_file_uses=ToolUseCounter._READ_FILE_USES_THRESHOLD - 1, + n_recent_non_symbolic_uses=ToolUseCounter._NON_SYMBOLIC_USES_THRESHOLD, + last_grep_use_timestamp=datetime.now(), + last_read_file_use_timestamp=datetime.now(), + last_non_symbolic_use_timestamp=datetime.now(), + ) + stub_for_path = object.__new__(PreToolUseRemindAboutSymbolicToolsHook) + stub_for_path.session_persistence_dir = str(session_dir) + counter.save(stub_for_path) + + # invoke execute with a neutral (non-grep, non-read, non-serena) tool so that + # update() leaves the counters untouched and the fall-through branch is taken + with patch("sys.stdin", _make_stdin(_base_input("Edit"))), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute() + + output = capsys.readouterr().out.strip() + assert output, "expected a non-symbolic deny to be emitted" + result = json.loads(output) + hook_output = result["hookSpecificOutput"] + assert hook_output["permissionDecision"] == "deny" + assert "symbolic" in hook_output["additionalContext"].lower() + + +class TestToolUseCounter: + """Tests for the time-windowed tool-use counter logic.""" + + def test_update_increments_grep_within_period(self): + counter = ToolUseCounter() + now = datetime.now() + counter.last_grep_use_timestamp = now - timedelta(seconds=1) + counter.n_recent_grep_uses = 1 + + hook = self._make_hook_stub("grep_search", now) + counter.update(hook) + + assert counter.n_recent_grep_uses == 2 + assert counter.last_grep_use_timestamp == now + + def test_update_resets_grep_outside_period(self): + counter = ToolUseCounter() + now = datetime.now() + counter.last_grep_use_timestamp = now - timedelta(seconds=ToolUseCounter._GREP_RESET_PERIOD_SECONDS + 1) + counter.n_recent_grep_uses = 2 + + hook = self._make_hook_stub("grep_search", now) + counter.update(hook) + + assert counter.n_recent_grep_uses == 1 + assert counter.last_grep_use_timestamp == now + + def test_update_increments_read_file_within_period(self): + counter = ToolUseCounter() + now = datetime.now() + counter.last_read_file_use_timestamp = now - timedelta(seconds=1) + counter.n_recent_read_file_uses = 1 + + hook = self._make_hook_stub("read_file", now, file_path="src/foo.py") + counter.update(hook) + + assert counter.n_recent_read_file_uses == 2 + + def test_update_resets_read_file_outside_period(self): + counter = ToolUseCounter() + now = datetime.now() + counter.last_read_file_use_timestamp = now - timedelta(seconds=ToolUseCounter._READ_FILE_RESET_PERIOD_SECONDS + 1) + counter.n_recent_read_file_uses = 2 + + hook = self._make_hook_stub("read_file", now, file_path="src/foo.py") + counter.update(hook) + + assert counter.n_recent_read_file_uses == 1 + + def test_update_ignores_read_of_non_code_file(self): + """A read call whose payload points at a non-source file does not increment the code-read counter.""" + counter = ToolUseCounter() + now = datetime.now() + + hook = self._make_hook_stub("read_file", now, file_path="notes/todo.txt") + counter.update(hook) + + assert counter.n_recent_read_file_uses == 0 + assert counter.last_read_file_use_timestamp is None + + def test_serena_tool_resets_all_counters(self): + counter = ToolUseCounter( + n_recent_grep_uses=2, + n_recent_read_file_uses=2, + last_grep_use_timestamp=datetime.now(), + last_read_file_use_timestamp=datetime.now(), + ) + hook = self._make_hook_stub("mcp_serena_overview", datetime.now()) + counter.update(hook) + + assert counter.n_recent_grep_uses == 0 + assert counter.n_recent_read_file_uses == 0 + assert counter.last_grep_use_timestamp is None + assert counter.last_read_file_use_timestamp is None + + def test_non_matching_tool_leaves_counters_unchanged(self): + counter = ToolUseCounter(n_recent_grep_uses=1, n_recent_read_file_uses=1) + hook = self._make_hook_stub("write_file", datetime.now()) + counter.update(hook) + + assert counter.n_recent_grep_uses == 1 + assert counter.n_recent_read_file_uses == 1 + + def test_persistence_round_trip(self, tmp_path: Path): + counter = ToolUseCounter(n_recent_grep_uses=2, n_recent_read_file_uses=1) + + hook_stub = type("HookStub", (), {"session_persistence_dir": str(tmp_path)})() + counter.save(hook_stub) + loaded = ToolUseCounter.load(hook_stub) + + assert loaded.n_recent_grep_uses == 2 + assert loaded.n_recent_read_file_uses == 1 + + def test_load_returns_fresh_counter_on_missing_file(self, tmp_path: Path): + hook_stub = type("HookStub", (), {"session_persistence_dir": str(tmp_path / "nonexistent")})() + loaded = ToolUseCounter.load(hook_stub) + assert loaded == ToolUseCounter() + + def test_load_returns_fresh_counter_on_corrupt_file(self, tmp_path: Path): + hook_stub = type("HookStub", (), {"session_persistence_dir": str(tmp_path)})() + path = tmp_path / ToolUseCounter._FILE_NAME + path.write_bytes(b"not a pickle") + loaded = ToolUseCounter.load(hook_stub) + assert loaded == ToolUseCounter() + + def test_is_hook_active_respects_min_interval(self): + """:meth:`is_hook_active` returns False within the minimum interval, True outside it.""" + counter = ToolUseCounter() + base = datetime.now() + + # no prior deny → hook is always active + assert counter.is_hook_active(base) + + # within the interval → hook gated + counter.last_deny_timestamp = base + interval = ToolUseCounter._MIN_DENY_INTERVAL_SECONDS + assert not counter.is_hook_active(base + timedelta(seconds=interval - 1)) + assert not counter.is_hook_active(base) + + # at/after the interval → hook active again + assert counter.is_hook_active(base + timedelta(seconds=interval)) + assert counter.is_hook_active(base + timedelta(seconds=interval + 1)) + + def test_reset_preserves_last_deny_timestamp(self): + """``reset`` clears burst counters but must keep ``last_deny_timestamp`` intact.""" + counter = ToolUseCounter() + base = datetime.now() + counter.last_deny_timestamp = base + counter.n_recent_grep_uses = 5 + counter.n_recent_read_file_uses = 4 + counter.n_recent_non_symbolic_uses = 7 + + counter.reset() + + assert counter.n_recent_grep_uses == 0 + assert counter.n_recent_read_file_uses == 0 + assert counter.n_recent_non_symbolic_uses == 0 + assert counter.last_deny_timestamp == base + + @staticmethod + def _make_hook_stub( + tool_name: str, + timestamp: datetime, + file_path: str | None = None, + command_args_str: str | None = None, + ) -> PreToolUseRemindAboutSymbolicToolsHook: + """Create a minimal stub that satisfies ToolUseCounter.update without reading stdin. + + Uses ``HookClient.VSCODE`` so that ``is_grep_tool`` / ``is_read_file_tool`` apply the + substring-matching branch — the counter tests below feed verbose tool names like + ``grep_search`` / ``read_file`` which are only recognized under the non-Claude-Code + branch (Claude Code uses exact names ``grep`` / ``read``). + + :param file_path: optional payload path; the hook classifies read-style tools as + file reads regardless of the path extension. + :param command_args_str: optional shell-argument tail; equivalent to ``file_path`` but + populates the ``cmd``-derived field instead of ``file_path``. + """ + stub = object.__new__(PreToolUseRemindAboutSymbolicToolsHook) + stub._tool_name = tool_name.lower() + stub._client = HookClient.VSCODE + stub.triggered_at_timestamp = timestamp + stub._command = None + stub._command_name = None + stub._command_args_str = command_args_str + stub._file_path = file_path + return stub + + +class TestPreToolUseAutoApproveSerenaHook: + """Tests for the auto-approve hook that allows Serena tools while the client is in a permissive permission mode (``acceptEdits`` or ``auto``).""" + + @staticmethod + def _approve_input( + tool_name: str = "mcp__serena__find_symbol", + permission_mode: str | None = "acceptEdits", + session_id: str = "auto-approve-session", + permission_mode_key: str = "permission_mode", + ) -> dict: + data: dict = { + "session_id": session_id, + "tool_name": tool_name, + "tool_input": {}, + } + if permission_mode is not None: + data[permission_mode_key] = permission_mode + return data + + def test_approves_serena_tool_in_accept_edits_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """When the tool is a Serena tool and the mode is ``acceptEdits``, an allow decision is emitted.""" + stdin_data = self._approve_input() + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() + + output = capsys.readouterr().out.strip() + result = json.loads(output) + hook_output = result["hookSpecificOutput"] + assert hook_output["hookEventName"] == "PreToolUse" + assert hook_output["permissionDecision"] == "allow" + assert "acceptedits" in hook_output["permissionDecisionReason"].lower() + + def test_approves_serena_tool_in_auto_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """When the tool is a Serena tool and the mode is ``auto``, an allow decision is emitted. + + Claude Code's ``auto`` mode is the hands-off-execution mode users adopt for autonomous + runs; Serena tool calls should be auto-approved there for the same reason as in + ``acceptEdits`` (Serena's destructive tools would otherwise still prompt per call). + """ + stdin_data = self._approve_input(permission_mode="auto") + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() + + output = capsys.readouterr().out.strip() + result = json.loads(output) + hook_output = result["hookSpecificOutput"] + assert hook_output["hookEventName"] == "PreToolUse" + assert hook_output["permissionDecision"] == "allow" + # the reason must identify the actual mode that triggered the approval, not just say + # "auto-approved" — the substring " auto " (with surrounding spaces) discriminates the + # mode name from the unrelated "Auto-approved" prefix. + assert " auto " in hook_output["permissionDecisionReason"] + + def test_accepts_camel_case_permission_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """The hook also reads the ``permissionMode`` (camelCase) variant of the field.""" + stdin_data = self._approve_input(permission_mode_key="permissionMode") + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() + + output = capsys.readouterr().out.strip() + assert json.loads(output)["hookSpecificOutput"]["permissionDecision"] == "allow" + + def test_stays_silent_for_non_serena_tool(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """Non-Serena tools get no decision even in ``acceptEdits`` mode (the hook stays silent).""" + stdin_data = self._approve_input(tool_name="Grep") + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() + assert capsys.readouterr().out == "" + + def test_stays_silent_in_default_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """Serena tools in ``default`` mode get no decision (the hook stays silent).""" + stdin_data = self._approve_input(permission_mode="default") + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() + assert capsys.readouterr().out == "" + + def test_stays_silent_in_plan_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """Other permission modes (e.g. ``plan``) must not trigger an auto-approve.""" + stdin_data = self._approve_input(permission_mode="plan") + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() + assert capsys.readouterr().out == "" + + def test_stays_silent_in_bypass_permissions_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """``bypassPermissions`` already approves everything upstream in Claude Code, so the + hook deliberately does not emit an explicit allow there — it stays silent. + + This test pins that boundary: only ``acceptEdits`` and ``auto`` are active modes for the + hook; expanding it further requires a deliberate change here. + """ + stdin_data = self._approve_input(permission_mode="bypassPermissions") + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() + assert capsys.readouterr().out == "" + + def test_stays_silent_in_dont_ask_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """``dontAsk`` is the user's deny-by-default posture (auto-deny unless allow-listed); + the hook honors that choice and stays silent rather than overriding it. + """ + stdin_data = self._approve_input(permission_mode="dontAsk") + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() + assert capsys.readouterr().out == "" + + def test_stays_silent_when_permission_mode_missing(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """If ``permission_mode`` is missing from the input, the hook stays silent rather than erroring.""" + stdin_data = self._approve_input(permission_mode=None) + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() + assert capsys.readouterr().out == "" + + +class TestSessionEndCleanupHook: + def test_removes_session_dir(self, tmp_path: Path): + session_dir = tmp_path / "hook_data" / "cleanup-session" + session_dir.mkdir(parents=True) + # place a file inside to verify recursive removal + (session_dir / "tool_use_counter.pkl").write_bytes(pickle.dumps(ToolUseCounter())) + + stdin_data = {"session_id": "cleanup-session"} + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + SessionEndCleanupHook(HookClient.CLAUDE_CODE).execute() + + assert not session_dir.exists() + + def test_cleanup_is_idempotent(self, tmp_path: Path): + """Cleaning up a non-existent session directory should not raise.""" + stdin_data = {"session_id": "nonexistent-session"} + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + SessionEndCleanupHook(HookClient.CLAUDE_CODE).execute() + + +class TestHookCli: + """Tests for the Click CLI entry point (serena-hooks).""" + + def test_cleanup_command(self, tmp_path: Path): + session_dir = tmp_path / "hook_data" / "cli-cleanup" + session_dir.mkdir(parents=True) + (session_dir / "somefile").write_text("data") + + stdin_json = json.dumps({"session_id": "cli-cleanup"}) + runner = CliRunner() + with patch("serena.hooks.serena_home_dir", str(tmp_path)): + result = runner.invoke(hook_commands, ["cleanup", "--client", "claude-code"], input=stdin_json) + assert result.exit_code == 0 + assert not session_dir.exists() + + def test_remind_command(self, tmp_path: Path): + """Invoke the remind command enough times to trigger a deny.""" + runner = CliRunner() + for _ in range(ToolUseCounter._GREP_USES_THRESHOLD): + stdin_json = json.dumps({"session_id": "cli-remind", "tool_name": "grep", "tool_input": {}}) + with patch("serena.hooks.serena_home_dir", str(tmp_path)): + result = runner.invoke(hook_commands, ["remind", "--client", "claude-code"], input=stdin_json) + assert result.exit_code == 0 + + output = json.loads(result.output) + assert output["hookSpecificOutput"]["permissionDecision"] == "deny" + + def test_auto_approve_command(self, tmp_path: Path): + """The ``auto-approve`` CLI command emits an allow for a Serena tool in acceptEdits mode.""" + stdin_json = json.dumps( + { + "session_id": "cli-auto-approve", + "tool_name": "mcp__serena__find_symbol", + "tool_input": {}, + "permission_mode": "acceptEdits", + } + ) + runner = CliRunner() + with patch("serena.hooks.serena_home_dir", str(tmp_path)): + result = runner.invoke(hook_commands, ["auto-approve", "--client", "claude-code"], input=stdin_json) + assert result.exit_code == 0 + output = json.loads(result.output) + assert output["hookSpecificOutput"]["permissionDecision"] == "allow" + + def test_auto_approve_command_in_auto_mode(self, tmp_path: Path): + """The ``auto-approve`` CLI command emits an allow for a Serena tool in ``auto`` mode.""" + stdin_json = json.dumps( + { + "session_id": "cli-auto-approve-auto-mode", + "tool_name": "mcp__serena__find_symbol", + "tool_input": {}, + "permission_mode": "auto", + } + ) + runner = CliRunner() + with patch("serena.hooks.serena_home_dir", str(tmp_path)): + result = runner.invoke(hook_commands, ["auto-approve", "--client", "claude-code"], input=stdin_json) + assert result.exit_code == 0 + output = json.loads(result.output) + assert output["hookSpecificOutput"]["permissionDecision"] == "allow" + + def test_auto_approve_command_stays_silent_in_default_mode(self, tmp_path: Path): + """The ``auto-approve`` CLI command emits nothing when the mode is not ``acceptEdits``.""" + stdin_json = json.dumps( + { + "session_id": "cli-auto-approve-default", + "tool_name": "mcp__serena__find_symbol", + "tool_input": {}, + "permission_mode": "default", + } + ) + runner = CliRunner() + with patch("serena.hooks.serena_home_dir", str(tmp_path)): + result = runner.invoke(hook_commands, ["auto-approve", "--client", "claude-code"], input=stdin_json) + assert result.exit_code == 0 + assert result.output == "" + + def test_client_default_is_claude_code(self, tmp_path: Path): + """When --client is omitted, it defaults to claude-code.""" + stdin_json = json.dumps({"session_id": "cli-default"}) + runner = CliRunner() + with patch("serena.hooks.serena_home_dir", str(tmp_path)): + result = runner.invoke(hook_commands, ["activate"], input=stdin_json) + assert result.exit_code == 0 + + def test_invalid_client_rejected(self, tmp_path: Path): + stdin_json = json.dumps({"session_id": "s1"}) + runner = CliRunner() + with patch("serena.hooks.serena_home_dir", str(tmp_path)): + result = runner.invoke(hook_commands, ["activate", "--client", "invalid"], input=stdin_json) + assert result.exit_code != 0 + + def test_invalid_stdin_exits_nonzero(self, tmp_path: Path): + runner = CliRunner() + with patch("serena.hooks.serena_home_dir", str(tmp_path)): + result = runner.invoke(hook_commands, ["activate", "--client", "claude-code"], input="not json") + assert result.exit_code != 0 diff --git a/test/serena/test_jetbrains_plugin_client.py b/test/serena/test_jetbrains_plugin_client.py new file mode 100644 index 0000000..d6590ea --- /dev/null +++ b/test/serena/test_jetbrains_plugin_client.py @@ -0,0 +1,20 @@ +import pytest + +from serena.constants import REPO_ROOT +from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient + + +class TestSerenaJetBrainsPluginClient: + @pytest.mark.parametrize( + "serena_path, plugin_path", + [ + (REPO_ROOT, REPO_ROOT), + ("/home/user/project", "/home/user/project"), + ("/home/user/project", "//wsl.localhost/Ubuntu-24.04/home/user/project"), + ("/home/user/project", "//wsl$/Ubuntu/home/user/project"), + ("/home/user/project", "//wsl$/Ubuntu/home/user/project"), + ("/mnt/c/Users/user/projects/my-app", "/workspaces/serena/C:/Users/user/projects/my-app"), + ], + ) + def test_path_matching(self, serena_path, plugin_path) -> None: + assert JetBrainsPluginClient._paths_match(serena_path, plugin_path) diff --git a/test/serena/test_mcp.py b/test/serena/test_mcp.py new file mode 100644 index 0000000..731a1cc --- /dev/null +++ b/test/serena/test_mcp.py @@ -0,0 +1,300 @@ +"""Tests for the mcp.py module in serena.""" + +import pytest +from mcp.server.fastmcp.tools.base import Tool as MCPTool + +from serena.agent import Tool, ToolRegistry +from serena.config.context_mode import SerenaAgentContext +from serena.mcp import SerenaMCPFactory + +make_tool = SerenaMCPFactory.make_mcp_tool + + +# Create a mock agent for tool initialization +class MockAgent: + def __init__(self): + self.project_config = None + self.serena_config = None + + @staticmethod + def get_context() -> SerenaAgentContext: + return SerenaAgentContext.load_default() + + +class BaseMockTool(Tool): + """A mock Tool class for testing.""" + + def __init__(self): + super().__init__(MockAgent()) + + +class BasicTool(BaseMockTool): + """A mock Tool class for testing.""" + + def apply(self, name: str, age: int = 0) -> str: + """This is a test function. + + :param name: The person's name + :param age: The person's age + :return: A greeting message + """ + return f"Hello {name}, you are {age} years old!" + + def apply_ex( + self, + log_call: bool = True, + catch_exceptions: bool = True, + **kwargs, + ) -> str: + """Mock implementation of apply_ex.""" + return self.apply(**kwargs) + + +def test_make_tool_basic() -> None: + """Test that make_tool correctly creates an MCP tool from a Tool object.""" + mock_tool = BasicTool() + + mcp_tool = make_tool(mock_tool) + + # Test that the MCP tool has the correct properties + assert isinstance(mcp_tool, MCPTool) + assert mcp_tool.name == "basic" + assert "This is a test function. Returns A greeting message." in mcp_tool.description + + # Test that the parameters were correctly processed + parameters = mcp_tool.parameters + assert "properties" in parameters + assert "name" in parameters["properties"] + assert "age" in parameters["properties"] + assert parameters["properties"]["name"]["description"] == "The person's name." + assert parameters["properties"]["age"]["description"] == "The person's age." + + +def test_make_tool_execution() -> None: + """Test that the execution function created by make_tool works correctly.""" + mock_tool = BasicTool() + mcp_tool = make_tool(mock_tool) + + # Execute the MCP tool function + result = mcp_tool.fn(name="Alice", age=30) + + assert result == "Hello Alice, you are 30 years old!" + + +def test_make_tool_no_params() -> None: + """Test make_tool with a function that has no parameters.""" + + class NoParamsTool(BaseMockTool): + def apply(self) -> str: + """This is a test function with no parameters. + + :return: A simple result + """ + return "Simple result" + + def apply_ex(self, *args, **kwargs) -> str: + return self.apply() + + tool = NoParamsTool() + mcp_tool = make_tool(tool) + + assert mcp_tool.name == "no_params" + assert "This is a test function with no parameters. Returns A simple result." in mcp_tool.description + assert mcp_tool.parameters["properties"] == {} + + +def test_make_tool_no_return_description() -> None: + """Test make_tool with a function that has no return description.""" + + class NoReturnTool(BaseMockTool): + def apply(self, param: str) -> str: + """This is a test function. + + :param param: The parameter + """ + return f"Processed: {param}" + + def apply_ex(self, *args, **kwargs) -> str: + return self.apply(**kwargs) + + tool = NoReturnTool() + mcp_tool = make_tool(tool) + + assert mcp_tool.name == "no_return" + assert mcp_tool.description == "This is a test function." + assert mcp_tool.parameters["properties"]["param"]["description"] == "The parameter." + + +def test_make_tool_parameter_not_in_docstring() -> None: + """Test make_tool when a parameter in properties is not in the docstring.""" + + class MissingParamTool(BaseMockTool): + def apply(self, name: str, missing_param: str = "") -> str: + """This is a test function. + + :param name: The person's name + """ + return f"Hello {name}! Missing param: {missing_param}" + + def apply_ex(self, *args, **kwargs) -> str: + return self.apply(**kwargs) + + tool = MissingParamTool() + mcp_tool = make_tool(tool) + + assert "name" in mcp_tool.parameters["properties"] + assert "missing_param" in mcp_tool.parameters["properties"] + assert mcp_tool.parameters["properties"]["name"]["description"] == "The person's name." + assert "description" not in mcp_tool.parameters["properties"]["missing_param"] + + +def test_make_tool_multiline_docstring() -> None: + """Test make_tool with a complex multi-line docstring.""" + + class ComplexDocTool(BaseMockTool): + def apply(self, project_file_path: str, host: str, port: int) -> str: + """Create an MCP server. + + This function creates and configures a Model Context Protocol server + with the specified settings. + + :param project_file_path: The path to the project file, or None + :param host: The host to bind to + :param port: The port to bind to + :return: A configured FastMCP server instance + """ + return f"Server config: {project_file_path}, {host}:{port}" + + def apply_ex(self, *args, **kwargs) -> str: + return self.apply(**kwargs) + + tool = ComplexDocTool() + mcp_tool = make_tool(tool) + + assert "Create an MCP server" in mcp_tool.description + assert "Returns A configured FastMCP server instance" in mcp_tool.description + assert mcp_tool.parameters["properties"]["project_file_path"]["description"] == "The path to the project file, or None." + assert mcp_tool.parameters["properties"]["host"]["description"] == "The host to bind to." + assert mcp_tool.parameters["properties"]["port"]["description"] == "The port to bind to." + + +def test_make_tool_capitalization_and_periods() -> None: + """Test that make_tool properly handles capitalization and periods in descriptions.""" + + class FormatTool(BaseMockTool): + def apply(self, param1: str, param2: str, param3: str) -> str: + """Test function. + + :param param1: lowercase description + :param param2: description with period. + :param param3: description with Capitalized word. + """ + return f"Formatted: {param1}, {param2}, {param3}" + + def apply_ex(self, *args, **kwargs) -> str: + return self.apply(**kwargs) + + tool = FormatTool() + mcp_tool = make_tool(tool) + + assert mcp_tool.parameters["properties"]["param1"]["description"] == "Lowercase description." + assert mcp_tool.parameters["properties"]["param2"]["description"] == "Description with period." + assert mcp_tool.parameters["properties"]["param3"]["description"] == "Description with Capitalized word." + + +def test_make_tool_missing_apply() -> None: + """Test make_tool with a tool that doesn't have an apply method.""" + + class BadTool(BaseMockTool): + pass + + tool = BadTool() + + with pytest.raises(AttributeError): + make_tool(tool) + + +@pytest.mark.parametrize( + "docstring, expected_description", + [ + ( + """This is a test function. + + :param param: The parameter + :return: A result + """, + "This is a test function. Returns A result.", + ), + ( + """ + :param param: The parameter + :return: A result + """, + "Returns A result.", + ), + ( + """ + :param param: The parameter + """, + "", + ), + ("Description without params.", "Description without params."), + ], +) +def test_make_tool_descriptions(docstring, expected_description) -> None: + """Test make_tool with various docstring formats.""" + + class TestTool(BaseMockTool): + def apply(self, param: str) -> str: + return f"Result: {param}" + + def apply_ex(self, *args, **kwargs) -> str: + return self.apply(**kwargs) + + # Dynamically set the docstring + TestTool.apply.__doc__ = docstring + + tool = TestTool() + mcp_tool = make_tool(tool) + + assert mcp_tool.name == "test" + assert mcp_tool.description == expected_description + + +def is_test_mock_class(tool_class: type) -> bool: + """Check if a class is a test mock class.""" + # Check if the class is defined in a test module + module_name = tool_class.__module__ + return ( + module_name.startswith(("test.", "tests.")) + or "test_" in module_name + or tool_class.__name__ + in [ + "BaseMockTool", + "BasicTool", + "BadTool", + "NoParamsTool", + "NoReturnTool", + "MissingParamTool", + "ComplexDocTool", + "FormatTool", + "NoDescriptionTool", + ] + ) + + +@pytest.mark.parametrize("tool_class", ToolRegistry().get_all_tool_classes()) +def test_make_tool_all_tools(tool_class) -> None: + """Test that make_tool works for all tools in the codebase.""" + # Create an instance of the tool + tool_instance = tool_class(MockAgent()) + + # Try to create an MCP tool from it + mcp_tool = make_tool(tool_instance) + + # Basic validation + assert isinstance(mcp_tool, MCPTool) + assert mcp_tool.name == tool_class.get_name_from_cls() + + # The description should be a string (either from docstring or default) + assert isinstance(mcp_tool.description, str) diff --git a/test/serena/test_memories_manager.py b/test/serena/test_memories_manager.py new file mode 100644 index 0000000..158ab37 --- /dev/null +++ b/test/serena/test_memories_manager.py @@ -0,0 +1,766 @@ +""" +Unit tests for :class:`serena.project.MemoriesManager` helpers that do not +require touching the filesystem (in particular, :meth:`rename_references_to_memory` +and :meth:`_prepare_name`). +""" + +import os + +import pytest + +from serena.memories.memory_reference_analysis import ( + HIGH_CONFIDENCE_NAME_LENGTH, + MAX_STALE_REFERENCE_CANDIDATES, + NAME_SIMILARITY_THRESHOLD, + compute_name_similarity, + find_stale_reference_candidates, +) +from serena.project import MemoryManager + + +@pytest.fixture +def manager() -> MemoryManager: + # serena_data_folder=None: the tested helpers do not access the filesystem + return MemoryManager(serena_data_folder=None) + + +class TestPrepareName: + @pytest.mark.parametrize( + "name", + [ + "memory", + "emergency", + "meeting_notes", + "em-dash", + "ml_notes", + "embedding", + "e", + "m", + "mem", + ":colon_prefixed_unlikely", + ], + ) + def test_does_not_strip_partial_prefix_chars_from_real_names(self, manager: MemoryManager, name: str) -> None: + """A regression guard: previously used ``str.lstrip("mem:")`` which strips any leading m/e/:.""" + assert manager._sanitize_name(name) == name + + @pytest.mark.parametrize( + ("input_name", "expected"), + [ + ("mem:foo", "foo"), + ("mem:foo/bar", "foo/bar"), + ("mem:memory", "memory"), # prefix is removed only once, not greedily + ("mem:", ""), + ], + ) + def test_strips_mem_prefix_only_when_actually_a_prefix(self, manager: MemoryManager, input_name: str, expected: str) -> None: + assert manager._sanitize_name(input_name) == expected + + @pytest.mark.parametrize( + ("input_name", "expected"), + [ + ("foo.md", "foo"), + ("topic/foo.md", "topic/foo"), + ("a.md.notes", "a.md.notes"), # ".md" must only be stripped as trailing extension + ("foo.markdown", "foo.markdown"), + (".md", ""), + ], + ) + def test_strips_md_only_as_suffix(self, manager: MemoryManager, input_name: str, expected: str) -> None: + assert manager._sanitize_name(input_name) == expected + + def test_normalizes_os_separator(self, manager: MemoryManager) -> None: + assert manager._sanitize_name(f"topic{os.sep}sub{os.sep}name") == "topic/sub/name" + + def test_combined_normalizations(self, manager: MemoryManager) -> None: + assert manager._sanitize_name(f"mem:topic{os.sep}foo.md") == "topic/foo" + + +class TestRenameReferencesToMemory: + @pytest.mark.parametrize( + ("content", "old_name", "new_name", "expected_content", "expected_count"), + [ + # simple cases + pytest.param("see mem:foo for details", "foo", "bar", "see mem:bar for details", 1, id="simple"), + pytest.param("nothing to see here", "foo", "bar", "nothing to see here", 0, id="no_match"), + pytest.param( + "mem:foo and mem:foo and `mem:foo`", + "foo", + "bar", + "mem:bar and mem:bar and `mem:bar`", + 3, + id="multiple_occurrences", + ), + # boundaries + pytest.param("mem:foo rest", "foo", "bar", "mem:bar rest", 1, id="start_of_string"), + pytest.param("prefix mem:foo", "foo", "bar", "prefix mem:bar", 1, id="end_of_string"), + pytest.param("mem:foo", "foo", "bar", "mem:bar", 1, id="entire_string"), + # must NOT match: embedded in longer name + pytest.param("`mem:foobar`", "foo", "bar", "`mem:foobar`", 0, id="longer_suffix"), + pytest.param("`mem:foo/sub`", "foo", "bar", "`mem:foo/sub`", 0, id="subtopic"), + pytest.param("mem:foo-extra", "foo", "bar", "mem:foo-extra", 0, id="hyphen_suffix"), + pytest.param("mem:foo_extra", "foo", "bar", "mem:foo_extra", 0, id="underscore_suffix"), + pytest.param("xmem:foo", "foo", "bar", "xmem:foo", 0, id="preceded_by_name_char"), + # boundaries on non-name characters + pytest.param("see `mem:foo.md`", "foo", "bar", "see `mem:bar.md`", 1, id="md_extension_suffix"), + # topic renames + pytest.param("`mem:foo/bar`", "foo/bar", "baz", "`mem:baz`", 1, id="topic_to_flat"), + pytest.param("`mem:foo`", "foo/bar", "baz", "`mem:foo`", 0, id="parent_does_not_match_topic"), + pytest.param("`mem:foo`", "foo", "topic/bar", "`mem:topic/bar`", 1, id="flat_to_topic"), + # regex-meta safety + pytest.param("`mem:a.b`", "a.b", "c", "`mem:c`", 1, id="dot_in_old_name_literal"), + pytest.param("`mem:axb`", "a.b", "c", "`mem:axb`", 0, id="dot_in_old_name_not_wildcard"), + # mixed + pytest.param( + "valid `mem:foo`, embedded mem:foobar, subtopic mem:foo/x, and another `mem:foo`.", + "foo", + "bar", + "valid `mem:bar`, embedded mem:foobar, subtopic mem:foo/x, and another `mem:bar`.", + 2, + id="mixed_matching_and_nonmatching", + ), + # input names that previously broke due to lstrip("mem:") in _add_reference_prefix + pytest.param("see `mem:memory`", "memory", "diary", "see `mem:diary`", 1, id="old_name_starts_with_m"), + pytest.param("see `mem:emergency`", "emergency", "incident", "see `mem:incident`", 1, id="old_name_starts_with_e"), + pytest.param("see `mem:foo`", "foo", "memory", "see `mem:memory`", 1, id="new_name_starts_with_m"), + ], + ) + def test_rename( + self, + manager: MemoryManager, + content: str, + old_name: str, + new_name: str, + expected_content: str, + expected_count: int, + ) -> None: + result, n = manager.rename_references_to_memory(content, old_name, new_name) + assert result == expected_content + assert n == expected_count + + @pytest.mark.parametrize( + "delimiter_pair", + [ + # one case per structural class of surrounding character (asymmetric pair, whitespace, + # punctuation, none); the boundary regex only distinguishes "name char" vs "non-name char", + # so further variants exercise the same code path. + ("`", "`"), + ("(", ")"), + (" ", " "), + ("", ""), # raw, no delimiters + ], + ) + def test_replacement_inside_various_delimiters(self, manager: MemoryManager, delimiter_pair: tuple[str, str]) -> None: + left, right = delimiter_pair + content = f"{left}mem:foo{right}" + result, n = manager.rename_references_to_memory(content, "foo", "bar") + assert result == f"{left}mem:bar{right}" + assert n == 1 + + +class TestNameSimilarity: + @pytest.mark.parametrize( + ("a", "b", "expected"), + [ + # exact match (and case-insensitive) -> 1.0 + pytest.param("foo", "foo", 1.0, id="identical"), + pytest.param("Foo", "foo", 1.0, id="case_insensitive"), + # version-suffix renames are normalized away -> 1.0 + pytest.param("auth_v1", "auth_v2", 1.0, id="version_suffix_rename"), + pytest.param("auth_old", "auth", 1.0, id="old_suffix_normalized"), + pytest.param("auth_legacy", "auth", 1.0, id="legacy_suffix_normalized"), + # flat <-> topic basename move: canonical rename, fully confident + pytest.param("a/b/c/login", "login", 1.0, id="topic_move_to_flat"), + pytest.param("login", "auth/login", 1.0, id="flat_to_topic_move"), + # both sides share a prefix token AND basename: high confidence + pytest.param("auth/login", "auth/v2/login", 0.75, id="basename_match_shared_prefix"), + ], + ) + def test_high_similarity_cases(self, manager: MemoryManager, a: str, b: str, expected: float) -> None: + assert compute_name_similarity(a, b) == pytest.approx(expected) + + @pytest.mark.parametrize( + ("a", "b"), + [ + # completely unrelated names should be below threshold + pytest.param("authentication", "database", id="unrelated_long"), + pytest.param("api/users", "logging/handlers", id="unrelated_topic_paths"), + # short names without strong token signals must hit the short-name floor + pytest.param("foo", "bar", id="short_names_unrelated"), + pytest.param("foo", "for", id="short_names_typo_distance"), + # IMPORTANT: same basename under unrelated topic prefixes is NOT a rename. + # `auth/login` and `security/login` are two different login flows in different + # subsystems; `topic1/topic2/core` and `topic3/topic5/core` are unrelated. + pytest.param("auth/login", "security/login", id="same_basename_unrelated_prefixes"), + pytest.param("topic1/topic2/core", "topic3/topic5/core", id="same_basename_disjoint_prefix_tokens"), + ], + ) + def test_below_threshold_cases(self, manager: MemoryManager, a: str, b: str) -> None: + assert compute_name_similarity(a, b) < NAME_SIMILARITY_THRESHOLD + + @pytest.mark.parametrize( + ("a", "b"), + [ + # prefix/suffix extensions: caught by sequence ratio / containment, even though Jaccard is 0 + pytest.param("auth", "authentication", id="prefix_extension"), + pytest.param("login", "user_login", id="token_extension"), + # typos on longer names: caught by sequence ratio + pytest.param("authentication", "autentication", id="typo_in_long_name"), + # token reorder / partial overlap + pytest.param("user_login_flow", "login_flow", id="partial_token_overlap"), + ], + ) + def test_above_threshold_cases(self, manager: MemoryManager, a: str, b: str) -> None: + assert compute_name_similarity(a, b) >= NAME_SIMILARITY_THRESHOLD + + def test_symmetry(self, manager: MemoryManager) -> None: + # similarity is order-independent + assert compute_name_similarity("auth/login", "security/login") == compute_name_similarity("security/login", "auth/login") + + def test_cross_topic_suffix_coincidence_is_rejected(self, manager: MemoryManager) -> None: + """ + Regression: when two long names live under *different* topic prefixes and share + only a single trailing token (e.g. ``-subtleties``), the score must collapse to + zero. Without the cross-topic gate, SequenceMatcher returns ~0.55+ on these + purely on shared characters, flooding stale-reference candidate lists with + unrelated memories from neighbouring subsystems. + """ + target = "frontend/plugin-api-runtime-subtleties" + # different topic prefix + only the generic "subtleties" trailing token shared -> 0 + for other in ( + "backend/rpc-db-worker-subtleties", + "render-wasm/ffi-rendering-subtleties", + "common/grid-layout-subtleties", + ): + assert compute_name_similarity(target, other) == 0.0, f"cross-topic suffix coincidence with {other} must be rejected" + # contrast: *same* prefix is treated leniently — the gate must not apply, so the + # score is non-zero even if it still falls under the similarity threshold + same_prefix_score = compute_name_similarity(target, "frontend/routing-app-shell-subtleties") + assert same_prefix_score > 0.0 + + def test_find_candidates_ranks_by_descending_similarity(self, manager: MemoryManager) -> None: + existing = ["auth/login", "security/login", "database", "authentication"] + candidates = find_stale_reference_candidates("login", existing) + # both auth/login and security/login share basename `login` -> tied at 1.0; expect them first, alphabetical tiebreak + assert candidates[:2] == ["auth/login", "security/login"] + # `database` is unrelated and must not appear + assert "database" not in candidates + + def test_find_candidates_returns_empty_below_threshold(self, manager: MemoryManager) -> None: + existing = ["completely_unrelated", "another_thing"] + assert find_stale_reference_candidates("foo", existing) == [] + + def test_find_candidates_custom_threshold(self, manager: MemoryManager) -> None: + # raising the threshold filters out borderline matches + existing = ["authentication"] + loose = find_stale_reference_candidates("auth", existing, threshold=0.2) + strict = find_stale_reference_candidates("auth", existing, threshold=0.95) + assert loose == ["authentication"] + assert strict == [] + + +@pytest.fixture +def fs_manager(tmp_path) -> MemoryManager: + """A MemoriesManager backed by tmp_path with no read-only patterns.""" + return MemoryManager(serena_data_folder=tmp_path) + + +def _write(manager: MemoryManager, name: str, content: str) -> None: + manager.save_memory(name, content, is_tool_context=False) + + +class TestListMemoriesFollowsSymlinks: + """Regression: memories reachable only through a directory symlink (e.g. a monorepo whose + ``.serena/memories`` symlinks each submodule's memory folder, making them addressable as + ``/``) must be listed and readable. ``Path.rglob`` does not descend into + symlinked directories before Python 3.13, which silently hid them from ``list_memories``. + """ + + def test_lists_and_reads_memories_behind_directory_symlink(self, tmp_path) -> None: + project_dir = tmp_path / "project" / ".serena" + manager = MemoryManager(serena_data_folder=project_dir) + _write(manager, "own_memory", "# local") + + # a sibling submodule with its own (possibly nested) memories + submodule_memories = tmp_path / "submodule" / ".serena" / "memories" + (submodule_memories / "nested").mkdir(parents=True) + (submodule_memories / "sub_a.md").write_text("# a", encoding="utf-8") + (submodule_memories / "nested" / "sub_b.md").write_text("# b", encoding="utf-8") + + # symlink the submodule's memory dir into the project's memories folder + link = project_dir / "memories" / "submodule" + try: + link.symlink_to(submodule_memories, target_is_directory=True) + except (OSError, NotImplementedError) as e: + pytest.skip(f"cannot create directory symlinks on this platform/permissions: {e}") + + listed = manager.list_memories().get_full_list() + assert "own_memory" in listed + assert "submodule/sub_a" in listed + assert "submodule/nested/sub_b" in listed + # the listed names must round-trip through the read path (which resolves the symlink) + assert manager.load_memory("submodule/sub_a") == "# a" + assert manager.load_memory("submodule/nested/sub_b") == "# b" + + +class TestGetMemoryFilePathContainment: + """Regression: :meth:`MemoryManager.get_memory_file_path` must never return a path outside the + memories sandbox. An *absolute* memory name (e.g. ``/etc/cron.d/backdoor``) previously caused + ``pathlib`` to discard the base directory and reset to the absolute path, escaping + ``.serena/memories``. Since save/load/delete/move all route through this method, that let an + agent read, write, or delete arbitrary ``*.md`` files outside the sandbox. The guard must also + hold for the ``global/...`` branch, and the existing ``..`` rejection must keep working. + """ + + def test_absolute_project_memory_name_escapes_sandbox_is_rejected(self, fs_manager: MemoryManager, tmp_path) -> None: + # An absolute name pointing outside the memories dir. On the buggy code this silently + # returned a path under ``escape_target`` (outside ``memories``); the fix rejects it. + escape = str(tmp_path / "escape_target" / "backdoor") + with pytest.raises(ValueError): + fs_manager.get_memory_file_path(escape) + + def test_absolute_project_memory_name_system_path_is_rejected(self, fs_manager: MemoryManager) -> None: + with pytest.raises(ValueError): + fs_manager.get_memory_file_path("/etc/cron.d/backdoor") + + def test_absolute_global_memory_name_is_rejected(self, fs_manager: MemoryManager, tmp_path, monkeypatch) -> None: + # "global//etc/..." routes through the global branch; the empty segment / absolute sub-name + # must be rejected there too. Redirect the global dir so the test never touches the real one. + monkeypatch.setattr(fs_manager, "_global_memory_dir", tmp_path / "global") + with pytest.raises(ValueError): + fs_manager.get_memory_file_path("global//etc/cron.d/backdoor") + + def test_dotdot_segment_still_rejected(self, fs_manager: MemoryManager) -> None: + # the pre-existing guard must keep working + with pytest.raises(ValueError): + fs_manager.get_memory_file_path("../../etc/passwd") + + @pytest.mark.parametrize("name", ["notes", "topic/notes", "a/b/c/deep", "mem:topic/foo.md"]) + def test_accepted_project_names_resolve_inside_memories_dir(self, fs_manager: MemoryManager, name: str) -> None: + assert fs_manager._project_memory_dir is not None + path = fs_manager.get_memory_file_path(name) + assert path.resolve().is_relative_to(fs_manager._project_memory_dir.resolve()) + + def test_accepted_global_name_resolves_inside_global_dir(self, fs_manager: MemoryManager, tmp_path, monkeypatch) -> None: + global_dir = tmp_path / "global" + global_dir.mkdir() + monkeypatch.setattr(fs_manager, "_global_memory_dir", global_dir) + path = fs_manager.get_memory_file_path("global/topic/notes") + assert path.resolve().is_relative_to(global_dir.resolve()) + + +class TestValidateReferentialIntegrity: + def test_clean_report_when_all_references_resolve(self, fs_manager: MemoryManager) -> None: + _write(fs_manager, "auth", "# auth notes") + _write(fs_manager, "login_flow", "see `mem:auth` for details") + report = fs_manager.validate_referential_integrity() + assert report.is_clean() + assert "No referential integrity issues" in report.format() + + def test_stale_reference_with_topic_move_candidate(self, fs_manager: MemoryManager) -> None: + # original memory `login` was moved to `auth/login`; a reference still points to bare `login` + _write(fs_manager, "auth/login", "# moved here") + _write(fs_manager, "docs", "old reference: `mem:login`") + report = fs_manager.validate_referential_integrity() + assert len(report.stale_references) == 1 + stale = report.stale_references[0] + assert stale.source_memory == "docs" + assert stale.referenced_name == "login" + assert "auth/login" in stale.candidates + + def test_stale_reference_with_no_candidate(self, fs_manager: MemoryManager) -> None: + _write(fs_manager, "completely_unrelated", "# content") + _write(fs_manager, "docs", "broken: `mem:xyz_unknown_thing`") + report = fs_manager.validate_referential_integrity() + assert len(report.stale_references) == 1 + assert report.stale_references[0].candidates == [] + + def test_high_confidence_unmarked_warning_for_topic_path(self, fs_manager: MemoryManager) -> None: + _write(fs_manager, "auth/login", "# notes") + _write(fs_manager, "docs", "the auth/login process is documented elsewhere") + report = fs_manager.validate_referential_integrity() + assert len(report.high_confidence_unmarked_memories) == 1 + warning = report.high_confidence_unmarked_memories[0] + assert warning.suspected_name == "auth/login" + assert warning.is_high_confidence is True + assert warning.occurrences == 1 + assert report.low_confidence_unmarked_memories == [] + + def test_low_confidence_unmarked_warning_for_short_flat_name(self, fs_manager: MemoryManager) -> None: + _write(fs_manager, "auth", "# notes") + _write(fs_manager, "docs", "you must use auth here") + report = fs_manager.validate_referential_integrity() + assert report.high_confidence_unmarked_memories == [] + assert len(report.low_confidence_unmarked_memories) == 1 + assert report.low_confidence_unmarked_memories[0].suspected_name == "auth" + assert report.low_confidence_unmarked_memories[0].is_high_confidence is False + + def test_long_flat_name_is_high_confidence(self, fs_manager: MemoryManager) -> None: + # a flat name >= _HIGH_CONFIDENCE_NAME_LENGTH chars is unlikely to be coincidental prose + long_name = "authentication_flow" + assert len(long_name) >= HIGH_CONFIDENCE_NAME_LENGTH + _write(fs_manager, long_name, "# notes") + _write(fs_manager, "docs", f"the {long_name} is documented here") + report = fs_manager.validate_referential_integrity() + assert len(report.high_confidence_unmarked_memories) == 1 + + def test_fuzzy_near_miss_rejects_substring_only_matches(self, fs_manager: MemoryManager) -> None: + """ + Regression: fuzzy bare-text matching must require meaningful token overlap, not just + substring containment. Without the token-Jaccard floor, generic words like + ``Repository`` would be flagged as near-misses for ``serena_repository_structure`` + purely because they appear as substrings — a noise flood. The cases below are taken + from a real run on the Serena codebase that produced six false positives. + """ + _write(fs_manager, "serena_repository_structure", "# repo structure notes") + _write(fs_manager, "suggested_commands", "# command notes") + _write( + fs_manager, + "docs", + # generic words ("Repository", "repository") and unrelated code identifiers + # ("_create_launch_command", "create_launch_command", "repository_root_path") + # that happen to be substrings of, or share one token with, an existing memory name + """\ + See _create_launch_command and also create_launch_command. + The Repository pattern is documented elsewhere. + Also repository_root_path is set via env var. + The repository contains many tools. + """, + ) + report = fs_manager.validate_referential_integrity() + assert report.is_clean(), ( + f"Expected no findings; got high={report.high_confidence_unmarked_memories}, " + f"low={report.low_confidence_unmarked_memories}, stale={report.stale_references}" + ) + + def test_long_flat_name_near_miss_is_high_confidence(self, fs_manager: MemoryManager) -> None: + """ + Regression: bare text that is a *near-match* of an existing long memory name + (e.g. the same name with a missing suffix) should be flagged as a high-confidence + unmarked-reference warning. Long, distinctive multi-word tokens that appear bare in + prose and similarity-match an existing memory are almost certainly intended references. + + Real-world case: ``serena_repository_structure.md`` mentions + ``adding_new_language_support`` in prose; the actual memory is + ``adding_new_language_support_guide``. Exact-substring matching alone misses this. + """ + existing = "adding_new_language_support_guide" + bare = "adding_new_language_support" # prefix of `existing`; clearly the same concept + assert len(bare) >= HIGH_CONFIDENCE_NAME_LENGTH + _write(fs_manager, existing, "# guide content") + _write(fs_manager, "docs", f"See {bare} for details") + + report = fs_manager.validate_referential_integrity() + assert len(report.high_confidence_unmarked_memories) == 1, ( + f"expected one high-confidence warning for the near-miss reference, " + f"got {len(report.high_confidence_unmarked_memories)} " + f"(high={report.high_confidence_unmarked_memories}, low={report.low_confidence_unmarked_memories})" + ) + warning = report.high_confidence_unmarked_memories[0] + assert warning.suspected_name == existing + assert warning.source_memory == "docs" + + def test_fuzzy_accepts_bare_token_when_jaccard_just_above_floor(self, fs_manager: MemoryManager) -> None: + """ + Edge case: bare token is a clean substring of the existing memory name AND + their tokenized Jaccard sits just above ``_FUZZY_BARE_TOKEN_JACCARD_FLOOR`` + (with floor = 0.6, this means 2/3 ≈ 0.667). + """ + # existing has 3 name-tokens; bare has 2 of them -> jaccard = 2/3 = 0.667 + existing = "data-model-extra" + bare = "data-model" + assert len(bare) >= HIGH_CONFIDENCE_NAME_LENGTH + _write(fs_manager, existing, "# extra notes") + _write(fs_manager, "docs", f"see {bare} for naming") + report = fs_manager.validate_referential_integrity() + fuzzy = [w for w in report.high_confidence_unmarked_memories if not w.is_exact_match] + assert len(fuzzy) == 1 + assert fuzzy[0].suspected_name == existing + assert fuzzy[0].actual_token == bare + + def test_fuzzy_rejects_bare_token_when_jaccard_just_below_floor(self, fs_manager: MemoryManager) -> None: + """ + Edge case: bare token is still a clean substring of the existing memory name, + but their tokenized Jaccard is just below ``_FUZZY_BARE_TOKEN_JACCARD_FLOOR`` + (2/4 = 0.5 with the floor at 0.6). Must be rejected — without this gate, + long candidate names would attract many borderline matches. + """ + existing = "data-model-extra-stuff" # 4 tokens + bare = "data-model" # 2 tokens; jaccard = 2/4 = 0.5 + assert len(bare) >= HIGH_CONFIDENCE_NAME_LENGTH + _write(fs_manager, existing, "# notes") + _write(fs_manager, "docs", f"see {bare} for naming") + report = fs_manager.validate_referential_integrity() + assert report.high_confidence_unmarked_memories == [] + assert report.low_confidence_unmarked_memories == [] + + def test_fuzzy_requires_substring_containment(self, fs_manager: MemoryManager) -> None: + """ + Edge case: bare token and existing name share several tokens (and even a topic + prefix) but neither is a substring of the other. Without the containment + requirement, sequence-ratio alone would mis-rank long names like + ``frontend/X-subtleties`` vs ``frontend/Y-subtleties`` as near-misses. + """ + existing = "frontend/text-editor-workflow" + bare = "frontend/text-editor-features" # shares "frontend", "text", "editor"; not a substring + assert len(bare) >= HIGH_CONFIDENCE_NAME_LENGTH + _write(fs_manager, existing, "# workflow notes") + _write(fs_manager, "docs", f"see {bare} for layout") + report = fs_manager.validate_referential_integrity() + assert report.high_confidence_unmarked_memories == [] + assert report.low_confidence_unmarked_memories == [] + + def test_fuzzy_accepts_reverse_containment(self, fs_manager: MemoryManager) -> None: + """ + Edge case: the *existing* memory name is a substring of the longer bare token + (rather than the other way around). The containment gate must hold in both + directions, so this is still flagged. + """ + existing = "playwright-gestures" + bare = "playwright-gestures-guide" # existing ⊂ bare + assert len(bare) >= HIGH_CONFIDENCE_NAME_LENGTH + _write(fs_manager, existing, "# notes") + _write(fs_manager, "docs", f"see {bare} for details") + report = fs_manager.validate_referential_integrity() + fuzzy = [w for w in report.high_confidence_unmarked_memories if not w.is_exact_match] + assert len(fuzzy) == 1 + assert fuzzy[0].suspected_name == existing + assert fuzzy[0].actual_token == bare + + def test_unmarked_exact_match_suppressed_for_ignored_word(self, fs_manager: MemoryManager) -> None: + """ + Names in ``_WORDS_TO_IGNORE_AS_MEMORY_NAME_CANDIDATES`` (e.g. ``core``) are + common-word collisions and must not produce bare-occurrence warnings even + when an exact memory of that name exists. + """ + _write(fs_manager, "core", "# project core notes") + _write(fs_manager, "docs", "this is the core of the system") + report = fs_manager.validate_referential_integrity() + # bare "core" matches the candidate name `core` literally but is filtered out + assert all(w.suspected_name != "core" for w in report.high_confidence_unmarked_memories) + assert all(w.suspected_name != "core" for w in report.low_confidence_unmarked_memories) + + def test_unmarked_exact_match_suppressed_for_topic_with_ignored_basename(self, fs_manager: MemoryManager) -> None: + """ + The ignore filter applies to the *basename* of a candidate name, so a memory + named ``frontend/core`` is also exempt from bare-occurrence warnings (the + bare text ``frontend/core`` is more likely a directory reference than a + forgotten memory link). + """ + _write(fs_manager, "frontend/core", "# notes") + _write(fs_manager, "docs", "the frontend/core module is documented here") + report = fs_manager.validate_referential_integrity() + assert all(w.suspected_name != "frontend/core" for w in report.high_confidence_unmarked_memories) + assert all(w.suspected_name != "frontend/core" for w in report.low_confidence_unmarked_memories) + + def test_unmarked_exact_match_not_suppressed_for_non_ignored_basename(self, fs_manager: MemoryManager) -> None: + """ + Sanity: the ignore filter must not over-apply to names whose basename happens + to share a prefix with an ignored word. + """ + _write(fs_manager, "frontend/core-utils", "# notes") + _write(fs_manager, "docs", "the frontend/core-utils module is documented here") + report = fs_manager.validate_referential_integrity() + assert any(w.suspected_name == "frontend/core-utils" for w in report.high_confidence_unmarked_memories) + + def test_stale_reference_candidates_are_capped(self, fs_manager: MemoryManager) -> None: + """ + The candidate list proposed for an unresolved ``mem:`` reference is capped at + :attr:`MAX_STALE_REFERENCE_CANDIDATES`. Beyond a small number the list stops + aiding disambiguation and becomes noise. + """ + cap = MAX_STALE_REFERENCE_CANDIDATES + # five memories all sharing basename `login` -> all tie at compute_name_similarity == 1.0 + candidate_names = ["admin/login", "api/login", "auth/login", "security/login", "users/login"] + for name in candidate_names: + _write(fs_manager, name, "# notes") + _write(fs_manager, "docs", "old reference: `mem:login`") + report = fs_manager.validate_referential_integrity() + assert len(report.stale_references) == 1 + candidates = report.stale_references[0].candidates + # cap enforced; alphabetical tiebreak among equally-scored candidates + assert len(candidates) == cap + assert candidates == candidate_names[:cap] + + def test_include_unmarked_false_skips_both_scans(self, fs_manager: MemoryManager) -> None: + """ + With ``include_unmarked=False`` only stale references are reported; both the + exact unmarked scan and the fuzzy near-miss scan are entirely skipped. + """ + _write(fs_manager, "auth/login", "# notes") + _write(fs_manager, "playwright-gestures", "# notes") + _write( + fs_manager, + "docs", + "bare auth/login mention and a playwright-gestures-guide near-miss; also `mem:nope`", + ) + report = fs_manager.validate_referential_integrity(include_unmarked=False) + assert report.high_confidence_unmarked_memories == [] + assert report.low_confidence_unmarked_memories == [] + # stale reference is still reported regardless of the flag + assert len(report.stale_references) == 1 + assert report.stale_references[0].referenced_name == "nope" + + def test_include_fuzzy_matching_false_keeps_exact_only(self, fs_manager: MemoryManager) -> None: + """ + With ``include_unmarked=True`` but ``include_fuzzy_matching=False``, exact + bare-occurrence warnings still fire but fuzzy near-misses are suppressed. + """ + _write(fs_manager, "auth/login", "# notes") + _write(fs_manager, "playwright-gestures", "# notes") + _write( + fs_manager, + "docs", + "bare auth/login mention and a playwright-gestures-guide near-miss", + ) + report = fs_manager.validate_referential_integrity(include_unmarked=True, include_fuzzy_matching=False) + # exact match for auth/login is kept + exact = [w for w in report.high_confidence_unmarked_memories if w.is_exact_match] + fuzzy = [w for w in report.high_confidence_unmarked_memories if not w.is_exact_match] + assert len(exact) == 1 + assert exact[0].suspected_name == "auth/login" + # fuzzy near-miss for `playwright-gestures-guide` is suppressed + assert fuzzy == [] + + def test_self_reference_is_ignored(self, fs_manager: MemoryManager) -> None: + # the basename `login` appears in `auth/login`'s own content -> must not be flagged + _write(fs_manager, "auth/login", "this login flow ...") + _write(fs_manager, "auth", "# auth notes") + report = fs_manager.validate_referential_integrity() + # `auth/login` mentioning its own basename `login` should not produce a warning; + # `auth` mentioned in `auth/login` content also shouldn't (it's the prefix), and only + # actual matches matter — assert no warning concerns the source itself + for w in report.high_confidence_unmarked_memories + report.low_confidence_unmarked_memories: + assert not (w.source_memory == "auth/login" and w.suspected_name in ("login", "auth/login")) + + def test_marked_reference_does_not_produce_unmarked_warning(self, fs_manager: MemoryManager) -> None: + _write(fs_manager, "auth/login", "# notes") + _write(fs_manager, "docs", "see `mem:auth/login` for details") + report = fs_manager.validate_referential_integrity() + # the marked reference is valid; no warning should fire on that occurrence + assert report.is_clean() + + def test_mixed_marked_and_bare_in_same_content(self, fs_manager: MemoryManager) -> None: + _write(fs_manager, "auth/login", "# notes") + _write(fs_manager, "docs", "we have `mem:auth/login` plus a bare auth/login mention") + report = fs_manager.validate_referential_integrity() + # only the bare occurrence should be counted + assert len(report.high_confidence_unmarked_memories) == 1 + assert report.high_confidence_unmarked_memories[0].occurrences == 1 + + def test_empty_memory_skipped(self, fs_manager: MemoryManager) -> None: + _write(fs_manager, "auth/login", "") + _write(fs_manager, "docs", "# nothing relevant") + # must not raise even though one memory is empty + report = fs_manager.validate_referential_integrity() + assert report.is_clean() + + def test_read_only_status_propagated(self, tmp_path) -> None: + manager = MemoryManager(serena_data_folder=tmp_path, read_only_memory_patterns=[r"frozen/.*"]) + _write(manager, "frozen/notes", "see bare auth/login mention") + _write(manager, "auth/login", "# notes") + report = manager.validate_referential_integrity() + warnings = report.high_confidence_unmarked_memories + assert len(warnings) == 1 + assert warnings[0].source_memory == "frozen/notes" + assert warnings[0].source_is_read_only is True + + +class TestAutoPrefixBareReferences: + def test_prefixes_high_confidence_warning_in_writable_memory(self, fs_manager: MemoryManager) -> None: + _write(fs_manager, "auth/login", "# notes") + _write(fs_manager, "docs", "the auth/login process is documented here") + result = fs_manager.auto_prefix_bare_references() + assert result.total_replacements == 1 + assert len(result.autofixed) == 1 + # verify the file content was actually mutated + assert fs_manager.load_memory("docs") == "the mem:auth/login process is documented here" + + def test_skips_low_confidence_by_default(self, fs_manager: MemoryManager) -> None: + _write(fs_manager, "auth", "# notes") + _write(fs_manager, "docs", "use auth here") + result = fs_manager.auto_prefix_bare_references() + assert result.total_replacements == 0 + assert len(result.skipped_flat) == 1 + # content unchanged + assert fs_manager.load_memory("docs") == "use auth here" + + def test_include_flat_names_widens_scope(self, fs_manager: MemoryManager) -> None: + _write(fs_manager, "auth", "# notes") + _write(fs_manager, "docs", "use auth here") + result = fs_manager.auto_prefix_bare_references(include_flat_names=True) + assert result.total_replacements == 1 + assert fs_manager.load_memory("docs") == "use mem:auth here" + + def test_skips_read_only_source_by_default(self, tmp_path) -> None: + manager = MemoryManager(serena_data_folder=tmp_path, read_only_memory_patterns=[r"frozen/.*"]) + _write(manager, "frozen/notes", "the auth/login process") + _write(manager, "auth/login", "# notes") + result = manager.auto_prefix_bare_references() + assert result.total_replacements == 0 + assert len(result.skipped_read_only) == 1 + # content unchanged + assert manager.load_memory("frozen/notes") == "the auth/login process" + + def test_include_read_only_modifies_read_only_source(self, tmp_path) -> None: + manager = MemoryManager(serena_data_folder=tmp_path, read_only_memory_patterns=[r"frozen/.*"]) + _write(manager, "frozen/notes", "the auth/login process") + _write(manager, "auth/login", "# notes") + result = manager.auto_prefix_bare_references(include_read_only=True) + assert result.total_replacements == 1 + assert manager.load_memory("frozen/notes") == "the mem:auth/login process" + + def test_multiple_occurrences_in_same_memory(self, fs_manager: MemoryManager) -> None: + _write(fs_manager, "auth/login", "# notes") + _write(fs_manager, "docs", "auth/login here and auth/login there") + result = fs_manager.auto_prefix_bare_references() + assert result.total_replacements == 2 + assert fs_manager.load_memory("docs") == "mem:auth/login here and mem:auth/login there" + + def test_marked_references_left_alone(self, fs_manager: MemoryManager) -> None: + _write(fs_manager, "auth/login", "# notes") + _write(fs_manager, "docs", "see `mem:auth/login` and also auth/login bare") + result = fs_manager.auto_prefix_bare_references() + # only the bare occurrence is rewritten; the already-marked one stays as-is + assert result.total_replacements == 1 + assert fs_manager.load_memory("docs") == "see `mem:auth/login` and also mem:auth/login bare" + + def test_dry_run_does_not_modify_files(self, fs_manager: MemoryManager) -> None: + _write(fs_manager, "auth/login", "# notes") + _write(fs_manager, "docs", "the auth/login process is documented here") + result = fs_manager.auto_prefix_bare_references(dry_run=True) + # report describes what *would* have been done + assert result.dry_run is True + assert result.total_replacements == 1 + assert len(result.autofixed) == 1 + # but the file on disk is unchanged + assert fs_manager.load_memory("docs") == "the auth/login process is documented here" + # running again non-dry actually rewrites + result = fs_manager.auto_prefix_bare_references() + assert result.dry_run is False + assert fs_manager.load_memory("docs") == "the mem:auth/login process is documented here" + + def test_fuzzy_warnings_are_routed_to_skipped_fuzzy(self, fs_manager: MemoryManager) -> None: + # set up a fuzzy near-miss case (token shares all parts with a longer existing name) + existing = "adding_new_language_support_guide" + bare = "adding_new_language_support" + _write(fs_manager, existing, "# guide content") + _write(fs_manager, "docs", f"See {bare} for details") + result = fs_manager.auto_prefix_bare_references() + # fuzzy match must NOT be auto-rewritten — it would require substring substitution + assert result.total_replacements == 0 + assert len(result.skipped_fuzzy) == 1 + skipped = result.skipped_fuzzy[0] + assert skipped.actual_token == bare + assert skipped.suspected_name == existing + # source unchanged + assert fs_manager.load_memory("docs") == f"See {bare} for details" + + def test_no_double_prefix_on_repeated_runs(self, fs_manager: MemoryManager) -> None: + _write(fs_manager, "auth/login", "# notes") + _write(fs_manager, "docs", "the auth/login process") + fs_manager.auto_prefix_bare_references() + second = fs_manager.auto_prefix_bare_references() + # idempotent: the second run should not touch anything + assert second.total_replacements == 0 + assert fs_manager.load_memory("docs") == "the mem:auth/login process" diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py new file mode 100644 index 0000000..2ee299b --- /dev/null +++ b/test/serena/test_serena_agent.py @@ -0,0 +1,1428 @@ +import json +import logging +import os +import re +import time +from collections.abc import Iterator +from contextlib import contextmanager +from copy import copy +from dataclasses import dataclass +from typing import Literal, cast + +import pytest +from _pytest.mark import Mark, MarkDecorator, ParameterSet + +from serena.agent import SerenaAgent +from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig +from serena.project import Project +from serena.tools import ( + SUCCESS_RESULT, + ActivateProjectTool, + EditingToolWithDiagnostics, + FindDeclarationTool, + FindImplementationsTool, + FindReferencingSymbolsTool, + FindSymbolTool, + GetDiagnosticsForFileTool, + InitialInstructionsTool, + ReplaceContentTool, + ReplaceInFilesTool, + ReplaceSymbolBodyTool, + SafeDeleteSymbol, + Tool, +) +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from test.conftest import ( + find_identifier_pos, + get_pytest_markers, + get_repo_path, + language_tests_enabled, +) + + +@dataclass +class BaseCase: + language: Language + id: str + + def to_pytest_param(self, *marks: MarkDecorator | Mark) -> ParameterSet: + return pytest.param(self.language, self, marks=[*get_pytest_markers(self.language), *marks], id=self.id) + + +@dataclass +class FindSymbolCase(BaseCase): + symbol_name: str + expected_kind: str + expected_file: str + + +@dataclass +class FindReferenceCase(BaseCase): + symbol_name: str + definition_file: str + reference_file: str + + +@dataclass +class FindDefiningSymbolCase(BaseCase): + relative_path: str + identifier: str + occurrence_index: int + column_offset: int + expected_name: str + expected_definition_file: str + + +@dataclass +class RegexDefiningSymbolCase(BaseCase): + relative_path: str + regex: str + containing_symbol_name_path: str + expected_name: str + expected_definition_file: str + + +@dataclass +class RegexDefiningSymbolErrorCase(BaseCase): + relative_path: str + regex: str + containing_symbol_name_path: str + error_fragment: str + + +@dataclass +class FindImplementationCase(BaseCase): + symbol_name: str + definition_file: str + implementation_file: str + expected_symbol_name: str + + +@dataclass +class FindSymbolNamePathCase(BaseCase): + name_path: str + substring_matching: bool + expected_symbol_name: str + expected_kind: str + expected_file: str + + +@dataclass +class FindSymbolNoMatchCase(BaseCase): + name_path: str + + +@dataclass +class FindSymbolOverloadedCase(BaseCase): + name_path: str + num_expected: int + + +@dataclass +class NonUniqueSymbolReferenceCase(BaseCase): + name_path: str + relative_path: str + expected_error_fragment: str = "multiple" + + +@dataclass +class SafeDeleteCase(BaseCase): + name_path: str + relative_path: str + + def __post_init__(self) -> None: + object.__setattr__(self, "relative_path", self.relative_path.replace("\\", "/")) + + +@dataclass +class DiagnosticCase(BaseCase): + language: Language + relative_path: str + name_path1: str + name_path2: str | None + message_fragment1: str + message_fragment2: str | None + + @property + def symbol1_id_str(self) -> str: + return self.name_path1.split("/")[-1] + + @property + def symbol2_id_str(self) -> str | None: + if self.name_path2 is None: + return None + return self.name_path2.split("/")[-1] + + def without_second_symbol(self) -> "DiagnosticCase": + result = copy(self) + result.name_path2 = None + result.message_fragment2 = None + return result + + def assert_matches(self, tool_output: dict) -> None: + """ + + :param tool_output: Output of diagnostics tool, representing the mapping `relative_path -> severity -> name_path -> diagnostics_results`. + :return: + """ + assert self.relative_path in tool_output, ( + f"Missing diagnostics for relative path {self.relative_path} in tool output keys: {list(tool_output.keys())}" + ) + severity_group = tool_output[self.relative_path] + assert "Error" in severity_group, severity_group + name_path_group = severity_group["Error"] + + for expected_name_path in [self.name_path1, self.name_path2]: + if expected_name_path is not None: + assert expected_name_path in name_path_group, name_path_group + + diagnostic_messages = [ + diagnostic["message"] for diagnostics_for_name_path in name_path_group.values() for diagnostic in diagnostics_for_name_path + ] + for expected_fragment in [self.message_fragment1, self.message_fragment2]: + if expected_fragment is not None: + assert any(expected_fragment in message for message in diagnostic_messages), diagnostic_messages + + +DIAGNOSTIC_CASES = [ + DiagnosticCase( + language=Language.PYTHON, + id=f"{Language.PYTHON.value}_missing_user", + relative_path=os.path.join("test_repo", "diagnostics_sample.py"), + name_path1="broken_factory", + name_path2="broken_consumer", + message_fragment1="missing_user", + message_fragment2="undefined_name", + ).to_pytest_param(), + DiagnosticCase( + language=Language.CLOJURE, + id=f"{Language.CLOJURE.value}_missing-greeting", + relative_path=os.path.join("src", "test_app", "diagnostics_sample.clj"), + name_path1="broken-factory", + name_path2="broken-consumer", + message_fragment1="missing-greeting", + message_fragment2="missing-consumer-value", + ).to_pytest_param(), + DiagnosticCase( + language=Language.GO, + id=f"{Language.GO.value}_missingGreeting", + relative_path="diagnostics_sample.go", + name_path1="brokenFactory", + name_path2="brokenConsumer", + message_fragment1="missingGreeting", + message_fragment2="missingConsumerValue", + ).to_pytest_param(), + DiagnosticCase( + language=Language.TYPESCRIPT, + id=f"{Language.TYPESCRIPT.value}_missingGreeting", + relative_path="diagnostics_sample.ts", + name_path1="brokenFactory", + name_path2="brokenConsumer", + message_fragment1="missingGreeting", + message_fragment2="missingConsumerValue", + ).to_pytest_param(), +] + + +FIND_DEFINING_SYMBOL_CASES = [ + FindDefiningSymbolCase( + language=Language.PYTHON, + id="python_user_in_services", + relative_path=os.path.join("test_repo", "services.py"), + identifier="User", + occurrence_index=1, + column_offset=1, + expected_name="User", + expected_definition_file="models.py", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.PYTHON_TY, + id="python_ty_user_in_services", + relative_path=os.path.join("test_repo", "services.py"), + identifier="User", + occurrence_index=1, + column_offset=1, + expected_name="User", + expected_definition_file="models.py", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.GO, + id="go_helper_in_main", + relative_path="main.go", + identifier="Helper", + occurrence_index=0, + column_offset=1, + expected_name="Helper", + expected_definition_file="main.go", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.JAVA, + id="java_model_in_main", + relative_path=os.path.join("src", "main", "java", "test_repo", "Main.java"), + identifier="Model", + occurrence_index=0, + column_offset=1, + expected_name="Model", + expected_definition_file="Model.java", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.KOTLIN, + id="kotlin_model_in_main", + relative_path=os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), + identifier="Model", + occurrence_index=0, + column_offset=1, + expected_name="Model", + expected_definition_file="Model.kt", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.RUST, + id="rust_format_greeting", + relative_path=os.path.join("src", "main.rs"), + identifier="format_greeting", + occurrence_index=0, + column_offset=1, + expected_name="format_greeting", + expected_definition_file="lib.rs", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.PHP, + id="php_helper_function", + relative_path="index.php", + identifier="helperFunction", + occurrence_index=0, + column_offset=5, + expected_name="helperFunction", + expected_definition_file="helper.php", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.CLOJURE, + id="clojure_multiply_in_utils", + relative_path=os.path.join("src", "test_app", "utils.clj"), + identifier="multiply", + occurrence_index=0, + column_offset=1, + expected_name="multiply", + expected_definition_file=os.path.join("src", "test_app", "core.clj"), + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.CSHARP, + id="csharp_add_in_program", + relative_path="Program.cs", + identifier="Add", + occurrence_index=0, + column_offset=1, + expected_name="Add", + expected_definition_file="Program.cs", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.POWERSHELL, + id="powershell_convert_to_uppercase", + relative_path="main.ps1", + identifier="Convert-ToUpperCase", + occurrence_index=0, + column_offset=1, + expected_name="function Convert-ToUpperCase ()", + expected_definition_file="utils.ps1", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.CPP, + id="cpp_add_in_a", + relative_path="a.cpp", + identifier="add", + occurrence_index=0, + column_offset=1, + expected_name="add", + expected_definition_file="b.cpp", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.LEAN4, + id="lean_add_in_main", + relative_path="Main.lean", + identifier="add", + occurrence_index=1, + column_offset=1, + expected_name="add", + expected_definition_file="Helper.lean", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.TYPESCRIPT, + id="typescript_helper_function", + relative_path="index.ts", + identifier="helperFunction", + occurrence_index=1, + column_offset=1, + expected_name="helperFunction", + expected_definition_file="index.ts", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.FSHARP, + id="fsharp_add_in_program", + relative_path="Program.fs", + identifier="add", + occurrence_index=0, + column_offset=1, + expected_name="add", + expected_definition_file="Calculator.fs", + ).to_pytest_param( + pytest.mark.xfail(reason="F# language server cannot reliably resolve defining symbols"), + ), +] + +FIND_DEFINING_SYMBOL_REGEX_CASES = [ + RegexDefiningSymbolCase( + language=Language.PYTHON, + id="python_import_user", + relative_path=os.path.join("test_repo", "services.py"), + regex=r"from \.models import Item, (User)", + containing_symbol_name_path="", + expected_name="User", + expected_definition_file="models.py", + ).to_pytest_param(), + RegexDefiningSymbolCase( + language=Language.PYTHON, + id="python_create_user_call", + relative_path=os.path.join("test_repo", "services.py"), + regex=r"=\s+(User)\(", + containing_symbol_name_path="UserService/create_user", + expected_name="User", + expected_definition_file="models.py", + ).to_pytest_param(), + RegexDefiningSymbolCase( + language=Language.PYTHON_TY, + id="python_ty_create_user_call", + relative_path=os.path.join("test_repo", "services.py"), + regex=r"=\s+(User)\(", + containing_symbol_name_path="UserService/create_user", + expected_name="User", + expected_definition_file="models.py", + ).to_pytest_param(), + RegexDefiningSymbolCase( + language=Language.GO, + id="go_greeter_var", + relative_path="main.go", + regex=r"var greeter (Greeter) =", + containing_symbol_name_path="main", + expected_name="Greeter", + expected_definition_file="main.go", + ).to_pytest_param(), +] + +FIND_IMPLEMENTATION_CASES = [ + FindImplementationCase( + language=Language.CSHARP, + id="csharp_greeter_format", + symbol_name="IGreeter/FormatGreeting", + definition_file=os.path.join("Services", "IGreeter.cs"), + implementation_file=os.path.join("Services", "ConsoleGreeter.cs"), + expected_symbol_name="FormatGreeting", + ).to_pytest_param(), + FindImplementationCase( + language=Language.GO, + id="go_greeter_format", + symbol_name="Greeter/FormatGreeting", + definition_file="main.go", + implementation_file="main.go", + expected_symbol_name="FormatGreeting", + ).to_pytest_param(), + FindImplementationCase( + language=Language.JAVA, + id="java_greeter_format", + symbol_name="Greeter/formatGreeting", + definition_file=os.path.join("src", "main", "java", "test_repo", "Greeter.java"), + implementation_file=os.path.join("src", "main", "java", "test_repo", "ConsoleGreeter.java"), + expected_symbol_name="formatGreeting", + ).to_pytest_param(), + FindImplementationCase( + language=Language.RUST, + id="rust_greeter_format", + symbol_name="Greeter/format_greeting", + definition_file=os.path.join("src", "lib.rs"), + implementation_file=os.path.join("src", "lib.rs"), + expected_symbol_name="format_greeting", + ).to_pytest_param(), + FindImplementationCase( + language=Language.TYPESCRIPT, + id="typescript_greeter_format", + symbol_name="Greeter/formatGreeting", + definition_file="formatters.ts", + implementation_file="formatters.ts", + expected_symbol_name="formatGreeting", + ).to_pytest_param(), +] + + +FIND_SYMBOL_REFERENCES_CASES = [ + FindSymbolCase( + language=Language.PYTHON, id="python_user_class", symbol_name="User", expected_kind="Class", expected_file="models.py" + ).to_pytest_param(), + FindSymbolCase( + language=Language.GO, id="go_helper_function", symbol_name="Helper", expected_kind="Function", expected_file="main.go" + ).to_pytest_param(), + FindSymbolCase( + language=Language.JAVA, id="java_model_class", symbol_name="Model", expected_kind="Class", expected_file="Model.java" + ).to_pytest_param(), + FindSymbolCase( + language=Language.KOTLIN, id="kotlin_model_struct", symbol_name="Model", expected_kind="Struct", expected_file="Model.kt" + ).to_pytest_param(), + FindSymbolCase( + language=Language.TYPESCRIPT, + id="typescript_demo_class", + symbol_name="DemoClass", + expected_kind="Class", + expected_file="index.ts", + ).to_pytest_param(), + FindSymbolCase( + language=Language.PHP, + id="php_helper_function", + symbol_name="helperFunction", + expected_kind="Function", + expected_file="helper.php", + ).to_pytest_param(), + FindSymbolCase( + language=Language.CLOJURE, + id="clojure_greet_function", + symbol_name="greet", + expected_kind="Function", + expected_file=os.path.join("src", "test_app", "core.clj"), + ).to_pytest_param(), + FindSymbolCase( + language=Language.CSHARP, + id="csharp_calculator_class", + symbol_name="Calculator", + expected_kind="Class", + expected_file="Program.cs", + ).to_pytest_param(), + FindSymbolCase( + language=Language.POWERSHELL, + id="powershell_greet_user", + symbol_name="Greet-User", + expected_kind="Function", + expected_file="main.ps1", + ).to_pytest_param(), + FindSymbolCase( + language=Language.CPP, id="cpp_add_function", symbol_name="add", expected_kind="Function", expected_file="b.cpp" + ).to_pytest_param(), + FindSymbolCase( + language=Language.LEAN4, id="lean_add_method", symbol_name="add", expected_kind="Method", expected_file="Helper.lean" + ).to_pytest_param(), + FindSymbolCase( + language=Language.FSHARP, + id="fsharp_calculator_module", + symbol_name="Calculator", + expected_kind="Module", + expected_file="Calculator.fs", + ).to_pytest_param(pytest.mark.xfail(reason="F# language server is unreliable")), + FindSymbolCase( + language=Language.RUST, id="rust_add_function", symbol_name="add", expected_kind="Function", expected_file="lib.rs" + ).to_pytest_param(), + FindSymbolCase( + language=Language.LATEX, id="latex_methods_section", symbol_name="Methods", expected_kind="Module", expected_file="main.tex" + ).to_pytest_param(), +] + +FIND_REFERENCE_CASES = [ + FindReferenceCase( + language=Language.PYTHON, + id="python_user_refs", + symbol_name="User", + definition_file=os.path.join("test_repo", "models.py"), + reference_file=os.path.join("test_repo", "services.py"), + ).to_pytest_param(), + FindReferenceCase( + language=Language.GO, id="go_helper_refs", symbol_name="Helper", definition_file="main.go", reference_file="main.go" + ).to_pytest_param(), + FindReferenceCase( + language=Language.JAVA, + id="java_model_refs", + symbol_name="Model", + definition_file=os.path.join("src", "main", "java", "test_repo", "Model.java"), + reference_file=os.path.join("src", "main", "java", "test_repo", "Main.java"), + ).to_pytest_param(), + FindReferenceCase( + language=Language.KOTLIN, + id="kotlin_model_refs", + symbol_name="Model", + definition_file=os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"), + reference_file=os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), + ).to_pytest_param(), + FindReferenceCase( + language=Language.RUST, + id="rust_add_refs", + symbol_name="add", + definition_file=os.path.join("src", "lib.rs"), + reference_file=os.path.join("src", "main.rs"), + ).to_pytest_param(), + FindReferenceCase( + language=Language.PHP, + id="php_helper_refs", + symbol_name="helperFunction", + definition_file="helper.php", + reference_file="index.php", + ).to_pytest_param(), + FindReferenceCase( + language=Language.CLOJURE, + id="clojure_multiply_refs", + symbol_name="multiply", + definition_file=os.path.join("src", "test_app", "core.clj"), + reference_file=os.path.join("src", "test_app", "utils.clj"), + ).to_pytest_param(), + FindReferenceCase( + language=Language.CSHARP, + id="csharp_calculator_refs", + symbol_name="Calculator", + definition_file="Program.cs", + reference_file="Program.cs", + ).to_pytest_param(), + FindReferenceCase( + language=Language.POWERSHELL, + id="powershell_greet_user_refs", + symbol_name="Greet-User", + definition_file="main.ps1", + reference_file="main.ps1", + ).to_pytest_param(), + FindReferenceCase( + language=Language.CPP, id="cpp_add_refs", symbol_name="add", definition_file="b.cpp", reference_file="a.cpp" + ).to_pytest_param(), + FindReferenceCase( + language=Language.LEAN4, id="lean_add_refs", symbol_name="add", definition_file="Helper.lean", reference_file="Main.lean" + ).to_pytest_param(), + FindReferenceCase( + language=Language.TYPESCRIPT, + id="typescript_helper_refs", + symbol_name="helperFunction", + definition_file="index.ts", + reference_file="use_helper.ts", + ).to_pytest_param(pytest.mark.xfail(False, reason="TypeScript language server is unreliable")), + FindReferenceCase( + language=Language.FSHARP, id="fsharp_add_refs", symbol_name="add", definition_file="Calculator.fs", reference_file="Program.fs" + ).to_pytest_param( + pytest.mark.xfail(reason="F# language server is unreliable"), # See issue #1040 + ), + FindReferenceCase( + language=Language.LATEX, + id="latex_background_refs", + symbol_name="Background", + definition_file="sections/background.tex", + reference_file="main.tex", + ).to_pytest_param(), +] + +FIND_DEFINING_SYMBOL_REGEX_ERROR_CASES = [ + RegexDefiningSymbolErrorCase( + language=Language.PYTHON, + id="python_regex_multiple_matches", + relative_path=os.path.join("test_repo", "services.py"), + regex=r"(User)", + containing_symbol_name_path="", + error_fragment="Match must be unique", + ).to_pytest_param(), + RegexDefiningSymbolErrorCase( + language=Language.PYTHON, + id="python_regex_missing_group", + relative_path=os.path.join("test_repo", "services.py"), + regex=r"self.users.get\(id\)", + containing_symbol_name_path="UserService/get_user", + error_fragment="Regex must contain exactly one group", + ).to_pytest_param(), +] + +FIND_SYMBOL_NAME_PATH_CASES = [ + FindSymbolNamePathCase( + language=Language.PYTHON, + id="nested_class_exact", + name_path="OuterClass/NestedClass", + substring_matching=False, + expected_symbol_name="NestedClass", + expected_kind="Class", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), + FindSymbolNamePathCase( + language=Language.PYTHON, + id="nested_method_exact", + name_path="OuterClass/NestedClass/find_me", + substring_matching=False, + expected_symbol_name="find_me", + expected_kind="Method", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), + FindSymbolNamePathCase( + language=Language.PYTHON, + id="nested_class_substring", + name_path="OuterClass/NestedCl", + substring_matching=True, + expected_symbol_name="NestedClass", + expected_kind="Class", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), + FindSymbolNamePathCase( + language=Language.PYTHON, + id="nested_method_substring", + name_path="OuterClass/NestedClass/find_m", + substring_matching=True, + expected_symbol_name="find_me", + expected_kind="Method", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), + FindSymbolNamePathCase( + language=Language.PYTHON, + id="outer_class_absolute", + name_path="/OuterClass", + substring_matching=False, + expected_symbol_name="OuterClass", + expected_kind="Class", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), + FindSymbolNamePathCase( + language=Language.PYTHON, + id="nested_method_absolute_substring", + name_path="/OuterClass/NestedClass/find_m", + substring_matching=True, + expected_symbol_name="find_me", + expected_kind="Method", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), +] + +FIND_SYMBOL_NAME_PATH_NO_MATCH_CASES = [ + FindSymbolNoMatchCase(language=Language.PYTHON, id="nested_class_not_top_level", name_path="/NestedClass").to_pytest_param(), + FindSymbolNoMatchCase( + language=Language.PYTHON, id="nested_class_missing_parent", name_path="/NoSuchParent/NestedClass" + ).to_pytest_param(), +] + +FIND_SYMBOL_OVERLOADED_FUNCTION_CASES = [ + FindSymbolOverloadedCase( + language=Language.JAVA, id="java_overloaded_get_name", name_path="Model/getName", num_expected=2 + ).to_pytest_param(), +] + +NON_UNIQUE_SYMBOL_REFERENCE_ERROR_CASES = [ + NonUniqueSymbolReferenceCase( + language=Language.JAVA, + id="java_overloaded_get_name", + name_path="Model/getName", + relative_path=os.path.join("src", "main", "java", "test_repo", "Model.java"), + ).to_pytest_param(), +] + +SAFE_DELETE_BLOCKED_CASES = [ + SafeDeleteCase( + language=Language.PYTHON, + id="python_user", + name_path="User", + relative_path=os.path.join("test_repo", "models.py"), + ).to_pytest_param(), + SafeDeleteCase( + language=Language.JAVA, + id="java_model", + name_path="Model", + relative_path=os.path.join("src", "main", "java", "test_repo", "Model.java"), + ).to_pytest_param(), + SafeDeleteCase( + language=Language.KOTLIN, + id="kotlin_model", + name_path="Model", + relative_path=os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"), + ).to_pytest_param(), + SafeDeleteCase( + language=Language.TYPESCRIPT, + id="typescript_helper_function", + name_path="helperFunction", + relative_path="index.ts", + ).to_pytest_param(), +] + +SAFE_DELETE_SUCCEEDS_CASES = [ + SafeDeleteCase( + language=Language.PYTHON, + id="python_timer", + name_path="Timer", + relative_path=os.path.join("test_repo", "utils.py"), + ).to_pytest_param(), + SafeDeleteCase( + language=Language.JAVA, + id="java_model_user", + name_path="ModelUser", + relative_path=os.path.join("src", "main", "java", "test_repo", "ModelUser.java"), + ).to_pytest_param(), + SafeDeleteCase( + language=Language.KOTLIN, + id="kotlin_model_user", + name_path="ModelUser", + relative_path=os.path.join("src", "main", "kotlin", "test_repo", "ModelUser.kt"), + ).to_pytest_param(), + SafeDeleteCase( + language=Language.TYPESCRIPT, + id="typescript_unused_standalone_function", + name_path="unusedStandaloneFunction", + relative_path="index.ts", + ).to_pytest_param(), +] + + +@pytest.fixture +def serena_config(): + config = SerenaConfig(log_level=logging.ERROR).with_headless_mode_overrides() + + # Create test projects for all supported languages + test_projects = [] + for language in [ + Language.PYTHON, + Language.PYTHON_TY, + Language.GO, + Language.JAVA, + Language.KOTLIN, + Language.RUST, + Language.TYPESCRIPT, + Language.PHP, + Language.CSHARP, + Language.CLOJURE, + Language.FSHARP, + Language.POWERSHELL, + Language.CPP, + Language.HAXE, + Language.LEAN4, + Language.MSL, + Language.LATEX, + ]: + repo_path = get_repo_path(language) + if repo_path.exists(): + project_name = f"test_repo_{language}" + project = Project( + project_root=str(repo_path), + project_config=ProjectConfig( + project_name=project_name, + languages=[language], + ignored_paths=[], + excluded_tools=[], + read_only=False, + ignore_all_files_in_gitignore=True, + initial_prompt="", + encoding="utf-8", + ), + serena_config=config, + ) + test_projects.append(RegisteredProject.from_project_instance(project)) + + config.projects = test_projects + return config + + +def read_project_file(project: Project, relative_path: str) -> str: + """Utility function to read a file from the project.""" + file_path = os.path.join(project.project_root, relative_path) + with open(file_path, encoding=project.project_config.encoding) as f: + return f.read() + + +def parse_edit_diagnostics_result(result: str) -> dict: + """Utility function to parse the diagnostic payload returned by edit tools.""" + assert EditingToolWithDiagnostics.DIAGNOSTICS_KEY in result + d = json.loads(result) + return d[EditingToolWithDiagnostics.DIAGNOSTICS_KEY] + + +@contextmanager +def project_file_modification_context(serena_agent: SerenaAgent, relative_path: str) -> Iterator[None]: + """Context manager to modify a project file and revert the changes after use.""" + project = serena_agent.get_active_project() + file_path = os.path.join(project.project_root, relative_path) + + # Read the original content + original_content = read_project_file(project, relative_path) + + try: + yield + finally: + # Revert to the original content + with open(file_path, "w", encoding=project.project_config.encoding) as f: + f.write(original_content) + + +@pytest.fixture +def serena_agent(request: pytest.FixtureRequest, serena_config) -> Iterator[SerenaAgent]: + language = Language(request.param) + if not language_tests_enabled(language): + pytest.skip(f"Tests for language {language} are not enabled.") + + project_name = f"test_repo_{language}" + + agent = SerenaAgent(project=project_name, serena_config=serena_config) + + # wait for agent to be ready + agent.execute_task(lambda: None) + + yield agent + + # explicitly shut down to free resources + agent.on_shutdown(timeout=5) + + +class TestSerenaAgent: + @pytest.mark.parametrize( + "project", + [None, str(get_repo_path(Language.PYTHON)), "non_existent_path"], + ids=["no_project", "python_project_path", "invalid_project_path"], + ) + def test_agent_instantiation(self, project: str | None): + """ + Tests agent instantiation for cases where + * no project is specified at startup + * a valid project path is specified at startup + * an invalid project path is specified at startup + All cases must not raise an exception. + """ + serena_config = SerenaConfig().with_headless_mode_overrides() + SerenaAgent(project=project, serena_config=serena_config) + + def _symbol_matches_expected_name(self, symbol: dict, expected_name: str) -> bool: + return ( + symbol.get("name") == expected_name + or symbol.get("name_path", "").split("/")[-1] == expected_name + or expected_name in symbol.get("info", "") + ) + + def _assert_symbol_info_present( + self, + serena_agent: SerenaAgent, + symbol: dict, + expected_name: str | None = None, + ) -> None: + if serena_agent.get_active_lsp_languages() == [Language.KOTLIN]: + # kotlin LS doesn't seem to provide hover info right now, at least for the struct we test this on + return + + if symbol["kind"] in (SymbolKind.File.name, SymbolKind.Module.name): + # we ignore file and module symbols for the info test + return + + symbol_info = symbol.get("info") + assert symbol_info, f"Expected symbol info to be present for symbol: {symbol}" + + if expected_name is not None: + assert expected_name in symbol_info, ( + f"[{serena_agent.get_active_lsp_languages()[0]}] Expected symbol info to contain symbol name " + f"{expected_name}. Info: {symbol_info}" + ) + + # special additional test for Java, since Eclipse returns hover in a complex format and we want to make sure to get it right + if symbol["kind"] == SymbolKind.Class.name and serena_agent.get_active_lsp_languages() == [Language.JAVA]: + assert "A simple model class" in symbol_info, f"Java class docstring not found in symbol info: {symbol}" + + @pytest.mark.parametrize("serena_agent,case", FIND_SYMBOL_REFERENCES_CASES, indirect=["serena_agent"]) + def test_find_symbol(self, serena_agent: SerenaAgent, case: FindSymbolCase) -> None: + agent = serena_agent + find_symbol_tool = agent.get_tool(FindSymbolTool) + result = find_symbol_tool.apply(name_path_pattern=case.symbol_name, include_info=True) + symbols = json.loads(result) + assert any( + case.symbol_name in s["name_path"] + and case.expected_kind.lower() in s["kind"].lower() + and case.expected_file in s["relative_path"] + for s in symbols + ), ( + f"Expected to find {case.symbol_name} ({case.expected_kind}) in {case.expected_file}. Found name paths: {[s['name_path'] for s in symbols]}" + ) + for symbol in symbols: + self._assert_symbol_info_present(serena_agent, symbol, case.symbol_name) + + @pytest.mark.parametrize("serena_agent,case", FIND_REFERENCE_CASES, indirect=["serena_agent"]) + def test_find_symbol_references(self, serena_agent: SerenaAgent, case: FindReferenceCase) -> None: + # Find the symbol location first + find_symbol_tool = serena_agent.get_tool(FindSymbolTool) + result = find_symbol_tool.apply(name_path_pattern=case.symbol_name, relative_path=case.definition_file) + + time.sleep(1) + symbols = json.loads(result) + # Find the definition + def_symbol = symbols[0] + + # Now find references + find_refs_tool = serena_agent.get_tool(FindReferencingSymbolsTool) + result = find_refs_tool.apply(name_path=def_symbol["name_path"], relative_path=def_symbol["relative_path"]) + + def contains_ref_with_relative_path(refs, relative_path): + """ + Checks for reference to relative path, regardless of output format (grouped or ungrouped) + """ + if isinstance(refs, list): + for ref in refs: + if contains_ref_with_relative_path(ref, relative_path): + return True + elif isinstance(refs, dict): + if relative_path in refs: + return True + for value in refs.values(): + if contains_ref_with_relative_path(value, relative_path): + return True + return False + + refs = json.loads(result) + assert contains_ref_with_relative_path(refs, case.reference_file), ( + f"Expected to find reference to {case.symbol_name} in {case.reference_file}. refs={refs}" + ) + + @pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_REGEX_CASES, indirect=["serena_agent"]) + def test_find_declaration(self, serena_agent: SerenaAgent, case: RegexDefiningSymbolCase) -> None: + tool = serena_agent.get_tool(FindDeclarationTool) + result = tool.apply( + regex=case.regex, + relative_path=case.relative_path, + containing_symbol_name_path=case.containing_symbol_name_path, + include_info=True, + ) + defining_symbol = json.loads(result) + assert defining_symbol is not None, f"Expected defining symbol for regex {case.regex!r} in {case.relative_path}" + assert defining_symbol.get("relative_path") is not None + assert case.expected_definition_file in defining_symbol["relative_path"], ( + f"Expected defining symbol in {case.expected_definition_file!r}, got: {defining_symbol}" + ) + assert self._symbol_matches_expected_name(defining_symbol, case.expected_name), ( + f"Expected defining symbol name {case.expected_name!r}, got: {defining_symbol}" + ) + self._assert_symbol_info_present(serena_agent, defining_symbol) + + @pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_REGEX_ERROR_CASES, indirect=["serena_agent"]) + def test_find_declaration_error( + self, + serena_agent: SerenaAgent, + case: RegexDefiningSymbolErrorCase, + ) -> None: + tool = serena_agent.get_tool(FindDeclarationTool) + with pytest.raises(ValueError, match=case.error_fragment): + tool.apply( + regex=case.regex, + relative_path=case.relative_path, + containing_symbol_name_path=case.containing_symbol_name_path, + ) + + @pytest.mark.parametrize("serena_agent,diagnostic_case", DIAGNOSTIC_CASES, indirect=["serena_agent"]) + def test_get_diagnostics_for_file(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None: + diagnostics_tool = serena_agent.get_tool(GetDiagnosticsForFileTool) + result = diagnostics_tool.apply( + relative_path=diagnostic_case.relative_path, + min_severity=1, + ) + full_file_diagnostics = json.loads(result) + diagnostic_case.assert_matches(full_file_diagnostics) + + # testing diagnostics in range by removing second symbol + project_root = get_repo_path(diagnostic_case.language) + pos1 = find_identifier_pos(project_root / diagnostic_case.relative_path, diagnostic_case.symbol1_id_str) + pos2 = find_identifier_pos(project_root / diagnostic_case.relative_path, cast(str, diagnostic_case.symbol2_id_str)) + assert pos1 is not None + assert pos2 is not None + result = diagnostics_tool.apply( + relative_path=diagnostic_case.relative_path, + min_severity=1, + start_line=pos1[0], + end_line=pos2[0] - 1, + ) + diagnostics_in_range = json.loads(result) + diagnostic_case.without_second_symbol().assert_matches(diagnostics_in_range) + + @pytest.mark.parametrize("serena_agent,case", FIND_IMPLEMENTATION_CASES, indirect=["serena_agent"]) + def test_find_symbol_implementations(self, serena_agent: SerenaAgent, case: FindImplementationCase) -> None: + agent = serena_agent + find_symbol_tool = agent.get_tool(FindSymbolTool) + result = find_symbol_tool.apply(name_path_pattern=case.symbol_name, relative_path=case.definition_file) + symbols = json.loads(result) + assert symbols, f"Expected to find symbol {case.symbol_name} in {case.definition_file}" + def_symbol = symbols[0] + find_impl_tool = agent.get_tool(FindImplementationsTool) + result = find_impl_tool.apply(name_path=def_symbol["name_path"], relative_path=def_symbol["relative_path"], include_info=True) + implementations = json.loads(result) + assert any( + case.implementation_file in implementation["relative_path"] + and self._symbol_matches_expected_name(implementation, case.expected_symbol_name) + for implementation in implementations + ), f"Expected to find implementation of {case.symbol_name} in {case.implementation_file}. implementations={implementations}" + for implementation in implementations: + self._assert_symbol_info_present(serena_agent, implementation) + + @pytest.mark.parametrize("serena_agent,case", FIND_SYMBOL_NAME_PATH_CASES, indirect=["serena_agent"]) + def test_find_symbol_name_path(self, serena_agent: SerenaAgent, case: FindSymbolNamePathCase) -> None: + agent = serena_agent + + find_symbol_tool = agent.get_tool(FindSymbolTool) + result = find_symbol_tool.apply_ex( + name_path_pattern=case.name_path, + depth=0, + relative_path=None, + include_body=False, + include_kinds=None, + exclude_kinds=None, + substring_matching=case.substring_matching, + ) + + symbols = json.loads(result) + assert any( + case.expected_symbol_name == s["name_path"].split("/")[-1] + and case.expected_kind.lower() in s["kind"].lower() + and case.expected_file in s["relative_path"] + for s in symbols + ), f"Expected to find {case.name_path} ({case.expected_kind}) in {case.expected_file}. Symbols: {symbols}" + + @pytest.mark.parametrize("serena_agent,case", FIND_SYMBOL_NAME_PATH_NO_MATCH_CASES, indirect=["serena_agent"]) + def test_find_symbol_name_path_no_match(self, serena_agent: SerenaAgent, case: FindSymbolNoMatchCase) -> None: + agent = serena_agent + + find_symbol_tool = agent.get_tool(FindSymbolTool) + result = find_symbol_tool.apply_ex( + name_path_pattern=case.name_path, + depth=0, + substring_matching=True, + ) + + symbols = json.loads(result) + assert not symbols, f"Expected to find no symbols for {case.name_path}. Symbols found: {symbols}" + + @pytest.mark.parametrize("serena_agent,case", FIND_SYMBOL_OVERLOADED_FUNCTION_CASES, indirect=["serena_agent"]) + def test_find_symbol_overloaded_function(self, serena_agent: SerenaAgent, case: FindSymbolOverloadedCase) -> None: + """ + Tests whether the FindSymbolTool can find all overloads of a function/method + (provided that the overload id remains unspecified in the name path) + """ + agent = serena_agent + + find_symbol_tool = agent.get_tool(FindSymbolTool) + result = find_symbol_tool.apply_ex( + name_path_pattern=case.name_path, + depth=0, + substring_matching=False, + ) + + symbols = json.loads(result) + assert len(symbols) == case.num_expected, ( + f"Expected to find {case.num_expected} symbols for overloaded function {case.name_path}. Symbols found: {symbols}" + ) + + @pytest.mark.parametrize("serena_agent,case", NON_UNIQUE_SYMBOL_REFERENCE_ERROR_CASES, indirect=["serena_agent"]) + def test_non_unique_symbol_reference_error( + self, + serena_agent: SerenaAgent, + case: NonUniqueSymbolReferenceCase, + ) -> None: + """ + Tests whether the tools operating on a well-defined symbol raises an error when the symbol reference is non-unique. + We exemplarily test a retrieval tool (FindReferencingSymbolsTool) and an editing tool (ReplaceSymbolBodyTool). + """ + find_refs_tool = serena_agent.get_tool(FindReferencingSymbolsTool) + with pytest.raises(ValueError, match=case.expected_error_fragment): + find_refs_tool.apply(name_path=case.name_path, relative_path=case.relative_path) + + replace_symbol_body_tool = serena_agent.get_tool(ReplaceSymbolBodyTool) + with pytest.raises(ValueError, match=case.expected_error_fragment): + replace_symbol_body_tool.apply(name_path=case.name_path, relative_path=case.relative_path, body="") + + @pytest.mark.parametrize( + "serena_agent", + [ + pytest.param(Language.TYPESCRIPT, marks=get_pytest_markers(Language.TYPESCRIPT), id="typescript_unique_regex"), + ], + indirect=["serena_agent"], + ) + def test_replace_content_regex_with_wildcard_ok(self, serena_agent: SerenaAgent): + """ + Tests a regex-based content replacement that has a unique match + """ + relative_path = "ws_manager.js" + with project_file_modification_context(serena_agent, relative_path): + replace_content_tool = serena_agent.get_tool(ReplaceContentTool) + result = replace_content_tool.apply( + needle=r'catch \(error\) \{\s*console.error\("Failed to connect.*?\}', + repl='catch(error) {console.log("Never mind"); }', + relative_path=relative_path, + mode="regex", + ) + assert result == SUCCESS_RESULT + + @pytest.mark.parametrize( + "serena_agent", + [ + pytest.param(Language.TYPESCRIPT, marks=get_pytest_markers(Language.TYPESCRIPT), id="typescript_backslashes"), + ], + indirect=["serena_agent"], + ) + @pytest.mark.parametrize("mode", ["literal", "regex"], ids=["literal_mode", "regex_mode"]) + def test_replace_content_with_backslashes(self, serena_agent: SerenaAgent, mode: Literal["literal", "regex"]): + """ + Tests a content replacement where the needle and replacement strings contain backslashes. + This is a regression test for escaping issues. + """ + relative_path = "ws_manager.js" + needle = r'console.log("WebSocketManager initializing\nStatus OK");' + repl = r'console.log("WebSocketManager initialized\nAll systems go!");' + replace_content_tool = serena_agent.get_tool(ReplaceContentTool) + with project_file_modification_context(serena_agent, relative_path): + result = replace_content_tool.apply( + needle=re.escape(needle) if mode == "regex" else needle, + repl=repl, + relative_path=relative_path, + mode=mode, + ) + assert result == SUCCESS_RESULT + new_content = read_project_file(serena_agent.get_active_project(), relative_path) + assert repl in new_content + + @pytest.mark.parametrize( + "serena_agent", + [ + pytest.param(Language.PYTHON, marks=get_pytest_markers(Language.PYTHON), id="python_replace_in_files"), + ], + indirect=["serena_agent"], + ) + def test_replace_in_files_dry_run_then_selective_apply(self, serena_agent: SerenaAgent): + """A dry run lists every occurrence as a diff with an id and modifies nothing; a follow-up call + restricted to one of the ids replaces exactly that occurrence. + """ + relative_path = os.path.join("test_repo", "models.py") + needle = "name: str | None = None" + repl = "name: str | None = MARKER_DEFAULT" + tool = serena_agent.get_tool(ReplaceInFilesTool) + with project_file_modification_context(serena_agent, relative_path): + original_content = read_project_file(serena_agent.get_active_project(), relative_path) + assert original_content.count(needle) >= 2 + + listing = tool.apply(needle=needle, repl=repl, mode="literal", relative_path=relative_path, dry_run=True) + assert "DRY RUN" in listing + occurrence_ids = re.findall(r"\[([^\[\]]+:\d+@[0-9a-f]{6})\]", listing) + assert len(occurrence_ids) == original_content.count(needle) + assert read_project_file(serena_agent.get_active_project(), relative_path) == original_content + + result = tool.apply(needle=needle, repl=repl, mode="literal", relative_path=relative_path, occurrence_ids=[occurrence_ids[0]]) + assert "Replaced 1 occurrence(s) in 1 file(s)" in result + new_content = read_project_file(serena_agent.get_active_project(), relative_path) + assert new_content.count("MARKER_DEFAULT") == 1 + + @pytest.mark.parametrize( + "serena_agent", + [ + pytest.param(Language.PYTHON, marks=get_pytest_markers(Language.PYTHON), id="python_replace_in_files_guard"), + ], + indirect=["serena_agent"], + ) + def test_replace_in_files_expected_count_guard_returns_listing(self, serena_agent: SerenaAgent): + """A blind call with a wrong expected_count must change nothing and return the prospective + changes (the failed guard doubles as a dry run). + """ + relative_path = os.path.join("test_repo", "models.py") + needle = "name: str | None = None" + tool = serena_agent.get_tool(ReplaceInFilesTool) + with project_file_modification_context(serena_agent, relative_path): + original_content = read_project_file(serena_agent.get_active_project(), relative_path) + with pytest.raises(ValueError, match="NO changes were applied") as exc_info: + tool.apply(needle=needle, repl="X", mode="literal", relative_path=relative_path, expected_count=1) + assert re.search(r"\[[^\[\]]+:\d+@[0-9a-f]{6}\]", str(exc_info.value)) # the listing with ids is included + assert read_project_file(serena_agent.get_active_project(), relative_path) == original_content + + @pytest.mark.parametrize( + "serena_agent", + [ + pytest.param(Language.PYTHON, marks=get_pytest_markers(Language.PYTHON), id="python_services"), + pytest.param(Language.PYTHON_TY, marks=get_pytest_markers(Language.PYTHON_TY), id="python_ty_services"), + ], + indirect=["serena_agent"], + ) + def test_replace_content_reports_new_diagnostics(self, serena_agent: SerenaAgent): + """Tests that file-level edits report newly introduced diagnostics.""" + relative_path = os.path.join("test_repo", "services.py") + replace_content_tool = serena_agent.get_tool(ReplaceContentTool) + try: + replace_content_tool.ENABLE_DIAGNOSTICS = True + + with project_file_modification_context(serena_agent, relative_path): + result = replace_content_tool.apply( + relative_path=relative_path, + needle="return container", + repl="return missing_container", + mode="literal", + ) + + diagnostics = parse_edit_diagnostics_result(result) + relative_path_result = diagnostics[relative_path] + diagnostic_messages = json.dumps(relative_path_result) + assert "missing_container" in diagnostic_messages + assert "create_service_container" in diagnostic_messages + finally: + replace_content_tool.ENABLE_DIAGNOSTICS = False + + @pytest.mark.parametrize( + "serena_agent", + [ + pytest.param(Language.PYTHON, marks=get_pytest_markers(Language.PYTHON), id="python_container_body"), + pytest.param(Language.PYTHON_TY, marks=get_pytest_markers(Language.PYTHON_TY), id="python_ty_container_body"), + ], + indirect=["serena_agent"], + ) + def test_replace_symbol_body_reports_new_diagnostics(self, serena_agent: SerenaAgent): + """Tests that symbol-level edits report newly introduced diagnostics.""" + relative_path = os.path.join("test_repo", "services.py") + replace_symbol_body_tool = serena_agent.get_tool(ReplaceSymbolBodyTool) + try: + replace_symbol_body_tool.ENABLE_DIAGNOSTICS = True + + with project_file_modification_context(serena_agent, relative_path): + result = replace_symbol_body_tool.apply( + name_path="create_service_container", + relative_path=relative_path, + body=""" + def create_service_container() -> dict[str, Any]: + return missing_container + """, + ) + + diagnostics = parse_edit_diagnostics_result(result) + relative_path_result = diagnostics[relative_path] + diagnostic_messages = json.dumps(relative_path_result) + assert "missing_container" in diagnostic_messages + assert "create_service_container" in diagnostic_messages + finally: + replace_symbol_body_tool.ENABLE_DIAGNOSTICS = False + + @pytest.mark.parametrize( + "serena_agent", + [ + pytest.param(Language.TYPESCRIPT, marks=get_pytest_markers(Language.TYPESCRIPT), id="typescript_ambiguous_regex"), + ], + indirect=["serena_agent"], + ) + def test_replace_content_regex_with_wildcard_ambiguous(self, serena_agent: SerenaAgent): + """ + Tests that an ambiguous replacement where there is a larger match that internally contains + a smaller match triggers an exception + """ + replace_content_tool = serena_agent.get_tool(ReplaceContentTool) + with pytest.raises(ValueError, match="ambiguous"): + replace_content_tool.apply( + needle=r'catch \(error\) \{.*?this\.updateConnectionStatus\("Connection failed", false\);.*?\}', + repl='catch(error) {console.log("Never mind"); }', + relative_path="ws_manager.js", + mode="regex", + ) + + @pytest.mark.parametrize("serena_agent,case", SAFE_DELETE_BLOCKED_CASES, indirect=["serena_agent"]) + def test_safe_delete_symbol_blocked_by_references(self, serena_agent: SerenaAgent, case: SafeDeleteCase): + """ + Tests that SafeDeleteSymbol refuses to delete a symbol that is referenced elsewhere + and returns a message listing the referencing files. + """ + # wrap in modification context as a safety net: if the tool has a bug and deletes anyway, + # the file will be restored, preventing corruption of test resources + with project_file_modification_context(serena_agent, case.relative_path): + safe_delete_tool = serena_agent.get_tool(SafeDeleteSymbol) + result = safe_delete_tool.apply(name_path_pattern=case.name_path, relative_path=case.relative_path) + assert "Cannot delete" in result, f"Expected deletion to be blocked due to existing references, but got: {result}" + assert "referenced in" in result, f"Expected reference information in result, but got: {result}" + + @pytest.mark.parametrize("serena_agent,case", SAFE_DELETE_SUCCEEDS_CASES, indirect=["serena_agent"]) + def test_safe_delete_symbol_succeeds_when_no_references(self, serena_agent: SerenaAgent, case: SafeDeleteCase): + """ + Tests that SafeDeleteSymbol successfully deletes a symbol that has no references + and that the symbol is actually removed from the file. + """ + with project_file_modification_context(serena_agent, case.relative_path): + safe_delete_tool = serena_agent.get_tool(SafeDeleteSymbol) + result = safe_delete_tool.apply(name_path_pattern=case.name_path, relative_path=case.relative_path) + assert result == SUCCESS_RESULT, f"Expected successful deletion, but got: {result}" + + # verify the symbol was actually removed from the file + file_content = read_project_file(serena_agent.get_active_project(), case.relative_path) + assert case.name_path not in file_content, ( + f"Expected symbol {case.name_path} to be removed from {case.relative_path}, but it still appears in the file content" + ) + + +class TestPromptProvision: + class MockContext: + def __init__(self, session_id: str): + self.session = session_id + + @classmethod + def _call_tool(cls, agent: SerenaAgent, tool_class: type[Tool], session_id: str = "global", **kwargs) -> str: + result = agent.get_tool(tool_class).apply_ex(mcp_ctx=cls.MockContext(session_id), **kwargs) + return result + + @staticmethod + def _assert_activation_message(result: str, project_name: str, present: bool) -> None: + regex = r"^The project with name '" + project_name + r"'.*?is activated.$" + match = re.search(regex, result, re.MULTILINE) + if present: + assert match is not None, f"Expected project activation message in result:\n{result}" + else: + assert match is None, f"Expected no project activation message in result:\n{result}" + + @pytest.mark.parametrize("serena_agent", [Language.PYTHON], indirect=True) + def test_initial_instructions_provide_project_activation_message_once_per_session(self, serena_agent: SerenaAgent) -> None: + """ + Tests that the project activation message is provided on the first call to InitialInstructionsTool for a session, + but not on subsequent calls within the same session. #1372 + """ + project_name = "test_repo_python" + session1 = "session1" + session2 = "session2" + + result1 = self._call_tool(serena_agent, InitialInstructionsTool, session_id=session1) + self._assert_activation_message(result1, project_name, present=True) + + result2 = self._call_tool(serena_agent, InitialInstructionsTool, session_id=session2) + self._assert_activation_message(result2, project_name, present=True) + + result3 = self._call_tool(serena_agent, InitialInstructionsTool, session_id=session1) + self._assert_activation_message(result3, project_name, present=False) + + @pytest.mark.parametrize("serena_agent", [Language.PYTHON], indirect=True) + def test_dynamically_activated_mode_is_provided_once_per_session(self, serena_agent: SerenaAgent) -> None: + """ + Tests that when a new project is activated within a session that has a different mode configuration (e.g. no-onboarding), + the new mode's prompts are provided at project activation but not in subsequent initial instructions calls within the same + session, while they are provided in the initial instructions of a new session. + """ + project_name1 = "test_repo_python" + project_name2 = "test_repo_java" + session1 = "session1" + session2 = "session2" + + # the initial instructions must contain the project activation message for the first project + result1 = self._call_tool(serena_agent, InitialInstructionsTool, session_id=session1) + self._assert_activation_message(result1, project_name1, present=True) + + # now activate another project which dynamically enables a new mode (no-onboarding) + reg_project = serena_agent.serena_config.get_registered_project(project_name2) + reg_project.project_config.default_modes = ["no-onboarding"] + expected_new_mode_message = "The onboarding process is not applied." + result2 = self._call_tool(serena_agent, ActivateProjectTool, project=project_name2, session_id=session1) + + # the new mode's prompt must be included in the activation message + self._assert_activation_message(result2, project_name2, present=True) + assert expected_new_mode_message in result2, ( + f"Expected new mode message '{expected_new_mode_message}' not found in result:\n{result2}" + ) + + # the mode prompt must not be included in subsequent calls to the initial instructions tool within the same session + result3 = self._call_tool(serena_agent, InitialInstructionsTool, session_id=session1) + assert expected_new_mode_message not in result3, ( + f"Expected new mode message '{expected_new_mode_message}' to not be included in subsequent calls, but it was found in result:\n{result3}" + ) + + # the mode prompt must be included in the initial instructions of a new session + result4 = self._call_tool(serena_agent, InitialInstructionsTool, session_id=session2) + assert expected_new_mode_message in result4, ( + f"Expected new mode message '{expected_new_mode_message}' to be included in new session, but it was not found in result:\n{result4}" + ) + + # the initial instructions for the new session must also include the activation message for the project + self._assert_activation_message(result4, project_name2, present=True) + + @pytest.mark.parametrize("serena_agent", [Language.PYTHON], indirect=True) + def test_activate_project_tool_always_returns_activation_message(self, serena_agent: SerenaAgent) -> None: + project_name = "test_repo_python" + session = "session1" + + result1 = self._call_tool(serena_agent, ActivateProjectTool, project=project_name, session_id=session) + self._assert_activation_message(result1, project_name, present=True) + + result2 = self._call_tool(serena_agent, ActivateProjectTool, project=project_name, session_id=session) + self._assert_activation_message(result2, project_name, present=True) diff --git a/test/serena/test_symbol.py b/test/serena/test_symbol.py new file mode 100644 index 0000000..db31527 --- /dev/null +++ b/test/serena/test_symbol.py @@ -0,0 +1,447 @@ +from unittest.mock import MagicMock + +import pytest + +from serena.jetbrains.jetbrains_types import SymbolDTO, SymbolDTOKey +from serena.project import Project +from serena.symbol import LanguageServerSymbol, LanguageServerSymbolRetriever, NamePathComponent, NamePathMatcher +from test.solidlsp.conftest import PYTHON_BACKEND_LANGUAGES + + +class TestSymbolNameMatching: + def _create_assertion_error_message( + self, + name_path_pattern: str, + name_path_components: list[NamePathComponent], + is_substring_match: bool, + expected_result: bool, + actual_result: bool, + ) -> str: + """Helper to create a detailed error message for assertions.""" + qnp_repr = "/".join(map(str, name_path_components)) + + return ( + f"Pattern '{name_path_pattern}' (substring: {is_substring_match}) vs " + f"Name path components {name_path_components} (as '{qnp_repr}'). " + f"Expected: {expected_result}, Got: {actual_result}" + ) + + @pytest.mark.parametrize( + "name_path_pattern, symbol_name_path_parts, is_substring_match, expected", + [ + # Exact matches, anywhere in the name (is_substring_match=False) + pytest.param("foo", ["foo"], False, True, id="'foo' matches 'foo' exactly (simple)"), + pytest.param("foo/", ["foo"], False, True, id="'foo/' matches 'foo' exactly (simple)"), + pytest.param("foo", ["bar", "foo"], False, True, id="'foo' matches ['bar', 'foo'] exactly (simple, last element)"), + pytest.param("foo", ["foobar"], False, False, id="'foo' does not match 'foobar' exactly (simple)"), + pytest.param( + "foo", ["bar", "foobar"], False, False, id="'foo' does not match ['bar', 'foobar'] exactly (simple, last element)" + ), + pytest.param( + "foo", ["path", "to", "foo"], False, True, id="'foo' matches ['path', 'to', 'foo'] exactly (simple, last element)" + ), + # Exact matches, absolute patterns (is_substring_match=False) + pytest.param("/foo", ["foo"], False, True, id="'/foo' matches ['foo'] exactly (absolute simple)"), + pytest.param("/foo", ["foo", "bar"], False, False, id="'/foo' does not match ['foo', 'bar'] (absolute simple, len mismatch)"), + pytest.param("/foo", ["bar"], False, False, id="'/foo' does not match ['bar'] (absolute simple, name mismatch)"), + pytest.param( + "/foo", ["bar", "foo"], False, False, id="'/foo' does not match ['bar', 'foo'] (absolute simple, position mismatch)" + ), + # Substring matches, anywhere in the name (is_substring_match=True) + pytest.param("foo", ["foobar"], True, True, id="'foo' matches 'foobar' as substring (simple)"), + pytest.param("foo", ["bar", "foobar"], True, True, id="'foo' matches ['bar', 'foobar'] as substring (simple, last element)"), + pytest.param( + "foo", ["barfoo"], True, True, id="'foo' matches 'barfoo' as substring (simple)" + ), # This was potentially ambiguous before + pytest.param("foo", ["baz"], True, False, id="'foo' does not match 'baz' as substring (simple)"), + pytest.param("foo", ["bar", "baz"], True, False, id="'foo' does not match ['bar', 'baz'] as substring (simple, last element)"), + pytest.param("foo", ["my_foobar_func"], True, True, id="'foo' matches 'my_foobar_func' as substring (simple)"), + pytest.param( + "foo", + ["ClassA", "my_foobar_method"], + True, + True, + id="'foo' matches ['ClassA', 'my_foobar_method'] as substring (simple, last element)", + ), + pytest.param("foo", ["my_bar_func"], True, False, id="'foo' does not match 'my_bar_func' as substring (simple)"), + # Substring matches, absolute patterns (is_substring_match=True) + pytest.param("/foo", ["foobar"], True, True, id="'/foo' matches ['foobar'] as substring (absolute simple)"), + pytest.param("/foo/", ["foobar"], True, True, id="'/foo/' matches ['foobar'] as substring (absolute simple, last element)"), + pytest.param("/foo", ["barfoobaz"], True, True, id="'/foo' matches ['barfoobaz'] as substring (absolute simple)"), + pytest.param( + "/foo", ["foo", "bar"], True, False, id="'/foo' does not match ['foo', 'bar'] as substring (absolute simple, len mismatch)" + ), + pytest.param("/foo", ["bar"], True, False, id="'/foo' does not match ['bar'] (absolute simple, no substr)"), + pytest.param( + "/foo", ["bar", "foo"], True, False, id="'/foo' does not match ['bar', 'foo'] (absolute simple, position mismatch)" + ), + pytest.param( + "/foo/", ["bar", "foo"], True, False, id="'/foo/' does not match ['bar', 'foo'] (absolute simple, position mismatch)" + ), + ], + ) + def test_match_simple_name(self, name_path_pattern, symbol_name_path_parts, is_substring_match, expected): + """Tests matching for simple names (no '/' in pattern).""" + symbol_name_path_components = [NamePathComponent(part) for part in symbol_name_path_parts] + result = NamePathMatcher(name_path_pattern, is_substring_match).matches_reversed_components(reversed(symbol_name_path_components)) + error_msg = self._create_assertion_error_message(name_path_pattern, symbol_name_path_parts, is_substring_match, expected, result) + assert result == expected, error_msg + + @pytest.mark.parametrize( + "name_path_pattern, symbol_name_path_parts, is_substring_match, expected", + [ + # --- Relative patterns (suffix matching) --- + # Exact matches, relative patterns (is_substring_match=False) + pytest.param("bar/foo", ["bar", "foo"], False, True, id="R: 'bar/foo' matches ['bar', 'foo'] exactly"), + pytest.param("bar/foo", ["mod", "bar", "foo"], False, True, id="R: 'bar/foo' matches ['mod', 'bar', 'foo'] exactly (suffix)"), + pytest.param( + "bar/foo", ["bar", "foo", "baz"], False, False, id="R: 'bar/foo' does not match ['bar', 'foo', 'baz'] (pattern shorter)" + ), + pytest.param("bar/foo", ["bar"], False, False, id="R: 'bar/foo' does not match ['bar'] (pattern longer)"), + pytest.param("bar/foo", ["baz", "foo"], False, False, id="R: 'bar/foo' does not match ['baz', 'foo'] (first part mismatch)"), + pytest.param("bar/foo", ["bar", "baz"], False, False, id="R: 'bar/foo' does not match ['bar', 'baz'] (last part mismatch)"), + pytest.param("bar/foo", ["foo"], False, False, id="R: 'bar/foo' does not match ['foo'] (pattern longer)"), + pytest.param( + "bar/foo", ["other", "foo"], False, False, id="R: 'bar/foo' does not match ['other', 'foo'] (first part mismatch)" + ), + pytest.param( + "bar/foo", ["bar", "otherfoo"], False, False, id="R: 'bar/foo' does not match ['bar', 'otherfoo'] (last part mismatch)" + ), + # Substring matches, relative patterns (is_substring_match=True) + pytest.param("bar/foo", ["bar", "foobar"], True, True, id="R: 'bar/foo' matches ['bar', 'foobar'] as substring"), + pytest.param( + "bar/foo", ["mod", "bar", "foobar"], True, True, id="R: 'bar/foo' matches ['mod', 'bar', 'foobar'] as substring (suffix)" + ), + pytest.param("bar/foo", ["bar", "bazfoo"], True, True, id="R: 'bar/foo' matches ['bar', 'bazfoo'] as substring"), + pytest.param("bar/fo", ["bar", "foo"], True, True, id="R: 'bar/fo' matches ['bar', 'foo'] as substring"), # codespell:ignore + pytest.param("bar/foo", ["bar", "baz"], True, False, id="R: 'bar/foo' does not match ['bar', 'baz'] (last no substr)"), + pytest.param( + "bar/foo", ["baz", "foobar"], True, False, id="R: 'bar/foo' does not match ['baz', 'foobar'] (first part mismatch)" + ), + pytest.param( + "bar/foo", ["bar", "my_foobar_method"], True, True, id="R: 'bar/foo' matches ['bar', 'my_foobar_method'] as substring" + ), + pytest.param( + "bar/foo", + ["mod", "bar", "my_foobar_method"], + True, + True, + id="R: 'bar/foo' matches ['mod', 'bar', 'my_foobar_method'] as substring (suffix)", + ), + pytest.param( + "bar/foo", + ["bar", "another_method"], + True, + False, + id="R: 'bar/foo' does not match ['bar', 'another_method'] (last no substr)", + ), + pytest.param( + "bar/foo", + ["other", "my_foobar_method"], + True, + False, + id="R: 'bar/foo' does not match ['other', 'my_foobar_method'] (first part mismatch)", + ), + pytest.param("bar/f", ["bar", "foo"], True, True, id="R: 'bar/f' matches ['bar', 'foo'] as substring"), + # Exact matches, absolute patterns (is_substring_match=False) + pytest.param("/bar/foo", ["bar", "foo"], False, True, id="A: '/bar/foo' matches ['bar', 'foo'] exactly"), + pytest.param( + "/bar/foo", ["bar", "foo", "baz"], False, False, id="A: '/bar/foo' does not match ['bar', 'foo', 'baz'] (pattern shorter)" + ), + pytest.param("/bar/foo", ["bar"], False, False, id="A: '/bar/foo' does not match ['bar'] (pattern longer)"), + pytest.param("/bar/foo", ["baz", "foo"], False, False, id="A: '/bar/foo' does not match ['baz', 'foo'] (first part mismatch)"), + pytest.param( + "/bar/foo", + ["baz", "bar", "foo"], + False, + False, + id="A: '/bar/foo' does not match ['baz', 'bar', 'foo'] (only suffix match for abs pattern)", + ), + pytest.param("/bar/foo", ["bar", "baz"], False, False, id="A: '/bar/foo' does not match ['bar', 'baz'] (last part mismatch)"), + # Substring matches (is_substring_match=True) + pytest.param("/bar/foo", ["bar", "foobar"], True, True, id="A: '/bar/foo' matches ['bar', 'foobar'] as substring"), + pytest.param("/bar/foo", ["bar", "bazfoo"], True, True, id="A: '/bar/foo' matches ['bar', 'bazfoo'] as substring"), + pytest.param("/bar/fo", ["bar", "foo"], True, True, id="A: '/bar/fo' matches ['bar', 'foo'] as substring"), # codespell:ignore + pytest.param("/bar/foo", ["bar", "baz"], True, False, id="A: '/bar/foo' does not match ['bar', 'baz'] (last no substr)"), + pytest.param( + "/bar/foo", ["baz", "foobar"], True, False, id="A: '/bar/foo' does not match ['baz', 'foobar'] (first part mismatch)" + ), + ], + ) + def test_match_name_path_pattern_path_len_2(self, name_path_pattern, symbol_name_path_parts, is_substring_match, expected): + """Tests matching for qualified names (e.g. 'module/class/func').""" + symbol_name_path_components = [NamePathComponent(part) for part in symbol_name_path_parts] + result = NamePathMatcher(name_path_pattern, is_substring_match).matches_reversed_components(reversed(symbol_name_path_components)) + error_msg = self._create_assertion_error_message(name_path_pattern, symbol_name_path_parts, is_substring_match, expected, result) + assert result == expected, error_msg + + @pytest.mark.parametrize( + "name_path_pattern, symbol_name_path_components, expected", + [ + pytest.param( + "bar/foo", + [NamePathComponent("bar"), NamePathComponent("foo", 0)], + True, + id="R: 'bar/foo' matches ['bar', 'foo'] with overload_index=0", + ), + pytest.param( + "bar/foo", + [NamePathComponent("bar"), NamePathComponent("foo", 1)], + True, + id="R: 'bar/foo' matches ['bar', 'foo'] with overload_index=1", + ), + pytest.param( + "bar/foo[0]", + [NamePathComponent("bar"), NamePathComponent("foo", 0)], + True, + id="R: 'bar/foo[0]' matches ['bar', 'foo'] with overload_index=0", + ), + pytest.param( + "bar/foo[1]", + [NamePathComponent("bar"), NamePathComponent("foo", 0)], + False, + id="R: 'bar/foo[1]' does not match ['bar', 'foo'] with overload_index=0", + ), + pytest.param( + "bar/foo", [NamePathComponent("bar", 0), NamePathComponent("foo")], True, id="R: 'bar/foo' matches ['bar[0]', 'foo']" + ), + pytest.param( + "bar/foo", [NamePathComponent("bar", 0), NamePathComponent("foo", 1)], True, id="R: 'bar/foo' matches ['bar[0]', 'foo[1]']" + ), + pytest.param( + "bar[0]/foo", [NamePathComponent("bar", 0), NamePathComponent("foo")], True, id="R: 'bar[0]/foo' matches ['bar[0]', 'foo']" + ), + pytest.param( + "bar[0]/foo[1]", + [NamePathComponent("bar", 0), NamePathComponent("foo", 1)], + True, + id="R: 'bar[0]/foo[1]' matches ['bar[0]', 'foo[1]']", + ), + pytest.param( + "bar[0]/foo[1]", + [NamePathComponent("bar", 1), NamePathComponent("foo", 0)], + False, + id="R: 'bar[0]/foo[1]' does not match ['bar[1]', 'foo[0]']", + ), + ], + ) + def test_match_name_path_pattern_with_overload_idx(self, name_path_pattern, symbol_name_path_components, expected): + """Tests matching for qualified names (e.g. 'module/class/func').""" + matcher = NamePathMatcher(name_path_pattern, False) + result = matcher.matches_reversed_components(reversed(symbol_name_path_components)) + error_msg = self._create_assertion_error_message(name_path_pattern, symbol_name_path_components, False, expected, result) + assert result == expected, error_msg + + +@pytest.mark.python +class TestLanguageServerSymbolRetriever: + @pytest.mark.parametrize("project_with_ls", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_request_info(self, project_with_ls: Project): + symbol_retriever = LanguageServerSymbolRetriever(project_with_ls) + create_user_method_symbol = symbol_retriever.find("UserService/create_user", within_relative_path="test_repo/services.py")[0] + create_user_method_symbol_info = symbol_retriever.request_info_for_symbol(create_user_method_symbol) + assert "Create a new user and store it" in create_user_method_symbol_info + + +class TestSymbolDictTypes: + @staticmethod + def check_key_type(dict_type: type, key_type: type): + """ + :param dict_type: a TypedDict type + :param key_type: the corresponding key type (Literal[...]) that the dict should have for keys + """ + dict_type_keys = dict_type.__annotations__.keys() + assert len(dict_type_keys) == len(key_type.__args__), ( + f"Expected {len(key_type.__args__)} keys in {dict_type}, but got {len(dict_type_keys)}" + ) + for expected_key in key_type.__args__: + assert expected_key in dict_type_keys, f"Expected key '{expected_key}' not found in {dict_type}" + + def test_ls_symbol_dict_type(self): + self.check_key_type(LanguageServerSymbol.OutputDict, LanguageServerSymbol.OutputDictKey) + + def test_jb_symbol_dict_type(self): + self.check_key_type(SymbolDTO, SymbolDTOKey) + + +def _make_mock_symbols(count: int, *, relative_path: str = "test_repo/services.py") -> list[MagicMock]: + symbols: list[MagicMock] = [] + for i in range(count): + sym = MagicMock() + sym.relative_path = relative_path + sym.line = i + 1 + sym.column = 0 + sym.symbol_root = {} + symbols.append(sym) + return symbols + + +@pytest.mark.python +class TestHoverBudget: + """Tests for symbol_info_budget time budget behavior.""" + + @pytest.mark.parametrize("project_with_ls", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_budget_not_exceeded_all_lookups_performed(self, project_with_ls: Project, monkeypatch: pytest.MonkeyPatch): + """With a large budget, all hover lookups are performed.""" + # Create symbol retriever with a mock agent that has large budget + project_with_ls.serena_config.symbol_info_budget = 10.0 + project_with_ls.project_config.symbol_info_budget = 10.0 + + symbol_retriever = LanguageServerSymbolRetriever(project_with_ls) + + # Track _request_info calls + call_count = 0 + + def counting_request_info(file_path, line, column, **kwargs): + nonlocal call_count + call_count += 1 + return f"info:{line}:{column}" + + monkeypatch.setattr(symbol_retriever, "_request_info", counting_request_info) + + # Create mock symbols with unique (line, col) pairs + symbols = _make_mock_symbols(3) + + result = symbol_retriever.request_info_for_symbol_batch(symbols) + + # All 3 symbols should have info (no budget exceeded) + assert call_count == 3 + assert all(info is not None for info in result.values()) + assert len(result) == 3 + + @pytest.mark.parametrize("project_with_ls", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_budget_exceeded_partial_info(self, project_with_ls: Project, monkeypatch: pytest.MonkeyPatch): + """With a small budget, hover lookups stop and remaining symbols get None info.""" + project_with_ls.serena_config.symbol_info_budget = 0.1 + project_with_ls.project_config.symbol_info_budget = 0.1 + + symbol_retriever = LanguageServerSymbolRetriever(project_with_ls) + + # Track _request_info calls and simulate 0.05s per call + call_count = 0 + simulated_time = [0.0] + + def slow_request_info(file_path, line, column, **kwargs): + nonlocal call_count + call_count += 1 + # Simulate each hover taking 0.05s + simulated_time[0] += 0.05 + return f"info:{line}:{column}" + + # Mock perf_counter to return simulated time for hover duration + def mock_perf_counter(): + return simulated_time[0] + + monkeypatch.setattr(symbol_retriever, "_request_info", slow_request_info) + monkeypatch.setattr("serena.symbol.perf_counter", mock_perf_counter) + + # Create 5 mock symbols with unique (line, col) pairs + symbols = _make_mock_symbols(5) + + result = symbol_retriever.request_info_for_symbol_batch(symbols) + + # Budget is 0.1s, each call takes 0.05s, so only 2 calls should succeed + # After 2 calls: 0.1s >= 0.1s budget, remaining 3 should be skipped + assert call_count == 2 + assert len(result) == 5 + + # First 2 symbols should have info, last 3 should be None + result_list = list(result.values()) + assert result_list[0] is not None + assert result_list[1] is not None + assert result_list[2] is None + assert result_list[3] is None + assert result_list[4] is None + + @pytest.mark.parametrize("project_with_ls", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_budget_zero_means_unlimited(self, project_with_ls: Project, monkeypatch: pytest.MonkeyPatch): + """With budget=0, all hover lookups proceed (no early stopping).""" + project_with_ls.serena_config.symbol_info_budget = 0.0 + project_with_ls.project_config.symbol_info_budget = 0.0 + + symbol_retriever = LanguageServerSymbolRetriever(project_with_ls) + + # Track _request_info calls + call_count = 0 + + def counting_request_info(file_path, line, column, **kwargs): + nonlocal call_count + call_count += 1 + return f"info:{line}:{column}" + + monkeypatch.setattr(symbol_retriever, "_request_info", counting_request_info) + + # Create mock symbols + symbols = _make_mock_symbols(5) + + result = symbol_retriever.request_info_for_symbol_batch(symbols) + + # All 5 symbols should be looked up (no budget limit) + assert call_count == 5 + assert all(info is not None for info in result.values()) + + @pytest.mark.parametrize("project_with_ls", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_project_budget_overrides_global(self, project_with_ls: Project, monkeypatch: pytest.MonkeyPatch): + """Project-level budget overrides global budget.""" + # Create symbol retriever with global budget 10.0 but project budget 0.05 + project_with_ls.project_config.symbol_info_budget = 0.05 + project_with_ls.serena_config.symbol_info_budget = 10.0 + + symbol_retriever = LanguageServerSymbolRetriever(project_with_ls) + + # Track _request_info calls and simulate time + call_count = 0 + simulated_time = [0.0] + + def slow_request_info(file_path, line, column, **kwargs): + nonlocal call_count + call_count += 1 + simulated_time[0] += 0.03 + return f"info:{line}:{column}" + + def mock_perf_counter(): + return simulated_time[0] + + monkeypatch.setattr(symbol_retriever, "_request_info", slow_request_info) + monkeypatch.setattr("serena.symbol.perf_counter", mock_perf_counter) + + # Create 5 mock symbols + symbols = _make_mock_symbols(5) + + symbol_retriever.request_info_for_symbol_batch(symbols) + + # Project budget is 0.05s, each call takes 0.03s + # Budget check happens BEFORE starting a new call: + # - Before call 1: spent=0 < 0.05, proceed, spent becomes 0.03 + # - Before call 2: spent=0.03 < 0.05, proceed, spent becomes 0.06 + # - Before call 3: spent=0.06 >= 0.05, skip + # So 2 calls succeed (proving project budget 0.05 overrode global 10.0) + assert call_count == 2 + + @pytest.mark.parametrize("project_with_ls", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_project_null_inherits_global(self, project_with_ls: Project, monkeypatch: pytest.MonkeyPatch): + """When project budget is None, global budget is used.""" + # Create symbol retriever with project budget=None (inherit global) + project_with_ls.project_config.symbol_info_budget = None + project_with_ls.serena_config.symbol_info_budget = 10.0 + + symbol_retriever = LanguageServerSymbolRetriever(project_with_ls) + + # Track _request_info calls + call_count = 0 + + def counting_request_info(file_path, line, column, **kwargs): + nonlocal call_count + call_count += 1 + return f"info:{line}:{column}" + + monkeypatch.setattr(symbol_retriever, "_request_info", counting_request_info) + + # Create 3 mock symbols + symbols = _make_mock_symbols(3) + + result = symbol_retriever.request_info_for_symbol_batch(symbols) + + # Global budget is 10s, all 3 should succeed + assert call_count == 3 + assert all(info is not None for info in result.values()) diff --git a/test/serena/test_symbol_editing.py b/test/serena/test_symbol_editing.py new file mode 100644 index 0000000..97a02aa --- /dev/null +++ b/test/serena/test_symbol_editing.py @@ -0,0 +1,639 @@ +""" +Snapshot tests using the (awesome) syrupy pytest plugin https://github.com/syrupy-project/syrupy. +Recreate the snapshots with `pytest --snapshot-update`. +""" + +import logging +import os +import shutil +import tempfile +import time +from abc import ABC, abstractmethod +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass, field +from difflib import SequenceMatcher +from pathlib import Path +from typing import Literal, NamedTuple + +import pytest +from overrides import overrides +from syrupy import SnapshotAssertion + +from serena.code_editor import CodeEditor, LanguageServerCodeEditor +from solidlsp.ls_config import Language +from src.serena.symbol import LanguageServerSymbolRetriever +from test.conftest import get_repo_path, language_tests_enabled, project_with_ls_context + +pytestmark = pytest.mark.snapshot + +log = logging.getLogger(__name__) + + +class LineChange(NamedTuple): + """Represents a change to a specific line or range of lines.""" + + operation: Literal["insert", "delete", "replace"] + original_start: int + original_end: int + modified_start: int + modified_end: int + original_lines: list[str] + modified_lines: list[str] + + +@dataclass +class CodeDiff: + """ + Represents the difference between original and modified code. + Provides object-oriented access to diff information including line numbers. + """ + + relative_path: str + original_content: str + modified_content: str + _line_changes: list[LineChange] = field(init=False) + + def __post_init__(self) -> None: + """Compute the diff using difflib's SequenceMatcher.""" + original_lines = self.original_content.splitlines(keepends=True) + modified_lines = self.modified_content.splitlines(keepends=True) + + matcher = SequenceMatcher(None, original_lines, modified_lines) + self._line_changes = [] + + for tag, orig_start, orig_end, mod_start, mod_end in matcher.get_opcodes(): + if tag == "equal": + continue + if tag == "insert": + self._line_changes.append( + LineChange( + operation="insert", + original_start=orig_start, + original_end=orig_start, + modified_start=mod_start, + modified_end=mod_end, + original_lines=[], + modified_lines=modified_lines[mod_start:mod_end], + ) + ) + elif tag == "delete": + self._line_changes.append( + LineChange( + operation="delete", + original_start=orig_start, + original_end=orig_end, + modified_start=mod_start, + modified_end=mod_start, + original_lines=original_lines[orig_start:orig_end], + modified_lines=[], + ) + ) + elif tag == "replace": + self._line_changes.append( + LineChange( + operation="replace", + original_start=orig_start, + original_end=orig_end, + modified_start=mod_start, + modified_end=mod_end, + original_lines=original_lines[orig_start:orig_end], + modified_lines=modified_lines[mod_start:mod_end], + ) + ) + + @property + def line_changes(self) -> list[LineChange]: + """Get all line changes in the diff.""" + return self._line_changes + + @property + def has_changes(self) -> bool: + """Check if there are any changes.""" + return len(self._line_changes) > 0 + + @property + def added_lines(self) -> list[tuple[int, str]]: + """Get all added lines with their line numbers (0-based) in the modified file.""" + result = [] + for change in self._line_changes: + if change.operation in ("insert", "replace"): + for i, line in enumerate(change.modified_lines): + result.append((change.modified_start + i, line)) + return result + + @property + def deleted_lines(self) -> list[tuple[int, str]]: + """Get all deleted lines with their line numbers (0-based) in the original file.""" + result = [] + for change in self._line_changes: + if change.operation in ("delete", "replace"): + for i, line in enumerate(change.original_lines): + result.append((change.original_start + i, line)) + return result + + @property + def modified_line_numbers(self) -> list[int]: + """Get all line numbers (0-based) that were modified in the modified file.""" + line_nums: set[int] = set() + for change in self._line_changes: + if change.operation in ("insert", "replace"): + line_nums.update(range(change.modified_start, change.modified_end)) + return sorted(line_nums) + + @property + def affected_original_line_numbers(self) -> list[int]: + """Get all line numbers (0-based) that were affected in the original file.""" + line_nums: set[int] = set() + for change in self._line_changes: + if change.operation in ("delete", "replace"): + line_nums.update(range(change.original_start, change.original_end)) + return sorted(line_nums) + + def get_unified_diff(self, context_lines: int = 3) -> str: + """Get the unified diff as a string.""" + import difflib + + original_lines = self.original_content.splitlines(keepends=True) + modified_lines = self.modified_content.splitlines(keepends=True) + + diff = difflib.unified_diff( + original_lines, modified_lines, fromfile=f"a/{self.relative_path}", tofile=f"b/{self.relative_path}", n=context_lines + ) + return "".join(diff) + + def get_context_diff(self, context_lines: int = 3) -> str: + """Get the context diff as a string.""" + import difflib + + original_lines = self.original_content.splitlines(keepends=True) + modified_lines = self.modified_content.splitlines(keepends=True) + + diff = difflib.context_diff( + original_lines, modified_lines, fromfile=f"a/{self.relative_path}", tofile=f"b/{self.relative_path}", n=context_lines + ) + return "".join(diff) + + +class EditingTest(ABC): + def __init__(self, language: Language, rel_path: str): + """ + :param language: the language + :param rel_path: the relative path of the edited file + """ + self.rel_path = rel_path + self.language = language + self.original_repo_path = get_repo_path(language) + self.repo_path: Path | None = None + + @contextmanager + def _setup(self) -> Iterator[LanguageServerSymbolRetriever]: + """Context manager for setup/teardown with a temporary directory, providing the symbol manager.""" + temp_dir = Path(tempfile.mkdtemp()) + self.repo_path = temp_dir / self.original_repo_path.name + try: + print(f"Copying repo from {self.original_repo_path} to {self.repo_path}") + shutil.copytree(self.original_repo_path, self.repo_path) + # prevent deadlock on Windows due to file locks caused by antivirus or some other external software + # wait for a long time here + if os.name == "nt": + time.sleep(0.1) + log.info(f"Creating language server for {self.language} {self.rel_path}") + with project_with_ls_context(self.language, str(self.repo_path)) as project: + yield LanguageServerSymbolRetriever(project) + finally: + # prevent deadlock on Windows due to lingering file locks + if os.name == "nt": + time.sleep(0.1) + log.info(f"Removing temp directory {temp_dir}") + shutil.rmtree(temp_dir, ignore_errors=True) + log.info(f"Temp directory {temp_dir} removed") + + def _read_file(self, rel_path: str) -> str: + """Read the content of a file in the test repository.""" + assert self.repo_path is not None + file_path = self.repo_path / rel_path + with open(file_path, encoding="utf-8") as f: + return f.read() + + def run_test(self, content_after_ground_truth: SnapshotAssertion) -> None: + with self._setup() as symbol_retriever: + content_before = self._read_file(self.rel_path) + code_editor = LanguageServerCodeEditor(symbol_retriever) + self._apply_edit(code_editor) + content_after = self._read_file(self.rel_path) + code_diff = CodeDiff(self.rel_path, original_content=content_before, modified_content=content_after) + self._test_diff(code_diff, content_after_ground_truth) + + @abstractmethod + def _apply_edit(self, code_editor: CodeEditor) -> None: + pass + + def _test_diff(self, code_diff: CodeDiff, snapshot: SnapshotAssertion) -> None: + assert code_diff.has_changes, f"Sanity check failed: No changes detected in {code_diff.relative_path}" + assert code_diff.modified_content == snapshot + + +# Python test file path +PYTHON_TEST_REL_FILE_PATH = os.path.join("test_repo", "variables.py") + +# TypeScript test file path +TYPESCRIPT_TEST_FILE = "index.ts" + + +class DeleteSymbolTest(EditingTest): + def __init__(self, language: Language, rel_path: str, deleted_symbol: str): + super().__init__(language, rel_path) + self.deleted_symbol = deleted_symbol + self.rel_path = rel_path + + def _apply_edit(self, code_editor: CodeEditor) -> None: + code_editor.delete_symbol(self.deleted_symbol, self.rel_path) + + +@pytest.mark.parametrize( + "test_case", + [ + pytest.param( + DeleteSymbolTest( + Language.PYTHON, + PYTHON_TEST_REL_FILE_PATH, + "VariableContainer", + ), + marks=pytest.mark.python, + ), + pytest.param( + DeleteSymbolTest( + Language.TYPESCRIPT, + TYPESCRIPT_TEST_FILE, + "DemoClass", + ), + marks=pytest.mark.typescript, + ), + ], +) +def test_delete_symbol(test_case, snapshot: SnapshotAssertion): + test_case.run_test(content_after_ground_truth=snapshot) + + +NEW_PYTHON_FUNCTION = """def new_inserted_function(): + print("This is a new function inserted before another.")""" + +NEW_PYTHON_CLASS_WITH_LEADING_NEWLINES = """ + +class NewInsertedClass: + pass +""" + +NEW_PYTHON_CLASS_WITH_TRAILING_NEWLINES = """class NewInsertedClass: + pass + + +""" + +NEW_TYPESCRIPT_FUNCTION = """function newInsertedFunction(): void { + console.log("This is a new function inserted before another."); +}""" + + +NEW_PYTHON_VARIABLE = 'new_module_var = "Inserted after typed_module_var"' + +NEW_TYPESCRIPT_FUNCTION_AFTER = """function newFunctionAfterClass(): void { + console.log("This function is after DemoClass."); +}""" + + +class InsertInRelToSymbolTest(EditingTest): + def __init__( + self, language: Language, rel_path: str, symbol_name: str, new_content: str, mode: Literal["before", "after"] | None = None + ): + super().__init__(language, rel_path) + self.symbol_name = symbol_name + self.new_content = new_content + self.mode: Literal["before", "after"] | None = mode + + def set_mode(self, mode: Literal["before", "after"]): + self.mode = mode + + def _apply_edit(self, code_editor: CodeEditor) -> None: + assert self.mode is not None + if self.mode == "before": + code_editor.insert_before_symbol(self.symbol_name, self.rel_path, self.new_content) + elif self.mode == "after": + code_editor.insert_after_symbol(self.symbol_name, self.rel_path, self.new_content) + + +@pytest.mark.parametrize("mode", ["before", "after"]) +@pytest.mark.parametrize( + "test_case", + [ + pytest.param( + InsertInRelToSymbolTest( + Language.PYTHON, + PYTHON_TEST_REL_FILE_PATH, + "use_module_variables", + NEW_PYTHON_FUNCTION, + ), + marks=pytest.mark.python, + ), + pytest.param( + InsertInRelToSymbolTest( + Language.TYPESCRIPT, + TYPESCRIPT_TEST_FILE, + "DemoClass", + NEW_TYPESCRIPT_FUNCTION_AFTER, + ), + marks=pytest.mark.typescript, + ), + pytest.param( + InsertInRelToSymbolTest( + Language.TYPESCRIPT, + TYPESCRIPT_TEST_FILE, + "helperFunction", + NEW_TYPESCRIPT_FUNCTION, + ), + marks=pytest.mark.typescript, + ), + ], +) +def test_insert_in_rel_to_symbol(test_case: InsertInRelToSymbolTest, mode: Literal["before", "after"], snapshot: SnapshotAssertion): + test_case.set_mode(mode) + test_case.run_test(content_after_ground_truth=snapshot) + + +@pytest.mark.python +def test_insert_python_class_before(snapshot: SnapshotAssertion): + InsertInRelToSymbolTest( + Language.PYTHON, + PYTHON_TEST_REL_FILE_PATH, + "VariableDataclass", + NEW_PYTHON_CLASS_WITH_TRAILING_NEWLINES, + mode="before", + ).run_test(snapshot) + + +@pytest.mark.python +def test_insert_python_class_after(snapshot: SnapshotAssertion): + InsertInRelToSymbolTest( + Language.PYTHON, + PYTHON_TEST_REL_FILE_PATH, + "VariableDataclass", + NEW_PYTHON_CLASS_WITH_LEADING_NEWLINES, + mode="after", + ).run_test(snapshot) + + +PYTHON_REPLACED_BODY = """def modify_instance_var(self): + # This body has been replaced + self.instance_var = "Replaced!" + self.reassignable_instance_var = 999 +""" + +TYPESCRIPT_REPLACED_BODY = """function printValue() { + // This body has been replaced + console.warn("New value: " + this.value); + } +""" + + +class ReplaceBodyTest(EditingTest): + def __init__(self, language: Language, rel_path: str, symbol_name: str, new_body: str): + super().__init__(language, rel_path) + self.symbol_name = symbol_name + self.new_body = new_body + + def _apply_edit(self, code_editor: CodeEditor) -> None: + code_editor.replace_body(self.symbol_name, self.rel_path, self.new_body) + + +@pytest.mark.parametrize( + "test_case", + [ + pytest.param( + ReplaceBodyTest( + Language.PYTHON, + PYTHON_TEST_REL_FILE_PATH, + "VariableContainer/modify_instance_var", + PYTHON_REPLACED_BODY, + ), + marks=pytest.mark.python, + ), + pytest.param( + ReplaceBodyTest( + Language.TYPESCRIPT, + TYPESCRIPT_TEST_FILE, + "DemoClass/printValue", + TYPESCRIPT_REPLACED_BODY, + ), + marks=pytest.mark.typescript, + ), + ], +) +def test_replace_body(test_case: ReplaceBodyTest, snapshot: SnapshotAssertion): + # assert "a" in snapshot + test_case.run_test(content_after_ground_truth=snapshot) + + +NIX_ATTR_REPLACEMENT = """c = 3;""" + + +class NixAttrReplacementTest(EditingTest): + """Test for replacing individual attributes in Nix that should NOT result in double semicolons.""" + + def __init__(self, language: Language, rel_path: str, symbol_name: str, new_body: str): + super().__init__(language, rel_path) + self.symbol_name = symbol_name + self.new_body = new_body + + def _apply_edit(self, code_editor: CodeEditor) -> None: + code_editor.replace_body(self.symbol_name, self.rel_path, self.new_body) + + +@pytest.mark.nix +@pytest.mark.skipif(not language_tests_enabled(Language.NIX), reason="Nix tests are disabled (nixd not available)") +def test_nix_symbol_replacement_no_double_semicolon(snapshot: SnapshotAssertion): + """ + Test that replacing a Nix attribute does not result in double semicolons. + + This test exercises the bug where: + - Original: users.users.example = { isSystemUser = true; group = "example"; description = "Example service user"; }; + - Replacement: c = 3; + - Bug result would be: c = 3;; (double semicolon) + - Correct result should be: c = 3; (single semicolon) + + The replacement body includes a semicolon, but the language server's range extension + logic should prevent double semicolons. + """ + test_case = NixAttrReplacementTest( + Language.NIX, + "default.nix", + "testUser", # Simple attrset with multiple key-value pairs + NIX_ATTR_REPLACEMENT, + ) + test_case.run_test(content_after_ground_truth=snapshot) + + +class RenameSymbolTest(EditingTest): + def __init__(self, language: Language, rel_path: str, symbol_name: str, new_name: str): + super().__init__(language, rel_path) + self.symbol_name = symbol_name + self.new_name = new_name + + def _apply_edit(self, code_editor: CodeEditor) -> None: + code_editor.rename_symbol(self.symbol_name, self.rel_path, self.new_name) + + @overrides + def _test_diff(self, code_diff: CodeDiff, snapshot: SnapshotAssertion) -> None: + # sanity check (e.g., for newly generated snapshots) that the new name is actually in the modified content + assert self.new_name in code_diff.modified_content, f"New name '{self.new_name}' not found in modified content." + return super()._test_diff(code_diff, snapshot) + + +@pytest.mark.python +def test_rename_symbol(snapshot: SnapshotAssertion): + test_case = RenameSymbolTest( + Language.PYTHON, + PYTHON_TEST_REL_FILE_PATH, + "typed_module_var", + "renamed_typed_module_var", + ) + test_case.run_test(content_after_ground_truth=snapshot) + + +# ===== VUE WRITE OPERATIONS TESTS ===== + +VUE_TEST_FILE = os.path.join("src", "components", "CalculatorButton.vue") +VUE_STORE_FILE = os.path.join("src", "stores", "calculator.ts") + +NEW_VUE_HANDLER = """const handleDoubleClick = () => { + pressCount.value++; + emit('click', props.label); +}""" + + +@pytest.mark.parametrize( + "test_case", + [ + pytest.param( + DeleteSymbolTest( + Language.VUE, + VUE_TEST_FILE, + "handleMouseEnter", + ), + marks=pytest.mark.vue, + ), + ], +) +def test_delete_symbol_vue(test_case: DeleteSymbolTest, snapshot: SnapshotAssertion) -> None: + test_case.run_test(content_after_ground_truth=snapshot) + + +@pytest.mark.parametrize("mode", ["before", "after"]) +@pytest.mark.parametrize( + "test_case", + [ + pytest.param( + InsertInRelToSymbolTest( + Language.VUE, + VUE_TEST_FILE, + "handleClick", + NEW_VUE_HANDLER, + ), + marks=pytest.mark.vue, + ), + ], +) +def test_insert_in_rel_to_symbol_vue( + test_case: InsertInRelToSymbolTest, + mode: Literal["before", "after"], + snapshot: SnapshotAssertion, +) -> None: + test_case.set_mode(mode) + test_case.run_test(content_after_ground_truth=snapshot) + + +VUE_REPLACED_HANDLECLICK_BODY = """const handleClick = () => { + if (!props.disabled) { + pressCount.value = 0; // Reset instead of incrementing + emit('click', props.label); + } +}""" + + +@pytest.mark.parametrize( + "test_case", + [ + pytest.param( + ReplaceBodyTest( + Language.VUE, + VUE_TEST_FILE, + "handleClick", + VUE_REPLACED_HANDLECLICK_BODY, + ), + marks=pytest.mark.vue, + ), + ], +) +def test_replace_body_vue(test_case: ReplaceBodyTest, snapshot: SnapshotAssertion) -> None: + test_case.run_test(content_after_ground_truth=snapshot) + + +VUE_REPLACED_PRESSCOUNT_BODY = """const pressCount = ref(100)""" + + +@pytest.mark.parametrize( + "test_case", + [ + pytest.param( + ReplaceBodyTest( + Language.VUE, + VUE_TEST_FILE, + "pressCount", + VUE_REPLACED_PRESSCOUNT_BODY, + ), + marks=pytest.mark.vue, + ), + ], +) +def test_replace_body_vue_with_disambiguation(test_case: ReplaceBodyTest, snapshot: SnapshotAssertion) -> None: + """Test symbol disambiguation when replacing body in Vue files. + + This test verifies the fix for the Vue LSP symbol duplication issue. + When the LSP returns two symbols with the same name (e.g., pressCount appears both as + a definition `const pressCount = ref(0)` and as a shorthand property in `defineExpose({ pressCount })`), + the _find_unique_symbol method should prefer the symbol with the larger range (the definition). + + The test exercises this by calling replace_body on 'pressCount', which internally calls + _find_unique_symbol and should correctly select the definition (line 40, 19 chars) over + the reference (line 97, 10 chars). + """ + test_case.run_test(content_after_ground_truth=snapshot) + + +VUE_STORE_REPLACED_CLEAR_BODY = """function clear() { + // Modified: Reset to initial state with a log + console.log('Clearing calculator state'); + displayValue.value = '0'; + expression.value = ''; + operationHistory.value = []; + lastResult.value = undefined; +}""" + + +@pytest.mark.parametrize( + "test_case", + [ + pytest.param( + ReplaceBodyTest( + Language.VUE, + VUE_STORE_FILE, + "clear", + VUE_STORE_REPLACED_CLEAR_BODY, + ), + marks=pytest.mark.vue, + ), + ], +) +def test_replace_body_vue_ts_file(test_case: ReplaceBodyTest, snapshot: SnapshotAssertion) -> None: + """Test that TypeScript files within Vue projects can be edited.""" + test_case.run_test(content_after_ground_truth=snapshot) diff --git a/test/serena/test_task_executor.py b/test/serena/test_task_executor.py new file mode 100644 index 0000000..baf78bb --- /dev/null +++ b/test/serena/test_task_executor.py @@ -0,0 +1,126 @@ +import time + +import pytest + +from serena.task_executor import TaskExecutor + + +@pytest.fixture +def executor(): + """ + Fixture for a basic SerenaAgent without a project + """ + return TaskExecutor("TestExecutor") + + +class Task: + def __init__(self, delay: float, exception: bool = False): + self.delay = delay + self.exception = exception + self.did_run = False + + def run(self): + self.did_run = True + time.sleep(self.delay) + if self.exception: + raise ValueError("Task failed") + return True + + +def test_task_executor_sequence(executor): + """ + Tests that a sequence of tasks is executed correctly + """ + future1 = executor.issue_task(Task(1).run, name="task1") + future2 = executor.issue_task(Task(1).run, name="task2") + assert future1.result() is True + assert future2.result() is True + + +def test_task_executor_exception(executor): + """ + Tests that tasks that raise exceptions are handled correctly, i.e. that + * the exception is propagated, + * subsequent tasks are still executed. + """ + future1 = executor.issue_task(Task(1, exception=True).run, name="task1") + future2 = executor.issue_task(Task(1).run, name="task2") + have_exception = False + try: + assert future1.result() + except Exception as e: + assert isinstance(e, ValueError) + have_exception = True + assert have_exception + assert future2.result() is True + + +def test_task_executor_cancel_current(executor): + """ + Tests that tasks that are cancelled are handled correctly, i.e. that + * subsequent tasks are executed as soon as cancellation ensues. + * the cancelled task raises CancelledError when result() is called. + """ + start_time = time.time() + future1 = executor.issue_task(Task(10).run, name="task1") + future2 = executor.issue_task(Task(1).run, name="task2") + time.sleep(1) + future1.cancel() + assert future2.result() is True + end_time = time.time() + assert (end_time - start_time) < 9, "Cancelled task did not stop in time" + have_cancelled_error = False + try: + future1.result() + except Exception as e: + assert e.__class__.__name__ == "CancelledError" + have_cancelled_error = True + assert have_cancelled_error + + +def test_task_executor_cancel_future(executor): + """ + Tests that when a future task is cancelled, it is never run at all + """ + task1 = Task(10) + task2 = Task(1) + future1 = executor.issue_task(task1.run, name="task1") + future2 = executor.issue_task(task2.run, name="task2") + time.sleep(1) + future2.cancel() + future1.cancel() + try: + future2.result() + except: + pass + assert task1.did_run + assert not task2.did_run + + +def test_task_executor_cancellation_via_task_info(executor): + start_time = time.time() + executor.issue_task(Task(10).run, "task1") + executor.issue_task(Task(10).run, "task2") + task_infos = executor.get_current_tasks() + task_infos2 = executor.get_current_tasks() + + # test expected tasks + assert len(task_infos) == 2 + assert "task1" in task_infos[0].name + assert "task2" in task_infos[1].name + + # test task identifiers being stable + assert task_infos2[0].task_id == task_infos[0].task_id + + # test cancellation + task_infos[0].cancel() + time.sleep(0.5) + task_infos3 = executor.get_current_tasks() + assert len(task_infos3) == 1 # Cancelled task is gone from the queue + task_infos3[0].cancel() + try: + task_infos3[0].future.result() + except: + pass + end_time = time.time() + assert (end_time - start_time) < 9, "Cancelled task did not stop in time" diff --git a/test/serena/test_text_utils.py b/test/serena/test_text_utils.py new file mode 100644 index 0000000..936779f --- /dev/null +++ b/test/serena/test_text_utils.py @@ -0,0 +1,669 @@ +import re +from collections.abc import Callable + +import pytest + +from serena.util.file_proxy import FileCollection, FileProxy +from serena.util.text_utils import LineType, MultiFileContentReplacer, glob_to_regex, search_files, search_text + + +class TestSearchText: + def test_search_text_with_string_pattern(self): + """Test searching with a simple string pattern.""" + content = """ + def hello_world(): + print("Hello, World!") + return 42 + """ + + # Search for a simple string pattern + matches = search_text("print", content=content) + + assert len(matches) == 1 + assert matches[0].num_matched_lines == 1 + assert matches[0].start_line == 2 + assert matches[0].end_line == 2 + assert matches[0].lines[0].line_content.strip() == 'print("Hello, World!")' + + def test_search_text_with_regex_pattern(self): + """Test searching with a regex pattern.""" + content = """ + class DataProcessor: + def __init__(self, data): + self.data = data + + def process(self): + return [x * 2 for x in self.data if x > 0] + + def filter(self, predicate): + return [x for x in self.data if predicate(x)] + """ + + # Search for a regex pattern matching method definitions + pattern = r"def\s+\w+\s*\([^)]*\):" + matches = search_text(pattern, content=content) + + assert len(matches) == 3 + assert matches[0].lines[0].match_type == LineType.MATCH + assert "def __init__" in matches[0].lines[0].line_content + assert "def process" in matches[1].lines[0].line_content + assert "def filter" in matches[2].lines[0].line_content + + def test_search_text_with_regex_pattern2(self): + """Test searching with a pre-compiled regex pattern.""" + content = """ + import os + import sys + from pathlib import Path + + # Configuration variables + DEBUG = True + MAX_RETRIES = 3 + + def configure_logging(): + log_level = "DEBUG" if DEBUG else "INFO" + print(f"Setting log level to {log_level}") + """ + + # Search for variable assignments with a compiled regex + pattern = r"^\s*[A-Z_]+ = .*?$" + matches = search_text(pattern, content=content, multiline=True) + + assert len(matches) == 2 + assert "DEBUG = True" in matches[0].lines[0].line_content + assert "MAX_RETRIES = 3" in matches[1].lines[0].line_content + + def test_search_text_with_context_lines(self): + """Test searching with context lines before and after the match.""" + content = """ + def complex_function(a, b, c): + # This is a complex function that does something. + if a > b: + return a * c + elif b > a: + return b * c + else: + return (a + b) * c + """ + + # Search with context lines + matches = search_text("return", content=content, context_lines_before=1, context_lines_after=1) + + assert len(matches) == 3 + + # Check the first match with context + first_match = matches[0] + assert len(first_match.lines) == 3 + assert first_match.lines[0].match_type == LineType.BEFORE_MATCH + assert first_match.lines[1].match_type == LineType.MATCH + assert first_match.lines[2].match_type == LineType.AFTER_MATCH + + # Verify the content of lines + assert "if a > b:" in first_match.lines[0].line_content + assert "return a * c" in first_match.lines[1].line_content + assert "elif b > a:" in first_match.lines[2].line_content + + def test_search_text_with_multiline_match(self): + """Test searching with multiline pattern matching.""" + content = """ + def factorial(n): + if n <= 1: + return 1 + else: + return n * factorial(n-1) + + result = factorial(5) # Should be 120 + """ + + # Search for a pattern that spans multiple lines (if-else block) + pattern = r"if.*?else.*?return" + matches = search_text(pattern, content=content) + + assert len(matches) == 1 + multiline_match = matches[0] + assert multiline_match.num_matched_lines >= 3 + assert "if n <= 1:" in multiline_match.lines[0].line_content + + # All matched lines should have match_type == LineType.MATCH + match_lines = [line for line in multiline_match.lines if line.match_type == LineType.MATCH] + assert len(match_lines) >= 3 + + def test_search_text_with_glob_pattern(self): + """Test searching with glob-like patterns.""" + content = """ + class UserService: + def get_user(self, user_id): + return {"id": user_id, "name": "Test User"} + + def create_user(self, user_data): + print(f"Creating user: {user_data}") + return {"id": 123, **user_data} + + def update_user(self, user_id, user_data): + print(f"Updating user {user_id} with {user_data}") + return True + """ + + # Search with a glob pattern for all user methods + matches = search_text("*_user*", content=content, is_glob=True, multiline=False) + + assert len(matches) == 3 + assert "get_user" in matches[0].lines[0].line_content + assert "create_user" in matches[1].lines[0].line_content + assert "update_user" in matches[2].lines[0].line_content + + def test_search_text_with_complex_glob_pattern(self): + """Test searching with more complex glob patterns.""" + content = """ + def process_data(data): + return [transform(item) for item in data] + + def transform(item): + if isinstance(item, dict): + return {k: v.upper() if isinstance(v, str) else v for k, v in item.items()} + elif isinstance(item, list): + return [x * 2 for x in item if isinstance(x, (int, float))] + elif isinstance(item, str): + return item.upper() + else: + return item + """ + + # Search with a simplified glob pattern to find all isinstance occurrences + matches = search_text("*isinstance*", content=content, is_glob=True, multiline=False) + + # Should match lines with isinstance(item, dict) and isinstance(item, list) + assert len(matches) >= 2 + instance_matches = [ + line.line_content + for match in matches + for line in match.lines + if line.match_type == LineType.MATCH and "isinstance(item," in line.line_content + ] + assert len(instance_matches) >= 2 + assert any("isinstance(item, dict)" in line for line in instance_matches) + assert any("isinstance(item, list)" in line for line in instance_matches) + + def test_search_text_glob_with_special_chars(self): + """Glob patterns containing regex special characters should match literally.""" + content = """ + def func_square(): + print("value[42]") + + def func_curly(): + print("value{bar}") + """ + + matches_square = search_text(r"*\[42\]*", content=content, is_glob=True, multiline=False) + assert len(matches_square) == 1 + assert "[42]" in matches_square[0].lines[0].line_content + + matches_curly = search_text("*{bar}*", content=content, is_glob=True, multiline=False) + assert len(matches_curly) == 1 + assert "{bar}" in matches_curly[0].lines[0].line_content + + def test_glob_to_regex_question_matches_single_char(self): + """A glob '?' matches exactly one character, matching glob_match's documented semantics.""" + rx = glob_to_regex("a?c") + assert re.fullmatch(rx, "abc") is not None + assert re.fullmatch(rx, "ac") is None + assert re.fullmatch(rx, "abbc") is None + + def test_search_text_glob_question_single_char(self): + """search_text('c?t', is_glob=True) must match 'cat' (one char), not 'coat' (two chars).""" + content = "value_cat_end\nvalue_coat_end" + matches = search_text("c?t", content=content, is_glob=True, multiline=False) + assert len(matches) == 1 + assert "value_cat_end" in matches[0].lines[0].line_content + + def test_search_text_no_matches(self): + """Test searching with a pattern that doesn't match anything.""" + content = """ + def calculate_average(numbers): + if not numbers: + return 0 + return sum(numbers) / len(numbers) + """ + + # Search for a pattern that doesn't exist in the content + matches = search_text("missing_function", content=content) + + assert len(matches) == 0 + + +# Mock file reader that always returns matching content +def mock_reader_always_match(file_path: str) -> str: + """Mock file reader that returns content guaranteed to match the simple pattern.""" + return "This line contains a match." + + +class MockFileProxy(FileProxy): + def __init__(self, relative_path: str, mock_reader: Callable[[str], str] = mock_reader_always_match): + self.relative_path = relative_path + self.mock_reader = mock_reader + + def get_contents(self) -> str: + return self.mock_reader(self.relative_path) + + def get_relative_path(self) -> str: + return self.relative_path + + def is_glob_supported(self): + return True + + +class MockFileCollection(FileCollection): + def __init__(self, file_paths, mock_reader: Callable[[str], str] = mock_reader_always_match): + super().__init__([MockFileProxy(path, mock_reader) for path in file_paths]) + + +class TestSearchFiles: + @pytest.mark.parametrize( + "file_paths, pattern, paths_include_glob, paths_exclude_glob, expected_matched_files, description", + [ + # Basic cases + (["a.py", "b.txt"], "match", None, None, ["a.py", "b.txt"], "No filters"), + (["a.py", "b.txt"], "match", "*.py", None, ["a.py"], "Include only .py files"), + (["a.py", "b.txt"], "match", None, "*.txt", ["a.py"], "Exclude .txt files"), + (["a.py", "b.txt", "c.py"], "match", "*.py", "c.*", ["a.py"], "Include .py, exclude c.*"), + # Directory matching - Using pathspec patterns + (["main.c", "test/main.c"], "match", "test/*", None, ["test/main.c"], "Include files in test/ subdir"), + (["data/a.csv", "data/b.log"], "match", "data/*", "*.log", ["data/a.csv"], "Include data/*, exclude *.log"), + (["src/a.py", "tests/b.py"], "match", "src/**", "tests/**", ["src/a.py"], "Include src/**, exclude tests/**"), + (["src/mod/a.py", "tests/b.py"], "match", "**/*.py", "tests/**", ["src/mod/a.py"], "Include **/*.py, exclude tests/**"), + (["file.py", "dir/file.py"], "match", "dir/*.py", None, ["dir/file.py"], "Include files directly in dir"), + (["file.py", "dir/sub/file.py"], "match", "dir/**/*.py", None, ["dir/sub/file.py"], "Include files recursively in dir"), + # Overlap and edge cases + (["file.py", "dir/file.py"], "match", "*.py", "dir/*", ["file.py"], "Include *.py, exclude files directly in dir"), + (["root.py", "adir/a.py", "bdir/b.py"], "match", "a*/*.py", None, ["adir/a.py"], "Include files in dirs starting with 'a'"), + (["a.txt", "b.log"], "match", "*.py", None, [], "No files match include pattern"), + (["a.py", "b.py"], "match", None, "*.py", [], "All files match exclude pattern"), + (["a.py", "b.py"], "match", "a.*", "*.py", [], "Include a.* but exclude *.py -> empty"), + (["a.py", "b.py"], "match", "*.py", "b.*", ["a.py"], "Include *.py but exclude b.* -> a.py"), + ], + ids=lambda x: x if isinstance(x, str) else "", # Use description as test ID + ) + def test_search_files_include_exclude( + self, file_paths, pattern, paths_include_glob, paths_exclude_glob, expected_matched_files, description + ): + """ + Test the include/exclude glob filtering logic in search_files using PathSpec patterns. + """ + results = search_files( + MockFileCollection(file_paths), + pattern=pattern, + paths_include_glob=paths_include_glob, + paths_exclude_glob=paths_exclude_glob, + context_lines_before=0, # No context needed for this test focus + context_lines_after=0, + ) + + # Extract the source file paths from the results + actual_matched_files = sorted([result.source_file_path for result in results if result.source_file_path]) + + # Assert that the matched files are exactly the ones expected + assert actual_matched_files == sorted(expected_matched_files) + + # Basic check on results structure if files were expected + if expected_matched_files: + assert len(results) == len(expected_matched_files) + for result in results: + assert len(result.matched_lines) == 1 # Mock reader returns one matching line + assert result.matched_lines[0].line_content == "This line contains a match." + assert result.matched_lines[0].match_type == LineType.MATCH + + @pytest.mark.parametrize( + "file_paths, pattern, paths_include_glob, paths_exclude_glob, expected_matched_files, description", + [ + # Glob patterns that were problematic with gitignore syntax + ( + ["src/serena/agent.py", "src/serena/process_isolated_agent.py", "test/agent.py"], + "match", + "src/**agent.py", + None, + ["src/serena/agent.py", "src/serena/process_isolated_agent.py"], + "Glob: src/**agent.py should match files ending with agent.py under src/", + ), + ( + ["src/serena/agent.py", "src/serena/process_isolated_agent.py", "other/agent.py"], + "match", + "**agent.py", + None, + ["src/serena/agent.py", "src/serena/process_isolated_agent.py", "other/agent.py"], + "Glob: **agent.py should match files ending with agent.py anywhere", + ), + ( + ["dir/subdir/file.py", "dir/other/file.py", "elsewhere/file.py"], + "match", + "dir/**file.py", + None, + ["dir/subdir/file.py", "dir/other/file.py"], + "Glob: dir/**file.py should match files ending with file.py under dir/", + ), + ( + ["src/a/b/c/test.py", "src/x/test.py", "other/test.py"], + "match", + "src/**/test.py", + None, + ["src/a/b/c/test.py", "src/x/test.py"], + "Glob: src/**/test.py should match test.py files under src/ at any depth", + ), + # Edge cases for ** patterns + ( + ["agent.py", "src/agent.py", "src/serena/agent.py"], + "match", + "**agent.py", + None, + ["agent.py", "src/agent.py", "src/serena/agent.py"], + "Glob: **agent.py should match at root and any depth", + ), + (["file.txt", "src/file.txt"], "match", "src/**", None, ["src/file.txt"], "Glob: src/** should match everything under src/"), + ], + ids=lambda x: x if isinstance(x, str) else "", # Use description as test ID + ) + def test_search_files_glob_patterns( + self, file_paths, pattern, paths_include_glob, paths_exclude_glob, expected_matched_files, description + ): + """ + Test glob patterns that were problematic with the previous gitignore-based implementation. + """ + results = search_files( + MockFileCollection(file_paths), + pattern=pattern, + paths_include_glob=paths_include_glob, + paths_exclude_glob=paths_exclude_glob, + context_lines_before=0, + context_lines_after=0, + ) + + # Extract the source file paths from the results + actual_matched_files = sorted([result.source_file_path for result in results if result.source_file_path]) + + # Assert that the matched files are exactly the ones expected + assert actual_matched_files == sorted(expected_matched_files), ( + f"Pattern '{paths_include_glob}' failed: expected {sorted(expected_matched_files)}, got {actual_matched_files}" + ) + + # Basic check on results structure if files were expected + if expected_matched_files: + assert len(results) == len(expected_matched_files) + for result in results: + assert len(result.matched_lines) == 1 # Mock reader returns one matching line + assert result.matched_lines[0].line_content == "This line contains a match." + assert result.matched_lines[0].match_type == LineType.MATCH + + @pytest.mark.parametrize( + "file_paths, pattern, paths_include_glob, paths_exclude_glob, expected_matched_files, description", + [ + # Brace expansion in include glob + ( + ["a.py", "b.js", "c.txt"], + "match", + "*.{py,js}", + None, + ["a.py", "b.js"], + "Brace expansion in include glob", + ), + # Brace expansion in exclude glob + ( + ["a.py", "b.log", "c.txt"], + "match", + "*.{py,log,txt}", + "*.{log,txt}", + ["a.py"], + "Brace expansion in exclude glob", + ), + # Brace expansion in both include and exclude + ( + ["src/a.ts", "src/b.js", "test/a.ts", "test/b.js"], + "match", + "**/*.{ts,js}", + "test/**/*.{ts,js}", + ["src/a.ts", "src/b.js"], + "Brace expansion in both include and exclude", + ), + # No matching files with brace expansion + ( + ["a.py", "b.js"], + "match", + "*.{c,h}", + None, + [], + "Brace expansion with no matching files", + ), + # Multiple brace expansions + ( + ["src/a/a.py", "src/b/b.py", "lib/a/a.py", "lib/b/b.py"], + "match", + "{src,lib}/{a,b}/*.py", + "lib/b/*.py", + ["src/a/a.py", "src/b/b.py", "lib/a/a.py"], + "Multiple brace expansions in include/exclude", + ), + ], + ids=lambda x: x if isinstance(x, str) else "", + ) + def test_search_files_with_brace_expansion( + self, file_paths, pattern, paths_include_glob, paths_exclude_glob, expected_matched_files, description + ): + """Test search_files with glob patterns containing brace expansions.""" + results = search_files( + MockFileCollection(file_paths), + pattern=pattern, + paths_include_glob=paths_include_glob, + paths_exclude_glob=paths_exclude_glob, + ) + + actual_matched_files = sorted([result.source_file_path for result in results if result.source_file_path]) + assert actual_matched_files == sorted(expected_matched_files), f"Test failed: {description}" + + def test_search_files_no_pattern_match_in_content(self): + """Test that no results are returned if the pattern doesn't match the file content, even if files pass filters.""" + file_paths = ["a.py", "b.txt"] + pattern = "non_existent_pattern_in_mock_content" # This won't match mock_reader_always_match content + results = search_files( + MockFileCollection(file_paths), + pattern=pattern, + paths_include_glob=None, # Both files would pass filters + paths_exclude_glob=None, + ) + assert len(results) == 0, "Should not find matches if pattern doesn't match content" + + def test_search_files_regex_pattern_with_filters(self): + """Test using a regex pattern works correctly along with include/exclude filters.""" + + def specific_mock_reader(file_path: str) -> str: + # Provide different content for different files to test regex matching + if file_path == "a.py": # noqa: SIM116 + return "File A: value=123\nFile A: value=456" + elif file_path == "b.py": + return "File B: value=789" + elif file_path == "c.txt": + return "File C: value=000" + return "No values here." + + file_paths = ["a.py", "b.py", "c.txt"] + pattern = r"value=(\d+)" + + results = search_files( + MockFileCollection(file_paths, specific_mock_reader), + pattern=pattern, + paths_include_glob="*.py", # Only include .py files + paths_exclude_glob="b.*", # Exclude files starting with b + ) + + # Expected: a.py included, b.py excluded by glob, c.txt excluded by glob + # a.py has two matches for the regex pattern + assert len(results) == 2, "Expected 2 matches only from a.py" + actual_matched_files = sorted([result.source_file_path for result in results if result.source_file_path]) + assert actual_matched_files == ["a.py", "a.py"], "Both matches should be from a.py" + # Check the content of the matched lines + assert results[0].matched_lines[0].line_content == "File A: value=123" + assert results[1].matched_lines[0].line_content == "File A: value=456" + + def test_search_files_context_lines_with_filters(self): + """Test context lines are included correctly when filters are active.""" + + def context_mock_reader(file_path: str) -> str: + if file_path == "include_me.txt": + return "Line before 1\nLine before 2\nMATCH HERE\nLine after 1\nLine after 2" + elif file_path == "exclude_me.log": + return "Noise\nMATCH HERE\nNoise" + return "No match" + + file_paths = ["include_me.txt", "exclude_me.log"] + pattern = "MATCH HERE" + + results = search_files( + MockFileCollection(file_paths, context_mock_reader), + pattern=pattern, + paths_include_glob="*.txt", # Only include .txt files + paths_exclude_glob=None, + context_lines_before=1, + context_lines_after=1, + ) + + # Expected: Only include_me.txt should be processed and matched + assert len(results) == 1, "Expected only one result from the included file" + result = results[0] + assert result.source_file_path == "include_me.txt" + assert len(result.lines) == 3, "Expected 3 lines (1 before, 1 match, 1 after)" + assert result.lines[0].line_content == "Line before 2", "Incorrect 'before' context line" + assert result.lines[0].match_type == LineType.BEFORE_MATCH + assert result.lines[1].line_content == "MATCH HERE", "Incorrect 'match' line" + assert result.lines[1].match_type == LineType.MATCH + assert result.lines[2].line_content == "Line after 1", "Incorrect 'after' context line" + assert result.lines[2].match_type == LineType.AFTER_MATCH + + +class TestGlobMatch: + """Test the glob_match function directly.""" + + @pytest.mark.parametrize( + "pattern, path, expected", + [ + # Basic wildcard patterns + ("*.py", "file.py", True), + ("*.py", "file.txt", False), + ("*agent.py", "agent.py", True), + ("*agent.py", "process_isolated_agent.py", True), + ("*agent.py", "agent_test.py", False), + # Double asterisk patterns + ("**agent.py", "agent.py", True), + ("**agent.py", "src/agent.py", True), + ("**agent.py", "src/serena/agent.py", True), + ("**agent.py", "src/serena/process_isolated_agent.py", True), + ("**agent.py", "agent_test.py", False), + # Prefix with double asterisk + ("src/**agent.py", "src/agent.py", True), + ("src/**agent.py", "src/serena/agent.py", True), + ("src/**agent.py", "src/serena/process_isolated_agent.py", True), + ("src/**agent.py", "other/agent.py", False), + ("src/**agent.py", "src/agent_test.py", False), + # Directory patterns + ("src/**", "src/file.py", True), + ("src/**", "src/dir/file.py", True), + ("src/**", "other/file.py", False), + # Exact matches with double asterisk + ("src/**/test.py", "src/test.py", True), + ("src/**/test.py", "src/a/b/test.py", True), + ("src/**/test.py", "src/test_file.py", False), + # Simple patterns without asterisks + ("src/file.py", "src/file.py", True), + ("src/file.py", "src/other.py", False), + ], + ) + def test_glob_match(self, pattern, path, expected): + """Test glob_match function with various patterns.""" + from serena.util.text_utils import glob_match + + assert glob_match(pattern, path) == expected + + +class TestExpandBraces: + """Test the expand_braces function.""" + + @pytest.mark.parametrize( + "pattern, expected", + [ + # Basic case + ("src/*.{js,ts}", ["src/*.js", "src/*.ts"]), + # No braces + ("src/*.py", ["src/*.py"]), + # Multiple brace sets + ("src/{a,b}/{c,d}.py", ["src/a/c.py", "src/a/d.py", "src/b/c.py", "src/b/d.py"]), + # Empty string + ("", [""]), + # Braces with empty elements + ("src/{a,,b}.py", ["src/a.py", "src/.py", "src/b.py"]), + # No commas + ("src/{a}.py", ["src/a.py"]), + ], + ) + def test_expand_braces(self, pattern, expected): + """Test brace expansion for glob patterns.""" + from serena.util.text_utils import expand_braces + + assert sorted(expand_braces(pattern)) == sorted(expected) + + +class TestMultiFileContentReplacer: + FILES = [ + ("a/first.py", "import old_pkg\n\nvalue = old_pkg.compute()\n"), + ("b/second.py", "import old_pkg\nother = 1\n"), + ] + + def test_find_occurrences_order_ids_and_lines(self): + replacer = MultiFileContentReplacer(mode="literal") + occurrences = replacer.find_occurrences(self.FILES, "old_pkg", "new_pkg") + assert [o.relative_path for o in occurrences] == ["a/first.py", "a/first.py", "b/second.py"] + assert [o.index_in_file for o in occurrences] == [0, 1, 0] + assert [o.start_line for o in occurrences] == [0, 2, 0] + for o in occurrences: + assert o.matched_text == "old_pkg" + assert o.replacement == "new_pkg" + assert MultiFileContentReplacer.OCCURRENCE_ID_REGEX.match(o.occurrence_id) + # ids are content-anchored: same matched text at the same index yields the same id across calls + again = replacer.find_occurrences(self.FILES, "old_pkg", "new_pkg") + assert [o.occurrence_id for o in again] == [o.occurrence_id for o in occurrences] + + def test_regex_backreference_expansion(self): + replacer = MultiFileContentReplacer(mode="regex") + files = [("f.txt", "name=alpha\nname=beta\n")] + occurrences = replacer.find_occurrences(files, r"name=(\w+)", "id=$!1") + assert [o.replacement for o in occurrences] == ["id=alpha", "id=beta"] + + def test_apply_to_content_selected_subset(self): + replacer = MultiFileContentReplacer(mode="literal") + path, content = self.FILES[0] + occurrences = [o for o in replacer.find_occurrences(self.FILES, "old_pkg", "new_pkg") if o.relative_path == path] + updated = replacer.apply_to_content(content, occurrences[1:]) # only the second occurrence + assert updated == "import old_pkg\n\nvalue = new_pkg.compute()\n" + updated_all = replacer.apply_to_content(content, occurrences) + assert updated_all == "import new_pkg\n\nvalue = new_pkg.compute()\n" + + def test_ambiguous_multiline_match_is_flagged(self): + replacer = MultiFileContentReplacer(mode="regex") + files = [("f.txt", "start A\nstart B\nend\n")] + occurrences = replacer.find_occurrences(files, r"start.*?end", "X") + assert len(occurrences) == 1 + assert occurrences[0].is_ambiguous + + def test_render_occurrence_diff_minimal_lines(self): + replacer = MultiFileContentReplacer(mode="literal") + path, content = self.FILES[0] + occ = replacer.find_occurrences([(path, content)], "old_pkg.compute()", "new_pkg.compute_all()")[0] + diff = replacer.render_occurrence_diff(occ, content) + assert occ.occurrence_id in diff + assert "line 2" in diff + assert " - value = old_pkg.compute()" in diff + assert " + value = new_pkg.compute_all()" in diff + # only the affected line is shown, not the whole file + assert "import old_pkg" not in diff + + def test_apply_to_content_rejects_drifted_occurrence(self): + replacer = MultiFileContentReplacer(mode="literal") + path, content = self.FILES[0] + occ = replacer.find_occurrences([(path, content)], "old_pkg", "new_pkg")[0] + with pytest.raises(AssertionError): + replacer.apply_to_content("completely different content", [occ]) diff --git a/test/serena/test_tool_parameter_types.py b/test/serena/test_tool_parameter_types.py new file mode 100644 index 0000000..42b8948 --- /dev/null +++ b/test/serena/test_tool_parameter_types.py @@ -0,0 +1,39 @@ +import logging + +import pytest + +from serena.config.serena_config import SerenaConfig +from serena.mcp import SerenaMCPFactory +from serena.tools.tools_base import ToolRegistry + + +@pytest.mark.parametrize("context", ("chatgpt", "codex", "oaicompat-agent")) +def test_all_tool_parameters_have_type(context): + """ + For every tool exposed by Serena, ensure that the generated + Open‑AI schema contains a ``type`` entry for each parameter. + """ + cfg = SerenaConfig(log_level=logging.ERROR).with_headless_mode_overrides() + registry = ToolRegistry() + cfg.included_optional_tools = tuple(registry.get_tool_names_optional()) + factory = SerenaMCPFactory(transport="stdio", context=context) + # Initialize the agent so that the tools are available + factory.agent = factory._create_serena_agent(cfg) + tools = list(factory._iter_tools()) + + for tool in tools: + mcp_tool = factory.make_mcp_tool(tool, openai_tool_compatible=True) + params = mcp_tool.parameters + + # Collect any parameter that lacks a type + issues = [] + print(f"Checking tool {tool}") + + if "properties" not in params: + issues.append(f"Tool {tool.get_name()!r} missing properties section") + else: + for pname, prop in params["properties"].items(): + if "type" not in prop: + issues.append(f"Tool {tool.get_name()!r} parameter {pname!r} missing 'type'") + if issues: + raise AssertionError("\n".join(issues)) diff --git a/test/serena/util/test_exception.py b/test/serena/util/test_exception.py new file mode 100644 index 0000000..e13e57f --- /dev/null +++ b/test/serena/util/test_exception.py @@ -0,0 +1,116 @@ +import os +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from serena.util.exception import is_headless_environment, show_fatal_exception_safe + + +class TestHeadlessEnvironmentDetection: + """Test class for headless environment detection functionality.""" + + def test_is_headless_no_display(self): + """Test that environment without DISPLAY is detected as headless on Linux.""" + with patch("sys.platform", "linux"): + with patch.dict(os.environ, {}, clear=True): + assert is_headless_environment() is True + + def test_is_headless_ssh_connection(self): + """Test that SSH sessions are detected as headless.""" + with patch("sys.platform", "linux"): + with patch.dict(os.environ, {"SSH_CONNECTION": "192.168.1.1 22 192.168.1.2 22", "DISPLAY": ":0"}): + assert is_headless_environment() is True + + with patch.dict(os.environ, {"SSH_CLIENT": "192.168.1.1 22 22", "DISPLAY": ":0"}): + assert is_headless_environment() is True + + def test_is_headless_wsl(self): + """Test that WSL environment is detected as headless.""" + # Skip this test on Windows since os.uname doesn't exist + if not hasattr(os, "uname"): + pytest.skip("os.uname not available on this platform") + + with patch("sys.platform", "linux"): + with patch("os.uname") as mock_uname: + mock_uname.return_value = Mock(release="5.15.153.1-microsoft-standard-WSL2") + with patch.dict(os.environ, {"DISPLAY": ":0"}): + assert is_headless_environment() is True + + def test_is_headless_docker(self): + """Test that Docker containers are detected as headless.""" + with patch("sys.platform", "linux"): + # Test with CI environment variable + with patch.dict(os.environ, {"CI": "true", "DISPLAY": ":0"}): + assert is_headless_environment() is True + + # Test with CONTAINER environment variable + with patch.dict(os.environ, {"CONTAINER": "docker", "DISPLAY": ":0"}): + assert is_headless_environment() is True + + # Test with .dockerenv file + with patch("os.path.exists") as mock_exists: + mock_exists.return_value = True + with patch.dict(os.environ, {"DISPLAY": ":0"}): + assert is_headless_environment() is True + + def test_is_not_headless_windows(self): + """Test that Windows is never detected as headless.""" + with patch("sys.platform", "win32"): + # Even without DISPLAY, Windows should not be headless + with patch.dict(os.environ, {}, clear=True): + assert is_headless_environment() is False + + +class TestShowFatalExceptionSafe: + """Test class for safe fatal exception display functionality.""" + + @patch("serena.util.exception.is_headless_environment", return_value=True) + @patch("serena.util.exception.log") + def test_show_fatal_exception_safe_headless(self, mock_log, mock_is_headless): + """Test that GUI is not attempted in headless environment.""" + test_exception = ValueError("Test error") + + # The import should never happen in headless mode + with patch("serena.gui_log_viewer.show_fatal_exception") as mock_show_gui: + show_fatal_exception_safe(test_exception) + mock_show_gui.assert_not_called() + + # Verify debug log about skipping GUI + mock_log.debug.assert_called_once_with("Skipping GUI error display in headless environment") + + @patch("serena.util.exception.is_headless_environment", return_value=False) + @patch("serena.util.exception.log") + def test_show_fatal_exception_safe_with_gui(self, mock_log, mock_is_headless): + """Test that GUI is attempted when not in headless environment.""" + test_exception = ValueError("Test error") + + # Mock the GUI function + with patch("serena.gui_log_viewer.show_fatal_exception") as mock_show_gui: + show_fatal_exception_safe(test_exception) + mock_show_gui.assert_called_once_with(test_exception) + + @patch("serena.util.exception.is_headless_environment", return_value=False) + @patch("serena.util.exception.log") + def test_show_fatal_exception_safe_gui_failure(self, mock_log, mock_is_headless): + """Test graceful handling when GUI display fails.""" + test_exception = ValueError("Test error") + gui_error = ImportError("No module named 'tkinter'") + + # Mock the GUI function to raise an exception + with patch("serena.gui_log_viewer.show_fatal_exception", side_effect=gui_error): + show_fatal_exception_safe(test_exception) + + # Verify debug log about GUI failure + mock_log.debug.assert_called_with(f"Failed to show GUI error dialog: {gui_error}") + + def test_show_fatal_exception_safe_prints_to_stderr(self): + """Test that exceptions are always printed to stderr.""" + test_exception = ValueError("Test error message") + + with patch("sys.stderr", new_callable=MagicMock) as mock_stderr: + with patch("serena.util.exception.is_headless_environment", return_value=True): + with patch("serena.util.exception.log"): + show_fatal_exception_safe(test_exception) + + # Verify print was called with the correct arguments + mock_stderr.write.assert_any_call("Fatal exception: Test error message") diff --git a/test/serena/util/test_file_system.py b/test/serena/util/test_file_system.py new file mode 100644 index 0000000..ca8a087 --- /dev/null +++ b/test/serena/util/test_file_system.py @@ -0,0 +1,755 @@ +import os +import shutil +import tempfile +from pathlib import Path + +from pathspec import PathSpec + +from serena.util.file_system import GitignoreParser, GitignoreSpec, match_path + + +class TestGitignoreParser: + """Test class for GitignoreParser functionality.""" + + def setup_method(self): + """Set up test environment before each test method.""" + # Create a temporary directory for testing + self.test_dir = tempfile.mkdtemp() + self.repo_path = Path(self.test_dir) + + # Create test repository structure + self._create_repo_structure() + + def teardown_method(self): + """Clean up test environment after each test method.""" + # Remove the temporary directory + shutil.rmtree(self.test_dir) + + def _create_repo_structure(self): + """ + Create a test repository structure with multiple gitignore files. + + Structure: + repo/ + ├── .gitignore + ├── file1.txt + ├── test.log + ├── src/ + │ ├── .gitignore + │ ├── main.py + │ ├── test.log + │ ├── build/ + │ │ └── output.o + │ └── lib/ + │ ├── .gitignore + │ └── cache.tmp + └── docs/ + ├── .gitignore + ├── api.md + └── temp/ + └── draft.md + """ + # Create directories + (self.repo_path / "src").mkdir() + (self.repo_path / "src" / "build").mkdir() + (self.repo_path / "src" / "lib").mkdir() + (self.repo_path / "docs").mkdir() + (self.repo_path / "docs" / "temp").mkdir() + + # Create files + (self.repo_path / "file1.txt").touch() + (self.repo_path / "test.log").touch() + (self.repo_path / "src" / "main.py").touch() + (self.repo_path / "src" / "test.log").touch() + (self.repo_path / "src" / "build" / "output.o").touch() + (self.repo_path / "src" / "lib" / "cache.tmp").touch() + (self.repo_path / "docs" / "api.md").touch() + (self.repo_path / "docs" / "temp" / "draft.md").touch() + + # Create root .gitignore + root_gitignore = self.repo_path / ".gitignore" + root_gitignore.write_text( + """# Root gitignore +*.log +/build/ +""" + ) + + # Create src/.gitignore + src_gitignore = self.repo_path / "src" / ".gitignore" + src_gitignore.write_text( + """# Source gitignore +*.o +build/ +!important.log +""" + ) + + # Create src/lib/.gitignore (deeply nested) + src_lib_gitignore = self.repo_path / "src" / "lib" / ".gitignore" + src_lib_gitignore.write_text( + """# Library gitignore +*.tmp +*.cache +""" + ) + + # Create docs/.gitignore + docs_gitignore = self.repo_path / "docs" / ".gitignore" + docs_gitignore.write_text( + """# Docs gitignore +temp/ +*.tmp +""" + ) + + def test_initialization(self): + """Test GitignoreParser initialization.""" + parser = GitignoreParser(str(self.repo_path)) + + assert parser.repo_root == str(self.repo_path.absolute()) + assert len(parser.get_ignore_specs()) == 4 + + def test_find_gitignore_files(self): + """Test finding all gitignore files in repository, including deeply nested ones.""" + parser = GitignoreParser(str(self.repo_path)) + + # Get file paths from specs + gitignore_files = [spec.file_path for spec in parser.get_ignore_specs()] + + # Convert to relative paths for easier testing + rel_paths = [os.path.relpath(f, self.repo_path) for f in gitignore_files] + rel_paths.sort() + + assert len(rel_paths) == 4 + assert ".gitignore" in rel_paths + assert os.path.join("src", ".gitignore") in rel_paths + assert os.path.join("src", "lib", ".gitignore") in rel_paths # Deeply nested + assert os.path.join("docs", ".gitignore") in rel_paths + + def test_parse_patterns_root_directory(self): + """Test parsing gitignore patterns in root directory.""" + # Create a simple test case with only root gitignore + test_dir = self.repo_path / "test_root" + test_dir.mkdir() + + gitignore = test_dir / ".gitignore" + gitignore.write_text( + """*.log +build/ +/temp.txt +""" + ) + + parser = GitignoreParser(str(test_dir)) + specs = parser.get_ignore_specs() + + assert len(specs) == 1 + patterns = specs[0].patterns + + assert "*.log" in patterns + assert "build/" in patterns + assert "/temp.txt" in patterns + + def test_parse_patterns_subdirectory(self): + """Test parsing gitignore patterns in subdirectory.""" + # Create a test case with subdirectory gitignore + test_dir = self.repo_path / "test_sub" + test_dir.mkdir() + subdir = test_dir / "src" + subdir.mkdir() + + gitignore = subdir / ".gitignore" + gitignore.write_text( + """*.o +/build/ +test.log +""" + ) + + parser = GitignoreParser(str(test_dir)) + specs = parser.get_ignore_specs() + + assert len(specs) == 1 + patterns = specs[0].patterns + + # Non-anchored pattern should get ** prefix + assert "src/**/*.o" in patterns + # Anchored pattern should not get ** prefix + assert "src/build/" in patterns + # Non-anchored pattern without slash + assert "src/**/test.log" in patterns + + def test_should_ignore_root_patterns(self): + """Test ignoring files based on root .gitignore.""" + parser = GitignoreParser(str(self.repo_path)) + + # Files that should be ignored + assert parser.should_ignore("test.log") + assert parser.should_ignore(str(self.repo_path / "test.log")) + + # Files that should NOT be ignored + assert not parser.should_ignore("file1.txt") + assert not parser.should_ignore("src/main.py") + + def test_match_path_root_directory(self): + """Root directory should never be ignored by pathspec patterns.""" + spec = PathSpec.from_lines("gitwildmatch", ["/.*/"]) + + assert not match_path(".", spec, root_path=str(self.repo_path)) + assert not match_path("", spec, root_path=str(self.repo_path)) + + def test_should_ignore_subdirectory_patterns(self): + """Test ignoring files based on subdirectory .gitignore files.""" + parser = GitignoreParser(str(self.repo_path)) + + # .o files in src should be ignored + assert parser.should_ignore("src/build/output.o") + + # build/ directory in src should be ignored + assert parser.should_ignore("src/build/") + + # temp/ directory in docs should be ignored + assert parser.should_ignore("docs/temp/draft.md") + + # But temp/ outside docs should not be ignored by docs/.gitignore + assert not parser.should_ignore("temp/file.txt") + + # Test deeply nested .gitignore in src/lib/ + # .tmp files in src/lib should be ignored + assert parser.should_ignore("src/lib/cache.tmp") + + # .cache files in src/lib should also be ignored + assert parser.should_ignore("src/lib/data.cache") + + # But .tmp files outside src/lib should not be ignored by src/lib/.gitignore + assert not parser.should_ignore("src/other.tmp") + + def test_anchored_vs_non_anchored_patterns(self): + """Test the difference between anchored and non-anchored patterns.""" + # Create new test structure + test_dir = self.repo_path / "test_anchored" + test_dir.mkdir() + (test_dir / "src").mkdir() + (test_dir / "src" / "subdir").mkdir() + (test_dir / "src" / "subdir" / "deep").mkdir() + + # Create src/.gitignore with both anchored and non-anchored patterns + gitignore = test_dir / "src" / ".gitignore" + gitignore.write_text( + """/temp.txt +data.json +""" + ) + + # Create test files + (test_dir / "src" / "temp.txt").touch() + (test_dir / "src" / "data.json").touch() + (test_dir / "src" / "subdir" / "temp.txt").touch() + (test_dir / "src" / "subdir" / "data.json").touch() + (test_dir / "src" / "subdir" / "deep" / "data.json").touch() + + parser = GitignoreParser(str(test_dir)) + + # Anchored pattern /temp.txt should only match in src/ + assert parser.should_ignore("src/temp.txt") + assert not parser.should_ignore("src/subdir/temp.txt") + + # Non-anchored pattern data.json should match anywhere under src/ + assert parser.should_ignore("src/data.json") + assert parser.should_ignore("src/subdir/data.json") + assert parser.should_ignore("src/subdir/deep/data.json") + + def test_root_anchored_patterns(self): + """Test anchored patterns in root .gitignore only match root-level files.""" + # Create new test structure for root anchored patterns + test_dir = self.repo_path / "test_root_anchored" + test_dir.mkdir() + (test_dir / "src").mkdir() + (test_dir / "docs").mkdir() + (test_dir / "src" / "nested").mkdir() + + # Create root .gitignore with anchored patterns + gitignore = test_dir / ".gitignore" + gitignore.write_text( + """/config.json +/temp.log +/build +*.pyc +""" + ) + + # Create test files at root level + (test_dir / "config.json").touch() + (test_dir / "temp.log").touch() + (test_dir / "build").mkdir() + (test_dir / "file.pyc").touch() + + # Create same-named files in subdirectories + (test_dir / "src" / "config.json").touch() + (test_dir / "src" / "temp.log").touch() + (test_dir / "src" / "build").mkdir() + (test_dir / "src" / "file.pyc").touch() + (test_dir / "docs" / "config.json").touch() + (test_dir / "docs" / "temp.log").touch() + (test_dir / "src" / "nested" / "config.json").touch() + (test_dir / "src" / "nested" / "temp.log").touch() + (test_dir / "src" / "nested" / "build").mkdir() + + parser = GitignoreParser(str(test_dir)) + + # Anchored patterns should only match root-level files + assert parser.should_ignore("config.json") + assert not parser.should_ignore("src/config.json") + assert not parser.should_ignore("docs/config.json") + assert not parser.should_ignore("src/nested/config.json") + + assert parser.should_ignore("temp.log") + assert not parser.should_ignore("src/temp.log") + assert not parser.should_ignore("docs/temp.log") + assert not parser.should_ignore("src/nested/temp.log") + + assert parser.should_ignore("build") + assert not parser.should_ignore("src/build") + assert not parser.should_ignore("src/nested/build") + + # Non-anchored patterns should match everywhere + assert parser.should_ignore("file.pyc") + assert parser.should_ignore("src/file.pyc") + + def test_mixed_anchored_and_non_anchored_root_patterns(self): + """Test mix of anchored and non-anchored patterns in root .gitignore.""" + test_dir = self.repo_path / "test_mixed_patterns" + test_dir.mkdir() + (test_dir / "app").mkdir() + (test_dir / "tests").mkdir() + (test_dir / "app" / "modules").mkdir() + + # Create root .gitignore with mixed patterns + gitignore = test_dir / ".gitignore" + gitignore.write_text( + """/secrets.env +/dist/ +node_modules/ +*.tmp +/app/local.config +debug.log +""" + ) + + # Create test files and directories + (test_dir / "secrets.env").touch() + (test_dir / "dist").mkdir() + (test_dir / "node_modules").mkdir() + (test_dir / "file.tmp").touch() + (test_dir / "app" / "local.config").touch() + (test_dir / "debug.log").touch() + + # Create same files in subdirectories + (test_dir / "app" / "secrets.env").touch() + (test_dir / "app" / "dist").mkdir() + (test_dir / "app" / "node_modules").mkdir() + (test_dir / "app" / "file.tmp").touch() + (test_dir / "app" / "debug.log").touch() + (test_dir / "tests" / "secrets.env").touch() + (test_dir / "tests" / "node_modules").mkdir() + (test_dir / "tests" / "debug.log").touch() + (test_dir / "app" / "modules" / "local.config").touch() + + parser = GitignoreParser(str(test_dir)) + + # Anchored patterns should only match at root + assert parser.should_ignore("secrets.env") + assert not parser.should_ignore("app/secrets.env") + assert not parser.should_ignore("tests/secrets.env") + + assert parser.should_ignore("dist") + assert not parser.should_ignore("app/dist") + + assert parser.should_ignore("app/local.config") + assert not parser.should_ignore("app/modules/local.config") + + # Non-anchored patterns should match everywhere + assert parser.should_ignore("node_modules") + assert parser.should_ignore("app/node_modules") + assert parser.should_ignore("tests/node_modules") + + assert parser.should_ignore("file.tmp") + assert parser.should_ignore("app/file.tmp") + + assert parser.should_ignore("debug.log") + assert parser.should_ignore("app/debug.log") + assert parser.should_ignore("tests/debug.log") + + def test_negation_patterns(self): + """Test negation patterns are parsed correctly.""" + test_dir = self.repo_path / "test_negation" + test_dir.mkdir() + + gitignore = test_dir / ".gitignore" + gitignore.write_text( + """*.log +!important.log +!src/keep.log +""" + ) + + parser = GitignoreParser(str(test_dir)) + specs = parser.get_ignore_specs() + + assert len(specs) == 1 + patterns = specs[0].patterns + + assert "*.log" in patterns + assert "!important.log" in patterns + assert "!src/keep.log" in patterns + + def test_comments_and_empty_lines(self): + """Test that comments and empty lines are ignored.""" + test_dir = self.repo_path / "test_comments" + test_dir.mkdir() + + gitignore = test_dir / ".gitignore" + gitignore.write_text( + """# This is a comment +*.log + +# Another comment + # Indented comment + +build/ +""" + ) + + parser = GitignoreParser(str(test_dir)) + specs = parser.get_ignore_specs() + + assert len(specs) == 1 + patterns = specs[0].patterns + + assert len(patterns) == 2 + assert "*.log" in patterns + assert "build/" in patterns + + def test_escaped_characters(self): + """Test escaped special characters.""" + test_dir = self.repo_path / "test_escaped" + test_dir.mkdir() + + gitignore = test_dir / ".gitignore" + gitignore.write_text( + """\\#not-a-comment.txt +\\!not-negation.txt +""" + ) + + parser = GitignoreParser(str(test_dir)) + specs = parser.get_ignore_specs() + + assert len(specs) == 1 + patterns = specs[0].patterns + + assert "#not-a-comment.txt" in patterns + assert "!not-negation.txt" in patterns + + def test_escaped_negation_patterns(self): + test_dir = self.repo_path / "test_escaped_negation" + test_dir.mkdir() + + gitignore = test_dir / ".gitignore" + gitignore.write_text( + """*.log +\\!not-negation.log +!actual-negation.log +""" + ) + + parser = GitignoreParser(str(test_dir)) + specs = parser.get_ignore_specs() + + assert len(specs) == 1 + patterns = specs[0].patterns + + # Key assertions: escaped exclamation becomes literal, real negation preserved + assert "!not-negation.log" in patterns # escaped -> literal + assert "!actual-negation.log" in patterns # real negation preserved + + # Test the actual behavioral difference between escaped and real negation: + # *.log pattern should ignore test.log + assert parser.should_ignore("test.log") + + # Escaped negation file should still be ignored by *.log pattern + assert parser.should_ignore("!not-negation.log") + + # Actual negation should override the *.log pattern + assert not parser.should_ignore("actual-negation.log") + + def test_glob_patterns(self): + """Test various glob patterns work correctly.""" + test_dir = self.repo_path / "test_glob" + test_dir.mkdir() + + gitignore = test_dir / ".gitignore" + gitignore.write_text( + """*.pyc +**/*.tmp +src/*.o +!src/important.o +[Tt]est* +""" + ) + + # Create test files + (test_dir / "src").mkdir() + (test_dir / "src" / "nested").mkdir() + (test_dir / "file.pyc").touch() + (test_dir / "src" / "file.pyc").touch() + (test_dir / "file.tmp").touch() + (test_dir / "src" / "nested" / "file.tmp").touch() + (test_dir / "src" / "file.o").touch() + (test_dir / "src" / "important.o").touch() + (test_dir / "Test.txt").touch() + (test_dir / "test.log").touch() + + parser = GitignoreParser(str(test_dir)) + + # *.pyc should match everywhere + assert parser.should_ignore("file.pyc") + assert parser.should_ignore("src/file.pyc") + + # **/*.tmp should match all .tmp files + assert parser.should_ignore("file.tmp") + assert parser.should_ignore("src/nested/file.tmp") + + # src/*.o should only match .o files directly in src/ + assert parser.should_ignore("src/file.o") + + # Character class patterns + assert parser.should_ignore("Test.txt") + assert parser.should_ignore("test.log") + + def test_empty_gitignore(self): + """Test handling of empty gitignore files.""" + test_dir = self.repo_path / "test_empty" + test_dir.mkdir() + + gitignore = test_dir / ".gitignore" + gitignore.write_text("") + + parser = GitignoreParser(str(test_dir)) + + # Should not crash and should return empty list + assert len(parser.get_ignore_specs()) == 0 + + def test_malformed_gitignore(self): + """Test handling of malformed gitignore content.""" + test_dir = self.repo_path / "test_malformed" + test_dir.mkdir() + + gitignore = test_dir / ".gitignore" + gitignore.write_text( + """# Only comments and empty lines + +# More comments + + """ + ) + + parser = GitignoreParser(str(test_dir)) + + # Should handle gracefully + assert len(parser.get_ignore_specs()) == 0 + + def test_reload(self): + """Test reloading gitignore files.""" + test_dir = self.repo_path / "test_reload" + test_dir.mkdir() + + # Create initial gitignore + gitignore = test_dir / ".gitignore" + gitignore.write_text("*.log") + + parser = GitignoreParser(str(test_dir)) + assert len(parser.get_ignore_specs()) == 1 + assert parser.should_ignore("test.log") + + # Modify gitignore + gitignore.write_text("*.tmp") + + # Without reload, should still use old patterns + assert parser.should_ignore("test.log") + assert not parser.should_ignore("test.tmp") + + # After reload, should use new patterns + parser.reload() + assert not parser.should_ignore("test.log") + assert parser.should_ignore("test.tmp") + + def test_gitignore_spec_matches(self): + """Test GitignoreSpec.matches method.""" + spec = GitignoreSpec("/path/to/.gitignore", ["*.log", "build/", "!important.log"]) + + assert spec.matches("test.log") + assert spec.matches("build/output.o") + assert spec.matches("src/test.log") + + # Note: Negation patterns in pathspec work differently than in git + # This is a limitation of the pathspec library + + def test_subdirectory_gitignore_pattern_scoping(self): + """Test that subdirectory .gitignore patterns are scoped correctly.""" + # Create test structure: foo/ with subdirectory bar/ + test_dir = self.repo_path / "test_subdir_scoping" + test_dir.mkdir() + (test_dir / "foo").mkdir() + (test_dir / "foo" / "bar").mkdir() + + # Create files in various locations + (test_dir / "foo.txt").touch() # root level + (test_dir / "foo" / "foo.txt").touch() # in foo/ + (test_dir / "foo" / "bar" / "foo.txt").touch() # in foo/bar/ + + # Test case 1: foo.txt in foo/.gitignore should only ignore in foo/ subtree + gitignore = test_dir / "foo" / ".gitignore" + gitignore.write_text("foo.txt\n") + + parser = GitignoreParser(str(test_dir)) + + # foo.txt at root should NOT be ignored by foo/.gitignore + assert not parser.should_ignore("foo.txt"), "Root foo.txt should not be ignored by foo/.gitignore" + + # foo.txt in foo/ should be ignored + assert parser.should_ignore("foo/foo.txt"), "foo/foo.txt should be ignored" + + # foo.txt in foo/bar/ should be ignored (within foo/ subtree) + assert parser.should_ignore("foo/bar/foo.txt"), "foo/bar/foo.txt should be ignored" + + def test_anchored_pattern_in_subdirectory(self): + """Test that anchored patterns in subdirectory only match immediate children.""" + test_dir = self.repo_path / "test_anchored_subdir" + test_dir.mkdir() + (test_dir / "foo").mkdir() + (test_dir / "foo" / "bar").mkdir() + + # Create files + (test_dir / "foo.txt").touch() # root level + (test_dir / "foo" / "foo.txt").touch() # in foo/ + (test_dir / "foo" / "bar" / "foo.txt").touch() # in foo/bar/ + + # Test case 2: /foo.txt in foo/.gitignore should only match foo/foo.txt + gitignore = test_dir / "foo" / ".gitignore" + gitignore.write_text("/foo.txt\n") + + parser = GitignoreParser(str(test_dir)) + + # foo.txt at root should NOT be ignored + assert not parser.should_ignore("foo.txt"), "Root foo.txt should not be ignored" + + # foo.txt directly in foo/ should be ignored + assert parser.should_ignore("foo/foo.txt"), "foo/foo.txt should be ignored by /foo.txt pattern" + + # foo.txt in foo/bar/ should NOT be ignored (anchored pattern only matches immediate children) + assert not parser.should_ignore("foo/bar/foo.txt"), "foo/bar/foo.txt should NOT be ignored by /foo.txt pattern" + + def test_double_star_pattern_scoping(self): + """Test that **/pattern in subdirectory only applies within that subtree.""" + test_dir = self.repo_path / "test_doublestar_scope" + test_dir.mkdir() + (test_dir / "foo").mkdir() + (test_dir / "foo" / "bar").mkdir() + (test_dir / "other").mkdir() + + # Create files + (test_dir / "foo.txt").touch() # root level + (test_dir / "foo" / "foo.txt").touch() # in foo/ + (test_dir / "foo" / "bar" / "foo.txt").touch() # in foo/bar/ + (test_dir / "other" / "foo.txt").touch() # in other/ + + # Test case 3: **/foo.txt in foo/.gitignore should only ignore within foo/ subtree + gitignore = test_dir / "foo" / ".gitignore" + gitignore.write_text("**/foo.txt\n") + + parser = GitignoreParser(str(test_dir)) + + # foo.txt at root should NOT be ignored + assert not parser.should_ignore("foo.txt"), "Root foo.txt should not be ignored by foo/.gitignore" + + # foo.txt in foo/ should be ignored + assert parser.should_ignore("foo/foo.txt"), "foo/foo.txt should be ignored" + + # foo.txt in foo/bar/ should be ignored (within foo/ subtree) + assert parser.should_ignore("foo/bar/foo.txt"), "foo/bar/foo.txt should be ignored" + + # foo.txt in other/ should NOT be ignored (outside foo/ subtree) + assert not parser.should_ignore("other/foo.txt"), "other/foo.txt should NOT be ignored by foo/.gitignore" + + def test_anchored_double_star_pattern(self): + """Test that /**/pattern in subdirectory works correctly.""" + test_dir = self.repo_path / "test_anchored_doublestar" + test_dir.mkdir() + (test_dir / "foo").mkdir() + (test_dir / "foo" / "bar").mkdir() + (test_dir / "other").mkdir() + + # Create files + (test_dir / "foo.txt").touch() # root level + (test_dir / "foo" / "foo.txt").touch() # in foo/ + (test_dir / "foo" / "bar" / "foo.txt").touch() # in foo/bar/ + (test_dir / "other" / "foo.txt").touch() # in other/ + + # Test case 4: /**/foo.txt in foo/.gitignore should correctly ignore only within foo/ subtree + gitignore = test_dir / "foo" / ".gitignore" + gitignore.write_text("/**/foo.txt\n") + + parser = GitignoreParser(str(test_dir)) + + # foo.txt at root should NOT be ignored + assert not parser.should_ignore("foo.txt"), "Root foo.txt should not be ignored" + + # foo.txt in foo/ should be ignored + assert parser.should_ignore("foo/foo.txt"), "foo/foo.txt should be ignored" + + # foo.txt in foo/bar/ should be ignored (within foo/ subtree) + assert parser.should_ignore("foo/bar/foo.txt"), "foo/bar/foo.txt should be ignored" + + # foo.txt in other/ should NOT be ignored (outside foo/ subtree) + assert not parser.should_ignore("other/foo.txt"), "other/foo.txt should NOT be ignored by foo/.gitignore" + + +class TestGitignoreParserPermissionError: + """Test PermissionError handling in GitignoreParser.""" + + def setup_method(self): + self.test_dir = tempfile.mkdtemp() + self.repo_path = Path(self.test_dir) + + def teardown_method(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_scandir_permission_error_on_subdirectory(self): + """ + Test that GitignoreParser does not crash when a subdirectory + is not readable (PermissionError on os.scandir). + + Regression test for https://github.com/oraios/serena/issues/1624 + """ + # Create a root .gitignore + gitignore = self.repo_path / ".gitignore" + gitignore.write_text("*.log\n") + + # Create an unreadable subdirectory + unreadable = self.repo_path / "unreadable_dir" + unreadable.mkdir() + + # Remove read permissions (Unix only; on Windows this is a no-op) + old_mode = os.stat(unreadable).st_mode + os.chmod(unreadable, 0o000) + + try: + # This should not raise PermissionError + parser = GitignoreParser(str(self.repo_path)) + # Parser should still function + assert parser.should_ignore("test.log") + finally: + # Restore permissions so teardown can clean up + os.chmod(unreadable, old_mode) diff --git a/test/serena/util/test_inspection.py b/test/serena/util/test_inspection.py new file mode 100644 index 0000000..90378aa --- /dev/null +++ b/test/serena/util/test_inspection.py @@ -0,0 +1,45 @@ +"""Unit tests for :func:`serena.util.inspection.determine_programming_language_composition`.""" + +from pathlib import Path + +from serena.util.inspection import determine_programming_language_composition +from solidlsp.ls_config import Language + + +def _touch(directory: Path, *names: str) -> None: + for name in names: + (directory / name).write_text("content", encoding="utf-8") + + +class TestDetermineProgrammingLanguageComposition: + def test_single_language_repo(self, tmp_path: Path) -> None: + _touch(tmp_path, "a.py", "b.py", "c.py") + composition = determine_programming_language_composition(str(tmp_path)) + assert composition[Language.PYTHON] == 100.0 + + def test_unrecognised_files_do_not_dilute_percentages(self, tmp_path: Path) -> None: + # 2 source files vs many files that belong to no supported language + _touch(tmp_path, "main.py", "util.py") + _touch(tmp_path, *[f"note_{i}.txt" for i in range(20)]) + _touch(tmp_path, "logo.png", "LICENSE", "data.csv") + + composition = determine_programming_language_composition(str(tmp_path)) + + # previously: 2 / 25 files = 8% — now the denominator is recognised source files only + assert composition[Language.PYTHON] == 100.0 + + def test_mixed_language_percentages_relative_to_recognised_files(self, tmp_path: Path) -> None: + _touch(tmp_path, "a.py", "b.py", "c.py", "d.go") + _touch(tmp_path, *[f"asset_{i}.dat" for i in range(50)]) + + composition = determine_programming_language_composition(str(tmp_path)) + + assert composition[Language.PYTHON] == 75.0 + assert composition[Language.GO] == 25.0 + + def test_repo_without_recognised_files(self, tmp_path: Path) -> None: + _touch(tmp_path, "readme.txt", "logo.png") + assert determine_programming_language_composition(str(tmp_path)) == {} + + def test_empty_repo(self, tmp_path: Path) -> None: + assert determine_programming_language_composition(str(tmp_path)) == {} diff --git a/test/serena/util/test_yaml.py b/test/serena/util/test_yaml.py new file mode 100644 index 0000000..724438c --- /dev/null +++ b/test/serena/util/test_yaml.py @@ -0,0 +1,80 @@ +"""Tests for serena.util.yaml — focused on the atomic-write guarantee of ``save_yaml``. + +Regression: ``~/.serena/serena_config.yml`` was corrupted in the field when a non-atomic +truncate-and-write saved a SHORTER value over a longer existing file (or two Serena processes +saved concurrently), leaving a stale tail (e.g. a dangling ``ena`` line) that made the YAML +unparsable, which wedged every later load. ``save_yaml`` now writes to a temp file and +``os.replace``s it onto the target, so each write is all-or-nothing. +""" + +import os +import threading + +from ruamel.yaml import YAML + +from serena.util.yaml import load_yaml, save_yaml + + +def _read_text(path: str) -> str: + with open(path, encoding="utf-8") as f: + return f.read() + + +def test_save_yaml_roundtrips(tmp_path): + path = str(tmp_path / "config.yml") + save_yaml(path, {"projects": ["a", "b", "c"], "scalar": 1}) + loaded = load_yaml(path) + assert list(loaded["projects"]) == ["a", "b", "c"] + assert loaded["scalar"] == 1 + + +def test_shorter_write_over_longer_leaves_no_stale_tail(tmp_path): + """The exact field corruption: a long file overwritten by a much shorter one. + + A plain ``open(path, "w")`` truncates first, so this would pass even when buggy; the point + of the assertion is that the final file parses and contains EXACTLY the new content — no + leftover bytes from the longer previous version. + """ + path = str(tmp_path / "config.yml") + long_projects = [f"C:/Users/misch/Projects/some/really/long/path/number/{i}/serena" for i in range(50)] + save_yaml(path, {"projects": long_projects}) + + save_yaml(path, {"projects": ["C:/Users/misch/Projects/oraios/serena"]}) + + loaded = load_yaml(path) + assert list(loaded["projects"]) == ["C:/Users/misch/Projects/oraios/serena"] + # no stale tail from the longer write survived + assert "number/49" not in _read_text(path) + # and it is still valid YAML on a fresh strict parse + with open(path, encoding="utf-8") as f: + YAML().load(f) + + +def test_no_tmp_files_left_behind(tmp_path): + path = str(tmp_path / "config.yml") + save_yaml(path, {"projects": ["a"]}) + leftovers = [p for p in os.listdir(tmp_path) if p != "config.yml"] + assert leftovers == [], f"temp files not cleaned up: {leftovers}" + + +def test_concurrent_saves_never_corrupt(tmp_path): + """Many concurrent writers must yield a file that always parses (last-writer-wins is fine; + a corrupt interleave is not). + """ + path = str(tmp_path / "config.yml") + save_yaml(path, {"projects": ["seed"]}) + + def writer(n: int) -> None: + for _ in range(20): + save_yaml(path, {"projects": [f"p{n}-{k}" for k in range(n + 1)]}) + + threads = [threading.Thread(target=writer, args=(n,)) for n in range(6)] + for t in threads: + t.start() + for t in threads: + t.join() + + # whatever won, the file must be complete + parseable (no corruption) + loaded = load_yaml(path) + assert "projects" in loaded + assert all(isinstance(p, str) for p in loaded["projects"]) diff --git a/test/solidlsp/ada/__init__.py b/test/solidlsp/ada/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/solidlsp/ada/test_ada_basic.py b/test/solidlsp/ada/test_ada_basic.py new file mode 100644 index 0000000..18919a4 --- /dev/null +++ b/test/solidlsp/ada/test_ada_basic.py @@ -0,0 +1,144 @@ +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.ada +class TestAdaLanguageServer: + """Tests for AdaCore's Ada Language Server (covers Ada and SPARK). + + Source layout (test/resources/repos/ada/test_repo): + + default.gpr + src/ + helper.ads package spec, declares ``Greet`` and ``Greeting_Style`` + helper.adb package body, defines ``Greet`` + main.adb ``with Helper;`` and calls ``Helper.Greet`` + + LSP positions are 0-indexed. Below, the layout is annotated with the + columns used by each test so that future edits to the fixtures are + obviously test-affecting. + """ + + @pytest.mark.parametrize("language_server", [Language.ADA], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.ADA], indirect=True) + def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.ADA], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.ADA], indirect=True) + def test_find_definition_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + # main.adb (1-indexed source / 0-indexed LSP): + # 5/4: Greeting : constant String := Helper.Greet ("Ada"); + # 7/6: Ada.Text_IO.Put_Line (Greeting); + # `Greeting` in `Put_Line (Greeting)` starts at column 25 on LSP line 6. + main_path = str(repo_path / "src" / "main.adb") + definitions = language_server.request_definition(main_path, 6, 26) + + assert definitions, f"Expected non-empty definition list but got {definitions=}" + assert len(definitions) == 1 + loc = definitions[0] + assert loc["uri"].endswith("main.adb") + # `Greeting` is declared on LSP line 4; ALS points at the identifier (column 3). + assert loc["range"]["start"]["line"] == 4 + + @pytest.mark.parametrize("language_server", [Language.ADA], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.ADA], indirect=True) + def test_find_definition_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + # main.adb LSP line 4 / column 40 sits on the `G` of `Helper.Greet`: + # ` Greeting : constant String := Helper.Greet ("Ada");` + # ^ col 40 + # ALS resolves cross-file `Helper.Greet` to its spec declaration in helper.ads + # (LSP line 4, column 12 — the `G` of `Greet` after ` function `). + main_path = str(repo_path / "src" / "main.adb") + definitions = language_server.request_definition(main_path, 4, 40) + + assert definitions, f"Expected non-empty definition list but got {definitions=}" + assert len(definitions) == 1 + loc = definitions[0] + assert loc["uri"].endswith("helper.ads") + assert loc["range"]["start"]["line"] == 4 + assert loc["range"]["start"]["character"] == 12 + + @pytest.mark.parametrize("language_server", [Language.ADA], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.ADA], indirect=True) + def test_find_references_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + # Click on `Helper` in `with Helper;` (main.adb LSP line 1, column 5). + # Serena's request_references uses includeDeclaration=False, so we need a symbol with + # at least one non-declaration usage in the same file. `Helper` is referenced as a + # context clause on line 1 and again as the qualifier of `Helper.Greet` on line 4. + main_path = str(repo_path / "src" / "main.adb") + references = language_server.request_references(main_path, 1, 5) + + assert references, f"Expected non-empty references for Helper but got {references=}" + ref_lines_in_main = {loc["range"]["start"]["line"] for loc in references if loc["uri"].endswith("main.adb")} + # The qualifier usage on LSP line 4 of main.adb must appear among the references. + assert 4 in ref_lines_in_main, f"Expected reference on line 4 of main.adb, got {ref_lines_in_main}" + + @pytest.mark.parametrize("language_server", [Language.ADA], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.ADA], indirect=True) + def test_find_references_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + # Click on the `G` of `Greet` in the spec: + # helper.ads LSP line 4: ` function Greet (Name : String) return String;` + # `Greet` starts at column 12 (` ` + `function ` = 3 + 9 = 12). + spec_path = str(repo_path / "src" / "helper.ads") + references = language_server.request_references(spec_path, 4, 12) + + assert references, f"Expected non-empty references for Helper.Greet but got {references=}" + ref_files = {loc["uri"].split("/")[-1] for loc in references} + # The call site in main.adb must appear in the references. + assert "main.adb" in ref_files, f"Expected reference in main.adb, got {ref_files}" + + @pytest.mark.parametrize("language_server", [Language.ADA], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + from solidlsp.ls_utils import SymbolUtils + + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "Helper"), "Helper package not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Greet"), "Greet subprogram not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main procedure not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.ADA], indirect=True) + def test_document_symbols_helper(self, language_server: SolidLanguageServer) -> None: + doc_symbols = language_server.request_document_symbols(str(Path("src") / "helper.ads")) + all_symbols, _ = doc_symbols.get_all_symbols_and_roots() + names = {sym.get("name") for sym in all_symbols if sym.get("name")} + assert "Helper" in names, f"Helper package not found in helper.ads document symbols. Found: {names}" + assert "Greet" in names, f"Greet not found in helper.ads document symbols. Found: {names}" + + @pytest.mark.parametrize("language_server", [Language.ADA], indirect=True) + def test_document_symbols_hierarchical_structure(self, language_server: SolidLanguageServer) -> None: + """ALS must return hierarchical DocumentSymbol[] with subprograms nested under their package.""" + all_symbols, root_symbols = language_server.request_document_symbols(str(Path("src") / "helper.ads")).get_all_symbols_and_roots() + + root_names = [s.get("name") for s in root_symbols] + assert "Helper" in root_names, f"Helper package not at root level. Roots: {root_names}" + + helper_symbol = next((s for s in root_symbols if s.get("name") == "Helper"), None) + assert helper_symbol is not None, "Helper package missing from root symbols" + helper_children = helper_symbol.get("children", []) + helper_child_names = [c.get("name") for c in helper_children] + assert helper_child_names, f"Helper package has no children — hierarchicalDocumentSymbolSupport is not working. Roots: {root_names}" + assert "Greet" in helper_child_names, f"Greet not nested under Helper. Children: {helper_child_names}" + + # Greet must NOT appear at root level — that would indicate the flat fallback format. + assert "Greet" not in root_names, f"Greet should be a child of Helper, not at root level. Roots: {root_names}" + + @pytest.mark.parametrize("language_server", [Language.ADA], indirect=True) + def test_bare_symbol_names(self, language_server: SolidLanguageServer) -> None: + # ALS surfaces a few Ada-specific synthetic groupings as symbols: + # - "With clauses" — namespace-kind group containing all `with` statements in a unit + # - dotted unit names like "Ada.Text_IO" — references to library packages + # Both are legitimate names for Ada; allow whitespace and periods accordingly. + all_symbols = request_all_symbols(language_server) + malformed_symbols = [s for s in all_symbols if has_malformed_name(s, whitespace_allowed=True, period_allowed=True)] + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/ada/test_ada_diagnostics.py b/test/solidlsp/ada/test_ada_diagnostics.py new file mode 100644 index 0000000..cac0baa --- /dev/null +++ b/test/solidlsp/ada/test_ada_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.ada +class TestAdaDiagnostics: + @pytest.mark.parametrize("language_server", [Language.ADA], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/diagnostics_sample.adb", + (), + min_count=1, + ) diff --git a/test/solidlsp/al/test_al_basic.py b/test/solidlsp/al/test_al_basic.py new file mode 100644 index 0000000..a52e2f1 --- /dev/null +++ b/test/solidlsp/al/test_al_basic.py @@ -0,0 +1,595 @@ +import os + +import pytest + +from serena.symbol import LanguageServerSymbol +from solidlsp import SolidLanguageServer +from solidlsp.language_servers.al_language_server import ALLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from solidlsp.ls_utils import SymbolUtils +from test.conftest import language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + +pytestmark = [pytest.mark.al, pytest.mark.skipif(not language_tests_enabled(Language.AL), reason="AL tests are disabled")] + + +class TestExtractALDisplayName: + """Tests for the ALLanguageServer._extract_al_display_name method.""" + + def test_table_with_quoted_name(self) -> None: + """Test extraction from Table with quoted name.""" + assert ALLanguageServer._extract_al_display_name('Table 50000 "TEST Customer"') == "TEST Customer" + + def test_page_with_quoted_name(self) -> None: + """Test extraction from Page with quoted name.""" + assert ALLanguageServer._extract_al_display_name('Page 50001 "TEST Customer Card"') == "TEST Customer Card" + + def test_codeunit_unquoted(self) -> None: + """Test extraction from Codeunit with unquoted name.""" + assert ALLanguageServer._extract_al_display_name("Codeunit 50000 CustomerMgt") == "CustomerMgt" + + def test_enum_unquoted(self) -> None: + """Test extraction from Enum with unquoted name.""" + assert ALLanguageServer._extract_al_display_name("Enum 50000 CustomerType") == "CustomerType" + + def test_interface_no_id(self) -> None: + """Test extraction from Interface (no ID).""" + assert ALLanguageServer._extract_al_display_name("Interface IPaymentProcessor") == "IPaymentProcessor" + + def test_table_extension(self) -> None: + """Test extraction from TableExtension.""" + assert ALLanguageServer._extract_al_display_name('TableExtension 50000 "Ext Customer"') == "Ext Customer" + + def test_page_extension(self) -> None: + """Test extraction from PageExtension.""" + assert ALLanguageServer._extract_al_display_name('PageExtension 50000 "My Page Ext"') == "My Page Ext" + + def test_non_al_object_unchanged(self) -> None: + """Test that non-AL-object names pass through unchanged.""" + assert ALLanguageServer._extract_al_display_name("fields") == "fields" + assert ALLanguageServer._extract_al_display_name("CreateCustomer") == "CreateCustomer" + assert ALLanguageServer._extract_al_display_name("Name") == "Name" + + def test_report_with_quoted_name(self) -> None: + """Test extraction from Report.""" + assert ALLanguageServer._extract_al_display_name('Report 50000 "Sales Invoice"') == "Sales Invoice" + + def test_query_unquoted(self) -> None: + """Test extraction from Query.""" + assert ALLanguageServer._extract_al_display_name("Query 50000 CustomerQuery") == "CustomerQuery" + + +@pytest.mark.al +class TestALLanguageServer: + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_symbol_names_are_normalized(self, language_server: SolidLanguageServer) -> None: + """Test that AL symbol names are normalized (metadata stripped).""" + file_path = os.path.join("src", "Tables", "Customer.Table.al") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + _all_symbols, root_symbols = symbols + customer_table = None + for sym in root_symbols: + if sym.get("name") == "TEST Customer": + customer_table = sym + break + + assert customer_table is not None, "Could not find 'TEST Customer' table symbol (name should be normalized)" + # Name should be just "TEST Customer", not "Table 50000 'TEST Customer'" + assert customer_table["name"] == "TEST Customer", f"Expected normalized name 'TEST Customer', got '{customer_table['name']}'" + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_find_symbol_exact_match(self, language_server: SolidLanguageServer) -> None: + """Test that find_symbol can match AL symbols by normalized name without substring_matching.""" + file_path = os.path.join("src", "Tables", "Customer.Table.al") + symbols = language_server.request_document_symbols(file_path) + + # Find symbols that match 'TEST Customer' using LanguageServerSymbol.find() + for root in symbols.root_symbols: + ls_symbol = LanguageServerSymbol(root) + matches = ls_symbol.find("TEST Customer", substring_matching=False) + if matches: + assert len(matches) >= 1, "Should find at least one match for 'TEST Customer'" + assert matches[0].name == "TEST Customer", f"Expected 'TEST Customer', got '{matches[0].name}'" + return + + pytest.fail("Could not find 'TEST Customer' symbol by exact name match") + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_find_codeunit_exact_match(self, language_server: SolidLanguageServer) -> None: + """Test finding a codeunit by its normalized name.""" + file_path = os.path.join("src", "Codeunits", "CustomerMgt.Codeunit.al") + symbols = language_server.request_document_symbols(file_path) + + for root in symbols.root_symbols: + ls_symbol = LanguageServerSymbol(root) + matches = ls_symbol.find("CustomerMgt", substring_matching=False) + if matches: + assert len(matches) >= 1 + assert matches[0].name == "CustomerMgt" + return + + pytest.fail("Could not find 'CustomerMgt' symbol by exact name match") + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + """Test that AL Language Server can find symbols in the test repository with normalized names.""" + symbols = language_server.request_full_symbol_tree() + + # Check for table symbols - names should be normalized (no "Table 50000" prefix) + assert SymbolUtils.symbol_tree_contains_name(symbols, "TEST Customer"), "TEST Customer table not found in symbol tree" + + # Check for page symbols + assert SymbolUtils.symbol_tree_contains_name(symbols, "TEST Customer Card"), "TEST Customer Card page not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "TEST Customer List"), "TEST Customer List page not found in symbol tree" + + # Check for codeunit symbols + assert SymbolUtils.symbol_tree_contains_name(symbols, "CustomerMgt"), "CustomerMgt codeunit not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "PaymentProcessorImpl"), ( + "PaymentProcessorImpl codeunit not found in symbol tree" + ) + + # Check for enum symbol + assert SymbolUtils.symbol_tree_contains_name(symbols, "CustomerType"), "CustomerType enum not found in symbol tree" + + # Check for interface symbol + assert SymbolUtils.symbol_tree_contains_name(symbols, "IPaymentProcessor"), "IPaymentProcessor interface not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_find_table_fields(self, language_server: SolidLanguageServer) -> None: + """Test that AL Language Server can find fields within a table.""" + file_path = os.path.join("src", "Tables", "Customer.Table.al") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + # AL tables should have their fields as child symbols + customer_table = None + _all_symbols, root_symbols = symbols + for sym in root_symbols: + if sym.get("name") == "TEST Customer": + customer_table = sym + break + + assert customer_table is not None, "Could not find TEST Customer table symbol" + + # Check for field symbols (AL nests fields under a "fields" group) + if "children" in customer_table: + # Find the fields group + fields_group = None + for child in customer_table.get("children", []): + if child.get("name") == "fields": + fields_group = child + break + + assert fields_group is not None, "Fields group not found in Customer table" + + # Check actual field names + if "children" in fields_group: + field_names = [child.get("name", "") for child in fields_group.get("children", [])] + assert any("Name" in name for name in field_names), f"Name field not found. Fields: {field_names}" + assert any("Balance" in name for name in field_names), f"Balance field not found. Fields: {field_names}" + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_find_procedures(self, language_server: SolidLanguageServer) -> None: + """Test that AL Language Server can find procedures in codeunits.""" + file_path = os.path.join("src", "Codeunits", "CustomerMgt.Codeunit.al") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + # Find the codeunit symbol - name should be normalized to 'CustomerMgt' + codeunit_symbol = None + _all_symbols, root_symbols = symbols + for sym in root_symbols: + if sym.get("name") == "CustomerMgt": + codeunit_symbol = sym + break + + assert codeunit_symbol is not None, "Could not find CustomerMgt codeunit symbol" + + # Check for procedure symbols (if hierarchical) + if "children" in codeunit_symbol: + procedure_names = [child.get("name", "") for child in codeunit_symbol.get("children", [])] + assert any("CreateCustomer" in name for name in procedure_names), "CreateCustomer procedure not found" + assert any("TestNoSeries" in name for name in procedure_names), "TestNoSeries procedure not found" + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: + """Test that AL Language Server can find references to symbols.""" + # Find references to the Customer table from the CustomerMgt codeunit + table_file = os.path.join("src", "Tables", "Customer.Table.al") + symbols = language_server.request_document_symbols(table_file).get_all_symbols_and_roots() + + # Find the Customer table symbol (name is normalized) + customer_symbol = None + _all_symbols, root_symbols = symbols + for sym in root_symbols: + if sym.get("name") == "TEST Customer": + customer_symbol = sym + break + + if customer_symbol and "selectionRange" in customer_symbol: + sel_start = customer_symbol["selectionRange"]["start"] + refs = language_server.request_references(table_file, sel_start["line"], sel_start["character"]) + + # The Customer table should be referenced in CustomerMgt.Codeunit.al + assert any("CustomerMgt.Codeunit.al" in ref.get("relativePath", "") for ref in refs), ( + "Customer table should be referenced in CustomerMgt.Codeunit.al" + ) + + # It should also be referenced in CustomerCard.Page.al + assert any("CustomerCard.Page.al" in ref.get("relativePath", "") for ref in refs), ( + "Customer table should be referenced in CustomerCard.Page.al" + ) + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_cross_file_symbols(self, language_server: SolidLanguageServer) -> None: + """Test that AL Language Server can handle cross-file symbol relationships.""" + # Get all symbols to verify cross-file visibility + symbols = language_server.request_full_symbol_tree() + + # Count how many AL object symbols we found (names are now normalized) + al_object_names = [] + + def collect_symbols(syms: list) -> None: + for sym in syms: + if isinstance(sym, dict): + name = sym.get("name", "") + # These are normalized names now, so just collect them + al_object_names.append(name) + if "children" in sym: + collect_symbols(sym["children"]) + + collect_symbols(symbols) + + # We should find expected normalized names + assert "TEST Customer" in al_object_names, f"TEST Customer not found in: {al_object_names}" + assert "CustomerMgt" in al_object_names, f"CustomerMgt not found in: {al_object_names}" + assert "CustomerType" in al_object_names, f"CustomerType not found in: {al_object_names}" + + +@pytest.mark.al +class TestALHoverInjection: + """Tests for hover injection of original AL object names with type and ID.""" + + def _get_symbol_hover(self, language_server: SolidLanguageServer, file_path: str, symbol_name: str) -> tuple[dict | None, str | None]: + """Helper to get hover info for a symbol by name. + + Returns (hover_info, hover_value) tuple. + """ + symbols = language_server.request_document_symbols(file_path) + for sym in symbols.root_symbols: + if sym.get("name") == symbol_name: + sel_range = sym.get("selectionRange", {}) + start = sel_range.get("start", {}) + line = start.get("line", 0) + char = start.get("character", 0) + hover = language_server.request_hover(file_path, line, char) + if hover and "contents" in hover: + return hover, hover["contents"].get("value", "") + return hover, None + return None, None + + def _get_child_symbol_hover( + self, language_server: SolidLanguageServer, file_path: str, parent_name: str, child_name_contains: str + ) -> tuple[dict | None, str | None]: + """Helper to get hover info for a child symbol. + + Returns (hover_info, hover_value) tuple. + """ + symbols = language_server.request_document_symbols(file_path) + for sym in symbols.root_symbols: + if sym.get("name") == parent_name: + for child in sym.get("children", []): + if child_name_contains in child.get("name", ""): + sel_range = child.get("selectionRange", {}) + start = sel_range.get("start", {}) + line = start.get("line", 0) + char = start.get("character", 0) + hover = language_server.request_hover(file_path, line, char) + if hover and "contents" in hover: + return hover, hover["contents"].get("value", "") + return hover, None + return None, None + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_table_injects_full_name(self, language_server: SolidLanguageServer) -> None: + """Test that hovering over a Table symbol shows the full object name with ID.""" + file_path = os.path.join("src", "Tables", "Customer.Table.al") + hover, value = self._get_symbol_hover(language_server, file_path, "TEST Customer") + + assert hover is not None, "Hover should return a result for Table symbol" + assert value is not None, "Hover should have content" + assert '**Table 50000 "TEST Customer"**' in value, f"Hover should contain full Table name with ID. Got: {value[:200]}" + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_page_injects_full_name(self, language_server: SolidLanguageServer) -> None: + """Test that hovering over a Page symbol shows the full object name with ID.""" + file_path = os.path.join("src", "Pages", "CustomerCard.Page.al") + hover, value = self._get_symbol_hover(language_server, file_path, "TEST Customer Card") + + assert hover is not None, "Hover should return a result for Page symbol" + assert value is not None, "Hover should have content" + assert '**Page 50001 "TEST Customer Card"**' in value, f"Hover should contain full Page name with ID. Got: {value[:200]}" + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_codeunit_injects_full_name(self, language_server: SolidLanguageServer) -> None: + """Test that hovering over a Codeunit symbol shows the full object name with ID.""" + file_path = os.path.join("src", "Codeunits", "CustomerMgt.Codeunit.al") + hover, value = self._get_symbol_hover(language_server, file_path, "CustomerMgt") + + assert hover is not None, "Hover should return a result for Codeunit symbol" + assert value is not None, "Hover should have content" + assert "**Codeunit 50000 CustomerMgt**" in value, f"Hover should contain full Codeunit name with ID. Got: {value[:200]}" + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_enum_injects_full_name(self, language_server: SolidLanguageServer) -> None: + """Test that hovering over an Enum symbol shows the full object name with ID.""" + file_path = os.path.join("src", "Enums", "CustomerType.Enum.al") + hover, value = self._get_symbol_hover(language_server, file_path, "CustomerType") + + assert hover is not None, "Hover should return a result for Enum symbol" + assert value is not None, "Hover should have content" + assert "**Enum 50000 CustomerType**" in value, f"Hover should contain full Enum name with ID. Got: {value[:200]}" + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_interface_injects_full_name(self, language_server: SolidLanguageServer) -> None: + """Test that hovering over an Interface symbol shows the full object name (no ID for interfaces).""" + file_path = os.path.join("src", "Interfaces", "IPaymentProcessor.Interface.al") + hover, value = self._get_symbol_hover(language_server, file_path, "IPaymentProcessor") + + assert hover is not None, "Hover should return a result for Interface symbol" + assert value is not None, "Hover should have content" + assert "**Interface IPaymentProcessor**" in value, f"Hover should contain full Interface name. Got: {value[:200]}" + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_procedure_no_injection(self, language_server: SolidLanguageServer) -> None: + """Test that hovering over a procedure does NOT inject object name (procedures are not normalized).""" + file_path = os.path.join("src", "Codeunits", "CustomerMgt.Codeunit.al") + hover, value = self._get_child_symbol_hover(language_server, file_path, "CustomerMgt", "CreateCustomer") + + assert hover is not None, "Hover should return a result for procedure" + assert value is not None, "Hover should have content" + # Procedure hover should NOT start with ** (no injection) + assert not value.startswith("**"), f"Procedure hover should not have injected name. Got: {value[:200]}" + # But should contain procedure info + assert "CreateCustomer" in value, f"Hover should contain procedure name. Got: {value[:200]}" + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_field_no_injection(self, language_server: SolidLanguageServer) -> None: + """Test that hovering over a field does NOT inject object name (fields are not normalized).""" + file_path = os.path.join("src", "Tables", "Customer.Table.al") + symbols = language_server.request_document_symbols(file_path) + + # Navigate to a field: Table -> fields -> specific field + for sym in symbols.root_symbols: + if sym.get("name") == "TEST Customer": + for child in sym.get("children", []): + if child.get("name") == "fields": + for field in child.get("children", []): + if "Name" in field.get("name", ""): + sel_range = field.get("selectionRange", {}) + start = sel_range.get("start", {}) + line = start.get("line", 0) + char = start.get("character", 0) + hover = language_server.request_hover(file_path, line, char) + + assert hover is not None, "Hover should return a result for field" + value = hover.get("contents", {}).get("value", "") + # Field hover should NOT start with ** (no injection) + assert not value.startswith("**"), f"Field hover should not have injected name. Got: {value[:200]}" + return + + pytest.fail("Could not find a field to test hover on") + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_multiple_objects_correct_injection(self, language_server: SolidLanguageServer) -> None: + """Test that multiple AL objects each get their correct full name injected.""" + test_cases = [ + (os.path.join("src", "Tables", "Customer.Table.al"), "TEST Customer", 'Table 50000 "TEST Customer"'), + (os.path.join("src", "Codeunits", "CustomerMgt.Codeunit.al"), "CustomerMgt", "Codeunit 50000 CustomerMgt"), + (os.path.join("src", "Enums", "CustomerType.Enum.al"), "CustomerType", "Enum 50000 CustomerType"), + ] + + for file_path, symbol_name, expected_full_name in test_cases: + hover, value = self._get_symbol_hover(language_server, file_path, symbol_name) + + assert hover is not None, f"Hover should return a result for {symbol_name}" + assert value is not None, f"Hover should have content for {symbol_name}" + assert f"**{expected_full_name}**" in value, ( + f"Hover for {symbol_name} should contain '{expected_full_name}'. Got: {value[:200]}" + ) + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_contains_separator_after_injection(self, language_server: SolidLanguageServer) -> None: + """Test that injected hover has a separator between injected name and original content.""" + file_path = os.path.join("src", "Tables", "Customer.Table.al") + hover, value = self._get_symbol_hover(language_server, file_path, "TEST Customer") + + assert hover is not None, "Hover should return a result" + assert value is not None, "Hover should have content" + # Should have the separator after the bold name + assert "---" in value, f"Hover should contain separator. Got: {value[:300]}" + # The separator should come after the injected name + bold_end = value.find("**", 2) # Find closing ** + separator_pos = value.find("---") + assert separator_pos > bold_end, "Separator should come after the injected name" + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_preserves_original_content(self, language_server: SolidLanguageServer) -> None: + """Test that the original hover content is preserved after the injected name.""" + file_path = os.path.join("src", "Tables", "Customer.Table.al") + hover, value = self._get_symbol_hover(language_server, file_path, "TEST Customer") + + assert hover is not None, "Hover should return a result" + assert value is not None, "Hover should have content" + # Original AL hover content should still be present (the table structure) + assert "```al" in value, f"Hover should contain original AL code block. Got: {value[:500]}" + assert 'Table "TEST Customer"' in value, f"Hover should contain original table definition. Got: {value[:500]}" + + +@pytest.mark.al +class TestALPathNormalization: + """Tests for path normalization in hover injection cache.""" + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_with_forward_slash_path(self, language_server: SolidLanguageServer) -> None: + """Test that hover injection works with forward slash paths.""" + file_path = "src/Tables/Customer.Table.al" + symbols = language_server.request_document_symbols(file_path) + + for sym in symbols.root_symbols: + if sym.get("name") == "TEST Customer": + sel_range = sym.get("selectionRange", {}) + start = sel_range.get("start", {}) + line = start.get("line", 0) + char = start.get("character", 0) + + hover = language_server.request_hover(file_path, line, char) + assert hover is not None, "Hover should return a result" + value = hover.get("contents", {}).get("value", "") + assert '**Table 50000 "TEST Customer"**' in value, f"Hover should have injection. Got: {value[:200]}" + return + + pytest.fail("Could not find TEST Customer symbol") + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_with_backslash_path(self, language_server: SolidLanguageServer) -> None: + """Test that hover injection works with backslash paths (Windows style).""" + file_path = "src\\Tables\\Customer.Table.al" + symbols = language_server.request_document_symbols(file_path) + + for sym in symbols.root_symbols: + if sym.get("name") == "TEST Customer": + sel_range = sym.get("selectionRange", {}) + start = sel_range.get("start", {}) + line = start.get("line", 0) + char = start.get("character", 0) + + hover = language_server.request_hover(file_path, line, char) + assert hover is not None, "Hover should return a result" + value = hover.get("contents", {}).get("value", "") + assert '**Table 50000 "TEST Customer"**' in value, f"Hover should have injection. Got: {value[:200]}" + return + + pytest.fail("Could not find TEST Customer symbol") + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_with_mixed_path_formats_symbols_backslash_hover_forward(self, language_server: SolidLanguageServer) -> None: + """Test hover works when symbols requested with backslash but hover with forward slash.""" + file_path_backslash = "src\\Tables\\Customer.Table.al" + file_path_forward = "src/Tables/Customer.Table.al" + + # Request symbols with backslash path + symbols = language_server.request_document_symbols(file_path_backslash) + + for sym in symbols.root_symbols: + if sym.get("name") == "TEST Customer": + sel_range = sym.get("selectionRange", {}) + start = sel_range.get("start", {}) + line = start.get("line", 0) + char = start.get("character", 0) + + # Request hover with forward slash path (different format) + hover = language_server.request_hover(file_path_forward, line, char) + assert hover is not None, "Hover should return a result" + value = hover.get("contents", {}).get("value", "") + assert '**Table 50000 "TEST Customer"**' in value, ( + f"Hover injection should work with mixed path formats. Got: {value[:200]}" + ) + return + + pytest.fail("Could not find TEST Customer symbol") + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_with_mixed_path_formats_symbols_forward_hover_backslash(self, language_server: SolidLanguageServer) -> None: + """Test hover works when symbols requested with forward slash but hover with backslash.""" + file_path_forward = "src/Tables/Customer.Table.al" + file_path_backslash = "src\\Tables\\Customer.Table.al" + + # Request symbols with forward slash path + symbols = language_server.request_document_symbols(file_path_forward) + + for sym in symbols.root_symbols: + if sym.get("name") == "TEST Customer": + sel_range = sym.get("selectionRange", {}) + start = sel_range.get("start", {}) + line = start.get("line", 0) + char = start.get("character", 0) + + # Request hover with backslash path (different format) + hover = language_server.request_hover(file_path_backslash, line, char) + assert hover is not None, "Hover should return a result" + value = hover.get("contents", {}).get("value", "") + assert '**Table 50000 "TEST Customer"**' in value, ( + f"Hover injection should work with mixed path formats. Got: {value[:200]}" + ) + return + + pytest.fail("Could not find TEST Customer symbol") + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_caching_multiple_files_different_path_formats(self, language_server: SolidLanguageServer) -> None: + """Test that hover injection cache works correctly across multiple files with different path formats.""" + test_cases = [ + ("src/Tables/Customer.Table.al", "src\\Tables\\Customer.Table.al", "TEST Customer", 'Table 50000 "TEST Customer"'), + ( + "src\\Codeunits\\CustomerMgt.Codeunit.al", + "src/Codeunits/CustomerMgt.Codeunit.al", + "CustomerMgt", + "Codeunit 50000 CustomerMgt", + ), + ] + + for symbols_path, hover_path, symbol_name, expected_injection in test_cases: + # Request symbols with one path format + symbols = language_server.request_document_symbols(symbols_path) + + for sym in symbols.root_symbols: + if sym.get("name") == symbol_name: + sel_range = sym.get("selectionRange", {}) + start = sel_range.get("start", {}) + line = start.get("line", 0) + char = start.get("character", 0) + + # Request hover with different path format + hover = language_server.request_hover(hover_path, line, char) + assert hover is not None, f"Hover should return a result for {symbol_name}" + value = hover.get("contents", {}).get("value", "") + assert f"**{expected_injection}**" in value, ( + f"Hover for {symbol_name} should have injection with mixed paths. Got: {value[:200]}" + ) + break + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + is_al_display_symbol = ( + s["name"].endswith((".Codeunit", ".Page", ".Table", ".TableExt", ".Enum", ".Interface")) + or (s["name"].startswith('"') and s["name"].endswith('"')) + or s["name"].startswith( + ( + "Enum Name ", + "Area ", + "Group ", + "Field ", + "Part ", + "SystemPart ", + "Repeater ", + "ActionRef ", + "Key ", + "FieldGroup ", + ) + ) + or ":" in s["name"] + ) + if not is_al_display_symbol and has_malformed_name( + s, + whitespace_allowed=s["kind"] in {SymbolKind.Class, SymbolKind.Struct, SymbolKind.Interface, SymbolKind.Enum}, + ): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/angular/conftest.py b/test/solidlsp/angular/conftest.py new file mode 100644 index 0000000..d259508 --- /dev/null +++ b/test/solidlsp/angular/conftest.py @@ -0,0 +1,88 @@ +""" +Pytest fixtures for Angular language server tests. + +This conftest is NOT installing the language server itself — that is fully handled +by ``RuntimeDependencyCollection`` inside ``AngularLanguageServer`` and lands in +Serena's managed ``ls_resources_dir`` like every other LS download. + +What we install here is the *test fixture project's* npm dependencies (notably +``@angular/core``) into the fixture's own ``node_modules``. ngserver requires +``@angular/core`` to be resolvable from the workspace root or it silently treats +every file as "not in an Angular project", at which point all template-aware +features (definition / hover / references on .html, cross-file template→component +navigation) return empty results — tests would pass vacuously while exercising +nothing. Vendoring ``node_modules`` in-repo is impractical (~500MB), so we run +``npm install`` once per checkout, cached across sessions, and serialised across +xdist workers via ``filelock``. +""" + +from __future__ import annotations + +import logging +import os +import shutil +import subprocess +from pathlib import Path + +import pytest +from filelock import FileLock + +log = logging.getLogger(__name__) + +REPO_ROOT = Path(__file__).resolve().parents[2] / "resources" / "repos" / "angular" / "test_repo" +NODE_MODULES = REPO_ROOT / "node_modules" +ANGULAR_CORE_MARKER = NODE_MODULES / "@angular" / "core" / "package.json" +# Lock file lives inside the repo (covered by its .gitignore) so xdist workers +# can serialise on the install. Putting it elsewhere risks placing it on a +# different filesystem from the install dir, which makes flock semantics fuzzy. +INSTALL_LOCK = REPO_ROOT / ".angular-install.lock" + + +@pytest.fixture(scope="session", autouse=True) +def _install_angular_test_repo_node_modules() -> None: + """ + Populate the *test fixture project's* ``node_modules`` (NOT the language server's + install; see module docstring for the distinction). + + Cached across sessions: once ``node_modules/@angular/core/package.json`` exists, + this is a no-op. Skipped (with the test session marked) if npm is unavailable. + + Under pytest-xdist multiple workers race on the install. ``npm install`` does + not internally serialize concurrent runs against the same install dir, so we + take an exclusive ``filelock`` around the whole install/check sequence — the + second worker into the lock will then short-circuit on the marker check. + """ + if ANGULAR_CORE_MARKER.exists(): + log.info("Angular test repo node_modules already populated; skipping npm install") + return + + if shutil.which("npm") is None: + pytest.skip("npm is not available; cannot install Angular test repo dependencies") + + with FileLock(str(INSTALL_LOCK)): + # Re-check inside the lock: a sibling worker may have just installed. + if ANGULAR_CORE_MARKER.exists(): + log.info("Angular test repo node_modules populated by another worker; skipping npm install") + return + + log.warning( + "Installing npm dependencies into the Angular test repo at %s. This is a one-time cost per checkout and may take ~30s.", + REPO_ROOT, + ) + + proc = subprocess.run( + ["npm", "install", "--no-audit", "--no-fund", "--loglevel=warn"], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + check=False, + env=os.environ.copy(), + ) + if proc.returncode != 0: + log.error("npm install failed (rc=%s).\nstdout:\n%s\nstderr:\n%s", proc.returncode, proc.stdout, proc.stderr) + pytest.skip(f"npm install failed in {REPO_ROOT} (rc={proc.returncode}); see logs for details") + + if not ANGULAR_CORE_MARKER.exists(): + pytest.skip(f"npm install completed but {ANGULAR_CORE_MARKER} is missing; cannot run Angular tests") + + log.info("Angular test repo node_modules installed successfully") diff --git a/test/solidlsp/angular/test_angular_basic.py b/test/solidlsp/angular/test_angular_basic.py new file mode 100644 index 0000000..9d2054b --- /dev/null +++ b/test/solidlsp/angular/test_angular_basic.py @@ -0,0 +1,344 @@ +""" +Basic integration tests for the Angular language server. + +The Angular LS understands Angular template syntax (*ngIf, [prop], (event), +{{ interpolation }}, @if/@for blocks) and provides type-aware navigation +between templates and component classes. + +Workspace: a minimal standalone-component Angular app with a service that the +component injects, plus a template (.html) that interpolates component methods. +""" + +import os +import re +from pathlib import Path + +import pytest + +from serena.util.text_utils import find_text_coordinates +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.conftest import read_repo_file, request_all_symbols + + +@pytest.mark.angular +class TestAngularLanguageServerBasics: + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.ANGULAR], indirect=True) + def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_component_class_symbols(self, language_server: SolidLanguageServer) -> None: + """The Angular LS exposes the component class methods/fields via tsserver.""" + all_symbols, _ = language_server.request_document_symbols("src/app/app.component.ts").get_all_symbols_and_roots() + names = [s["name"] for s in all_symbols] + for expected in ("AppComponent", "title", "userName", "items", "greeting", "setName"): + assert expected in names, f"Expected '{expected}' in component symbols: {names}" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_service_class_symbols(self, language_server: SolidLanguageServer) -> None: + all_symbols, _ = language_server.request_document_symbols("src/app/greeting.service.ts").get_all_symbols_and_roots() + names = [s["name"] for s in all_symbols] + for expected in ("GreetingService", "greet", "defaultName"): + assert expected in names, f"Expected '{expected}' in service symbols: {names}" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_full_symbol_tree_includes_all_files(self, language_server: SolidLanguageServer) -> None: + all_symbols = request_all_symbols(language_server) + relative_paths = {s.get("location", {}).get("relativePath") for s in all_symbols} + # The HTML companion is unconditionally started, so .html documentSymbol + # contributes structural element entries to the workspace tree alongside + # the two TS files. + # relativePath uses OS-native separators (Path.relative_to), so build expectations the same way. + for f in ( + os.path.join("src", "app", "app.component.ts"), + os.path.join("src", "app", "greeting.service.ts"), + os.path.join("src", "app", "app.component.html"), + ): + assert f in relative_paths, f"Expected {f} to appear in symbol tree, got {relative_paths}" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_template_definition_to_component_method(self, language_server: SolidLanguageServer) -> None: + """Resolve `greeting()` interpolation in the template to its component method. + + Cursor lands inside the ``greeting`` identifier on the ``{{ greeting() }}`` + line of ``app.component.html``. + """ + path = "src/app/app.component.html" + needle = "greeting()" + coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})") + assert coords is not None, f"Could not find {needle!r} in {path}" + line, col = coords.line, coords.col + # +1 puts the cursor inside the identifier rather than on its leading boundary. + definitions = language_server.request_definition(path, line, col + 1) + assert definitions, f"Expected non-empty cross-file definition for template->component method, got {definitions}" + target_uris = [d["uri"] for d in definitions] + assert any(uri.endswith("app.component.ts") for uri in target_uris), ( + f"Expected definition to resolve into app.component.ts, got URIs: {target_uris}" + ) + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_plain_html_document_symbols(self, language_server: SolidLanguageServer) -> None: + """DocumentSymbol on plain index.html should come back from the HTML companion. + + ngserver returns -32601 for documentSymbol on every .html file (plain or + Angular template). The AngularLanguageServer routes .html documentSymbol + to a tertiary vscode-html-language-server companion so users get a + structural element outline instead of an empty list. + """ + all_symbols, _ = language_server.request_document_symbols("src/index.html").get_all_symbols_and_roots() + names = [s["name"] for s in all_symbols] + # index.html contains , , , , <body>, <app-root> + for expected in ("html", "head", "body", "app-root"): + assert expected in names, f"Expected '{expected}' in plain-HTML symbol list, got: {names}" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_template_html_document_symbols(self, language_server: SolidLanguageServer) -> None: + """DocumentSymbol on an Angular template must return the HTML element tree. + + Angular template syntax (``@if``/``@for``/``{{ }}``) is ignored by the + vscode-html-language-server parser but the surrounding element structure + (``<section>``, ``<h1>``, ``<p>``, ``<input>``, ``<ul>``, ``<app-item-card>``) + is still reported, which is the intended outline. + """ + all_symbols, _ = language_server.request_document_symbols("src/app/app.component.html").get_all_symbols_and_roots() + names = [s["name"] for s in all_symbols] + for expected in ("section", "h1", "p", "input", "ul", "app-item-card"): + assert expected in names, f"Expected '{expected}' in template HTML symbol list, got: {names}" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_ts_method_references_include_template_usage(self, language_server: SolidLanguageServer) -> None: + """References on a .ts component method must include its template callers. + + ``setName`` is defined in app.component.ts and bound in the template via + ``(input)="setName(...)"``. On real Angular projects the typescript-language-server + alone misses the template callers, so the Angular LS routes .ts references + through ngserver which aggregates both .ts and .html usages. + """ + src_path = "src/app/app.component.ts" + # Probe at the declaration site of setName, not a usage; the regex anchors on the + # parameter list signature so we always land on the method header even if calls move. + coords = find_text_coordinates(read_repo_file(language_server, src_path), r"(setName)\(name:") + assert coords is not None, "Could not locate setName declaration in app.component.ts" + # +1 puts the cursor inside the identifier rather than on its leading boundary. + refs = language_server.request_references(src_path, coords.line, coords.col + 1) + ref_paths = {r.get("relativePath", "") for r in refs} + assert any(p.endswith("app.component.html") for p in ref_paths), ( + f"Expected references for setName to include its template callsite in app.component.html, got: {ref_paths}" + ) + + +@pytest.mark.angular +class TestAngularHover: + """Hover routing — .ts goes to the companion tsserver, .html goes to ngserver.""" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_hover_on_ts_method(self, language_server: SolidLanguageServer) -> None: + """Hover on a .ts method declaration is routed through the companion TS server + and must yield a non-empty MarkupContent describing the method signature. + """ + path = "src/app/app.component.ts" + needle = "setName(" + coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})") + assert coords is not None, f"Could not find {needle!r} in {path}" + line, col = coords.line, coords.col + # Cursor inside the identifier (one past the leading 's') so the LSP + # treats the position as the symbol rather than a token boundary. + hover = language_server.request_hover(path, line, col + 1) + assert hover is not None, f"Expected hover info for setName in {path}, got None" + contents = hover.get("contents") + assert contents, f"Expected non-empty hover contents, got: {hover}" + text = contents["value"] if isinstance(contents, dict) else str(contents) + assert "setName" in text, f"Expected setName in hover text, got: {text}" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_hover_on_template_method_call(self, language_server: SolidLanguageServer) -> None: + """Hover on a method call inside an Angular template ({{ greeting() }}) goes + through ngserver and must yield Angular-aware type info. + """ + path = "src/app/app.component.html" + needle = "greeting()" + coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})") + assert coords is not None, f"Could not find {needle!r} in {path}" + line, col = coords.line, coords.col + # Cursor inside the identifier (one past the leading 'g'). + hover = language_server.request_hover(path, line, col + 1) + assert hover is not None, f"Expected hover info for greeting() in {path}, got None" + contents = hover.get("contents") + assert contents, f"Expected non-empty hover contents, got: {hover}" + + +@pytest.mark.angular +class TestAngularDefinitionRouting: + """Cross-file definition for the binding flavours not covered by basic tests.""" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_definition_from_property_binding(self, language_server: SolidLanguageServer) -> None: + """Property binding ``[value]="userName()"`` must resolve to the component's + ``userName`` signal field declaration in app.component.ts. + """ + path = "src/app/app.component.html" + needle = '[value]="userName()"' + coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})") + assert coords is not None, f"Could not find {needle!r} in {path}" + line, col = coords.line, coords.col + # Cursor on the 'u' of userName inside the binding expression + col = col + len('[value]="') + definitions = language_server.request_definition(path, line, col) + assert definitions, f"Expected non-empty definition for userName binding, got {definitions}" + target_uris = [d["uri"] for d in definitions] + assert any(uri.endswith("app.component.ts") for uri in target_uris), ( + f"Expected definition to resolve into app.component.ts, got URIs: {target_uris}" + ) + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_definition_from_event_binding(self, language_server: SolidLanguageServer) -> None: + """Event binding ``(input)="setName(...)"`` must resolve to the component's + ``setName`` method declaration in app.component.ts. + """ + path = "src/app/app.component.html" + needle = '(input)="setName(' + coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})") + assert coords is not None, f"Could not find {needle!r} in {path}" + line, col = coords.line, coords.col + col = col + len('(input)="') # cursor on 's' of setName + definitions = language_server.request_definition(path, line, col) + assert definitions, f"Expected non-empty definition for setName binding, got {definitions}" + target_uris = [d["uri"] for d in definitions] + assert any(uri.endswith("app.component.ts") for uri in target_uris), ( + f"Expected definition to resolve into app.component.ts, got URIs: {target_uris}" + ) + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_definition_service_import_in_component(self, language_server: SolidLanguageServer) -> None: + """The ``GreetingService`` symbol used in the constructor parameter list + of AppComponent must resolve to greeting.service.ts via the companion TS server. + """ + path = "src/app/app.component.ts" + # Probe at the constructor signature, not the import line, to make sure + # we exercise the type-resolution path rather than the module-resolution path. + needle = "private readonly greetings: GreetingService" + coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})") + assert coords is not None, f"Could not find {needle!r} in {path}" + line, col = coords.line, coords.col + col = col + len("private readonly greetings: ") # cursor on 'G' of GreetingService + definitions = language_server.request_definition(path, line, col) + assert definitions, f"Expected definition for GreetingService, got {definitions}" + target_uris = [d["uri"] for d in definitions] + assert any(uri.endswith("greeting.service.ts") for uri in target_uris), ( + f"Expected definition to resolve into greeting.service.ts, got URIs: {target_uris}" + ) + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_definition_from_child_component_selector(self, language_server: SolidLanguageServer) -> None: + """The ``<app-item-card>`` element in the parent template must resolve to the + ItemCardComponent class declaration in item-card.component.ts. + + This is an Angular-specific feature: the @angular/language-service tsserver + plugin makes the selector string a navigable type-reference back to its + component class. + """ + path = "src/app/app.component.html" + needle = "app-item-card" + coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})") + assert coords is not None, f"Could not find {needle!r} in {path}" + line, col = coords.line, coords.col + col = col + 1 # cursor inside 'app-item-card' + definitions = language_server.request_definition(path, line, col) + assert definitions, f"Expected definition for <app-item-card>, got {definitions}" + target_uris = [d["uri"] for d in definitions] + assert any(uri.endswith("item-card.component.ts") for uri in target_uris), ( + f"Expected definition to resolve into item-card.component.ts, got URIs: {target_uris}" + ) + + +@pytest.mark.angular +class TestAngularRename: + """Rename routing returns a WorkspaceEdit without applying it (safe for fixtures).""" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_rename_method_returns_edits_for_ts_and_template(self, language_server: SolidLanguageServer) -> None: + """Renaming the ``setName`` method from its .ts declaration must return a + WorkspaceEdit that touches both app.component.ts (declaration + any TS calls) + and app.component.html (the ``(input)="setName(...)"`` binding). + """ + path = "src/app/app.component.ts" + # Anchor on the declaration site (signature with the parameter list), not a usage. + coords = find_text_coordinates(read_repo_file(language_server, path), r"(setName)\(name:") + assert coords is not None, "Could not locate setName declaration in app.component.ts" + # +1 puts the cursor inside the identifier rather than on its leading boundary. + edit = language_server.request_rename_symbol_edit(path, coords.line, coords.col + 1, "updateName") + assert edit is not None, "Expected WorkspaceEdit, got None" + changes = edit.get("changes") or {} + # Some servers return ``documentChanges`` instead of (or in addition to) ``changes``. + document_changes = edit.get("documentChanges") or [] + all_uris: set[str] = set(changes.keys()) + for dc in document_changes: + uri = (dc.get("textDocument") or {}).get("uri") or dc.get("uri") + if uri: + all_uris.add(uri) + assert any(uri.endswith("app.component.ts") for uri in all_uris), f"Expected rename edits in app.component.ts, got URIs: {all_uris}" + assert any(uri.endswith("app.component.html") for uri in all_uris), ( + f"Expected rename edits in app.component.html (template binding), got URIs: {all_uris}" + ) + + +@pytest.mark.angular +class TestAngularSymbolStructure: + """Hierarchical symbol structure — class symbols must contain method/field children.""" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_component_class_has_methods_and_fields_as_children(self, language_server: SolidLanguageServer) -> None: + """``request_document_symbols`` should return AppComponent as a class symbol + whose children include its methods (``greeting``, ``setName``) and signal + fields (``title``, ``userName``, ``items``). Flat-name checks pass even when + the hierarchy is broken; this test asserts the parent-child relationship. + """ + all_symbols, root_symbols = language_server.request_document_symbols("src/app/app.component.ts").get_all_symbols_and_roots() + + component = next((s for s in all_symbols if s.get("name") == "AppComponent"), None) + assert component is not None, "AppComponent class symbol not found" + + children = component.get("children") or [] + child_names = {c.get("name") for c in children} + for expected in ("greeting", "setName", "title", "userName", "items"): + assert expected in child_names, f"Expected '{expected}' as child of AppComponent, got: {child_names}" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_pipe_class_in_symbol_tree(self, language_server: SolidLanguageServer) -> None: + """A custom pipe (``ExclaimPipe``) declared in exclaim.pipe.ts must appear in + the workspace symbol tree alongside the components and the service. + """ + all_symbols = request_all_symbols(language_server) + names = {s.get("name") for s in all_symbols} + for expected in ("ExclaimPipe", "ItemCardComponent", "AppComponent", "GreetingService"): + assert expected in names, f"Expected '{expected}' in full symbol tree, got: {sorted(names)}" + + +@pytest.mark.angular +class TestAngularImplementations: + """``textDocument/implementation`` is routed through tsserver via the Angular plugin.""" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_find_implementations_of_interface_method(self, language_server: SolidLanguageServer) -> None: + """``Greeter.greet`` is implemented by ``GreetingService``; ``request_implementation`` + invoked at the interface declaration must point at the service. + + Routed through the Angular LS' companion typescript-language-server (with the + @angular/language-service plugin loaded as a tsserver plugin) — bare ngserver + does not own ``textDocument/implementation``. + """ + path = "src/app/greeter.interface.ts" + needle = "greet(" + coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})") + assert coords is not None, f"Could not find {needle!r} in {path}" + line, col = coords.line, coords.col + # Cursor inside the identifier (one past 'g'). + implementations = language_server.request_implementation(path, line, col + 1) + assert implementations, f"Expected at least one implementation of Greeter.greet, got {implementations}" + target_uris = [impl["uri"] for impl in implementations] + assert any(uri.endswith("greeting.service.ts") for uri in target_uris), ( + f"Expected implementation to resolve into greeting.service.ts, got URIs: {target_uris}" + ) diff --git a/test/solidlsp/angular/test_angular_diagnostics.py b/test/solidlsp/angular/test_angular_diagnostics.py new file mode 100644 index 0000000..d1f02bd --- /dev/null +++ b/test/solidlsp/angular/test_angular_diagnostics.py @@ -0,0 +1,44 @@ +""" +Diagnostics tests for the Angular language server. + +Two paths are exercised: + * .ts component — tsserver (via the @angular/language-service plugin) reports + the type-mismatch on a class field initializer. + * .html template — ngserver reports the unresolved identifier in a template + interpolation, but only because the template is attached to a @Component via + ``templateUrl``. Bare .html files are not type-checked by ngserver. +""" + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.angular +class TestAngularDiagnostics: + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_component_class_diagnostics(self, language_server: SolidLanguageServer) -> None: + """The component's ``count: number = 'not-a-number'`` must be flagged by tsserver.""" + assert_file_diagnostics( + language_server, + "src/app/diagnostics_sample.ts", + (), + min_count=1, + ) + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_template_diagnostics(self, language_server: SolidLanguageServer) -> None: + """The template's ``{{ undefinedSignal() }}`` must be flagged by ngserver. + + Routed through the Angular template compiler, which only checks templates + attached to a @Component — see the companion ``diagnostics_sample.ts`` that + wires this file via ``templateUrl``. + """ + assert_file_diagnostics( + language_server, + "src/app/diagnostics_sample.html", + (), + min_count=1, + ) diff --git a/test/solidlsp/angular/test_angular_error_cases.py b/test/solidlsp/angular/test_angular_error_cases.py new file mode 100644 index 0000000..85e9375 --- /dev/null +++ b/test/solidlsp/angular/test_angular_error_cases.py @@ -0,0 +1,369 @@ +""" +Error / edge-case tests for the Angular language server. + +The Angular LS routes by file extension across three child processes: + * ``ngserver`` for ``.html`` definition / references / hover + * companion ``typescript-language-server`` (with @angular/language-service + loaded as a tsserver plugin) for ``.ts`` operations + * companion ``vscode-html-language-server`` for ``.html`` documentSymbol + +The behaviour locked in below is **observed** behaviour on Linux with the +versions pinned in ``angular_language_server.py`` (Angular LS 21.2.10, +typescript-language-server 5.1.3, TypeScript 5.9.3). It is platform-aware where +upstream LSPs are known to differ (Windows TS server tends to swallow malformed +positions instead of raising). +""" + +import os +import sys +import time + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_exceptions import SolidLSPException +from test.conftest import _create_ls + +pytestmark = pytest.mark.angular + +IS_WINDOWS = sys.platform == "win32" + +TS_FILE = os.path.join("src", "app", "app.component.ts") +SERVICE_FILE = os.path.join("src", "app", "greeting.service.ts") +TEMPLATE_FILE = os.path.join("src", "app", "app.component.html") + + +class TestAngularInvalidPositionsOnTs: + """Negative / out-of-bounds line and column on .ts files (TS-companion route).""" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_negative_line_number_containing_symbol(self, language_server: SolidLanguageServer) -> None: + """``request_containing_symbol`` short-circuits before reaching the LS: + a negative line returns None. + """ + result = language_server.request_containing_symbol(TS_FILE, -1, 0) + assert result is None, f"Expected None for negative line, got: {result!r}" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_negative_character_number_containing_symbol(self, language_server: SolidLanguageServer) -> None: + result = language_server.request_containing_symbol(TS_FILE, 5, -1) + assert result is None, f"Expected None for negative character, got: {result!r}" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_line_number_beyond_file_length(self, language_server: SolidLanguageServer) -> None: + """The wrapper code raises ``IndexError`` before reaching the LS.""" + with pytest.raises(IndexError) as exc_info: + language_server.request_containing_symbol(TS_FILE, 99999, 0) + assert "list index out of range" in str(exc_info.value), f"Expected 'list index out of range' error, got: {exc_info.value}" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_character_beyond_line_length_returns_enclosing_class(self, language_server: SolidLanguageServer) -> None: + """Character far beyond end-of-line is clamped by the LSP. Line 5 of + app.component.ts is inside ``export class AppComponent { ... }``, so the + TS companion (with the Angular plugin) returns the AppComponent class + as the containing symbol — not None — which is the documented LSP + behaviour. + """ + result = language_server.request_containing_symbol(TS_FILE, 5, 99999) + assert isinstance(result, dict), f"Expected dict (containing class), got: {result!r}" + assert result.get("name") == "AppComponent", f"Expected containing symbol 'AppComponent', got: {result.get('name')!r}" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_references_at_negative_line_returns_empty(self, language_server: SolidLanguageServer) -> None: + """The Angular LS's TS companion returns ``[]`` for negative-line + ``request_references`` — it does **not** raise. (Plain Vue tests + observe a ``Bad line number`` raise on Linux/macOS; the Angular + plugin layered on tsserver suppresses this.) + """ + result = language_server.request_references(TS_FILE, -1, 0) + assert result == [], f"Expected [], got: {result!r}" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_definition_at_negative_position_returns_empty(self, language_server: SolidLanguageServer) -> None: + """Same as references: TS companion returns ``[]``, does not raise.""" + result = language_server.request_definition(TS_FILE, -1, 0) + assert result == [], f"Expected [], got: {result!r}" + + +@pytest.mark.skipif( + IS_WINDOWS, + reason="Windows ngserver swallows malformed positions instead of raising — same divergence as the TS companion (see test_referencing_symbols_at_invalid_position_raises).", +) +class TestAngularInvalidPositionsOnTemplate: + """Negative / out-of-bounds positions on .html files (ngserver route).""" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_negative_line_definition_raises(self, language_server: SolidLanguageServer) -> None: + """Ngserver does **not** swallow malformed positions: it surfaces a + ``Debug Failure. False expression.`` error from its underlying + compiler. Lock that behaviour to catch any future change. + """ + with pytest.raises(SolidLSPException) as exc_info: + language_server.request_definition(TEMPLATE_FILE, -1, 0) + assert "Debug Failure" in str(exc_info.value) or "Bad line number" in str(exc_info.value), ( + f"Unexpected exception message: {exc_info.value}" + ) + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_character_beyond_line_definition_raises(self, language_server: SolidLanguageServer) -> None: + """Same path as negative line — ngserver raises Debug Failure.""" + with pytest.raises(SolidLSPException) as exc_info: + language_server.request_definition(TEMPLATE_FILE, 0, 99999) + assert "Debug Failure" in str(exc_info.value) or "Bad line number" in str(exc_info.value), ( + f"Unexpected exception message: {exc_info.value}" + ) + + +class TestAngularNonExistentFiles: + """Requests against files that don't exist must raise FileNotFoundError consistently.""" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_nonexistent_ts_file_raises(self, language_server: SolidLanguageServer) -> None: + nonexistent = os.path.join("src", "app", "does-not-exist.component.ts") + with pytest.raises(FileNotFoundError): + language_server.request_references(nonexistent, 0, 0) + with pytest.raises(FileNotFoundError): + language_server.request_definition(nonexistent, 0, 0) + with pytest.raises(FileNotFoundError): + language_server.request_document_symbols(nonexistent) + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_nonexistent_html_file_raises(self, language_server: SolidLanguageServer) -> None: + nonexistent = os.path.join("src", "app", "does-not-exist.component.html") + with pytest.raises(FileNotFoundError): + language_server.request_definition(nonexistent, 0, 0) + with pytest.raises(FileNotFoundError): + language_server.request_document_symbols(nonexistent) + + +class TestAngularUndefinedSymbols: + """Symbols that have no callers / definitions / referencing positions.""" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_definition_at_keyword_position_returns_empty(self, language_server: SolidLanguageServer) -> None: + """Cursor on the ``import`` keyword (line 0, col 0 of app.component.ts) + has no definition target — the TS companion returns ``[]``. + """ + result = language_server.request_definition(TS_FILE, 0, 0) + assert result == [], f"Expected [] for keyword position, got: {result!r}" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_references_for_local_const_have_few_callers(self, language_server: SolidLanguageServer) -> None: + """A locally-scoped private field has at most its declaration plus its + in-method use as references. ``defaultName`` on GreetingService is + used only inside ``greet()``. + """ + all_symbols, _ = language_server.request_document_symbols(SERVICE_FILE).get_all_symbols_and_roots() + sym = next((s for s in all_symbols if s.get("name") == "defaultName"), None) + assert sym is not None, "defaultName symbol missing in fixture" + sel = sym["selectionRange"]["start"] + refs = language_server.request_references(SERVICE_FILE, sel["line"], sel["character"]) + assert isinstance(refs, list), f"Expected list, got {type(refs)}" + # 1 declaration + 1 internal read = 2 expected; allow 1-3 to absorb + # whether the LSP includes the declaration. + assert 1 <= len(refs) <= 3, f"defaultName should have 1-3 references, got {len(refs)}: {refs}" + + +class TestAngularEdgeCasePositions: + """Position (0,0), whitespace lines, and other boundary conditions.""" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_containing_symbol_at_file_start_is_none(self, language_server: SolidLanguageServer) -> None: + """Line 0 of app.component.ts is an ``import`` statement, outside any + class or function — the TS companion returns ``None`` for containing + symbol. + """ + result = language_server.request_containing_symbol(TS_FILE, 0, 0) + assert result is None, f"Expected None at (0,0), got: {result!r}" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_references_at_file_start_returns_empty(self, language_server: SolidLanguageServer) -> None: + """Position (0, 0) is on the ``import`` keyword — no references.""" + result = language_server.request_references(TS_FILE, 0, 0) + assert result == [], f"Expected [] at (0,0), got: {result!r}" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_definition_at_file_start_returns_empty(self, language_server: SolidLanguageServer) -> None: + """Position (0, 0) is on the ``import`` keyword — no definition.""" + result = language_server.request_definition(TS_FILE, 0, 0) + assert result == [], f"Expected [] at (0,0), got: {result!r}" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_template_position_no_containing_symbol(self, language_server: SolidLanguageServer) -> None: + """An Angular template has no class/function containers; the HTML + companion's documentSymbol provides element symbols only. + """ + # (1, 4) lands inside <h1>{{ title() | exclaim }}</h1> on line 1. + result = language_server.request_containing_symbol(TEMPLATE_FILE, 1, 4) + assert result is None, f"Expected None inside template, got: {result!r}" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_containing_symbol_inside_class_body_returns_class(self, language_server: SolidLanguageServer) -> None: + """Lines 5 and 10 of app.component.ts are inside the AppComponent class + body. The TS companion correctly reports AppComponent as the + containing symbol. + """ + for line in (5, 10): + result = language_server.request_containing_symbol(TS_FILE, line, 0) + assert isinstance(result, dict), f"Line {line}: expected dict, got {result!r}" + assert result.get("name") == "AppComponent", f"Line {line}: expected 'AppComponent', got {result.get('name')!r}" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_containing_symbol_outside_class_body_is_none(self, language_server: SolidLanguageServer) -> None: + """Line 0 (import) and line 15 (blank/whitespace inside file but + outside any symbol's range, depending on file structure) report no + containing symbol. + """ + for line in (0,): + result = language_server.request_containing_symbol(TS_FILE, line, 0) + assert result is None, f"Line {line}: expected None, got {result!r}" + + +class TestAngularReferenceEdgeCases: + """Edge cases for the SolidLanguageServer reference helpers (built on top of LSP).""" + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_referencing_symbols_at_invalid_position_raises(self, language_server: SolidLanguageServer) -> None: + """Unlike ``request_references`` (which returns ``[]``), + ``request_referencing_symbols`` validates more strictly and surfaces + the underlying TS server error as a ``SolidLSPException``. + """ + if IS_WINDOWS: + # Windows TS server is known to swallow these — keep platform-specific + # contract minimal here since we can't run Windows in dev. + result = list(language_server.request_referencing_symbols(SERVICE_FILE, -1, -1, include_self=False)) + assert result == [], f"Expected [] on Windows, got: {result!r}" + return + with pytest.raises(SolidLSPException) as exc_info: + list(language_server.request_referencing_symbols(SERVICE_FILE, -1, -1, include_self=False)) + assert "Bad line number" in str(exc_info.value) or "Debug Failure" in str(exc_info.value), ( + f"Unexpected exception message: {exc_info.value}" + ) + + @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) + def test_defining_symbol_at_invalid_position_returns_none(self, language_server: SolidLanguageServer) -> None: + """``request_defining_symbol`` short-circuits when no definition is + found and returns None — no exception, even for negative positions. + """ + result = language_server.request_defining_symbol(TS_FILE, -1, -1) + assert result is None, f"Expected None, got: {result!r}" + + +class TestAngularCoreProbe: + """Unit tests for the monorepo-aware ``_find_angular_core_install`` walk. + + These don't spin up the Angular LS — they exercise the pure-Python + filesystem walker against synthetic node_modules layouts. + """ + + @staticmethod + def _walk(repo_path: str) -> str | None: + """Invoke the bound method against a minimal stub carrying only + ``repository_root_path``. + """ + from solidlsp.language_servers.angular_language_server import AngularLanguageServer + + stub = type("Stub", (), {"repository_root_path": repo_path})() + return AngularLanguageServer._find_angular_core_install(stub) + + def test_finds_core_at_project_root(self, tmp_path) -> None: + core_pkg = tmp_path / "node_modules" / "@angular" / "core" / "package.json" + core_pkg.parent.mkdir(parents=True) + core_pkg.write_text("{}") + assert self._walk(str(tmp_path)) == str(core_pkg) + + def test_finds_hoisted_core_in_workspace_parent(self, tmp_path) -> None: + """Nx / yarn-workspaces layout: ``node_modules`` is hoisted to the + workspace root and the activated sub-package has none of its own. + """ + sub_pkg = tmp_path / "packages" / "app" + sub_pkg.mkdir(parents=True) + core_pkg = tmp_path / "node_modules" / "@angular" / "core" / "package.json" + core_pkg.parent.mkdir(parents=True) + core_pkg.write_text("{}") + # Workspace root marker — the walker stops here. + (tmp_path / "package.json").write_text('{"workspaces": ["packages/*"]}') + + assert self._walk(str(sub_pkg)) == str(core_pkg) + + def test_returns_none_when_not_installed(self, tmp_path) -> None: + """No node_modules anywhere — walker exhausts and returns None.""" + sub_pkg = tmp_path / "packages" / "app" + sub_pkg.mkdir(parents=True) + assert self._walk(str(sub_pkg)) is None + + def test_workspace_root_stops_walk(self, tmp_path) -> None: + """If a ``package.json`` declares ``workspaces`` we should not walk past + it — the workspace root is the canonical install location even when no + ``@angular/core`` is present there. + """ + # Plant a misleading sibling install ABOVE the workspace root that the + # walker must NOT find. + sibling_core = tmp_path / "node_modules" / "@angular" / "core" / "package.json" + sibling_core.parent.mkdir(parents=True) + sibling_core.write_text("{}") + + workspace = tmp_path / "ws" + workspace.mkdir() + (workspace / "package.json").write_text('{"workspaces": ["packages/*"]}') + sub_pkg = workspace / "packages" / "app" + sub_pkg.mkdir(parents=True) + + assert self._walk(str(sub_pkg)) is None + + +class TestAngularStartupCleanup: + """Regression: companion processes (TS + HTML) must not leak when ngserver + fails partway through ``_start_server``. + + Until the cleanup wrapper landed, an exception during ngserver init left + the two companion Node processes orphaned because the parent constructor + never returned a handle on which the caller could invoke ``stop()``. + """ + + def test_companions_cleaned_up_on_initialize_failure(self, monkeypatch: pytest.MonkeyPatch) -> None: + if IS_WINDOWS: + pytest.skip("psutil child enumeration is flaky under Windows CI") + + import psutil + + ls = _create_ls(Language.ANGULAR) + my_proc = psutil.Process(os.getpid()) + children_before = {p.pid for p in my_proc.children(recursive=True)} + + def boom(*_args: object, **_kwargs: object) -> dict: + raise RuntimeError("simulated ngserver init failure") + + # _create_base_initialize_params runs after both companions and ngserver + # have been spawned — exactly the failure window the cleanup wrapper + # protects against. + monkeypatch.setattr(ls, "_create_base_initialize_params", boom) + + try: + with pytest.raises(RuntimeError, match="simulated ngserver init"): + ls.start() + + assert ls._ts_server is None, "TS companion was not cleared after startup failure" + assert ls._html_server is None, "HTML companion was not cleared after startup failure" + + # Allow a brief grace period for the OS to reap the spawned Node + # processes (and grandchildren — typescript-language-server forks + # tsserver). + deadline = time.monotonic() + 5.0 + new_children: set[int] = set() + while time.monotonic() < deadline: + children_after = {p.pid for p in my_proc.children(recursive=True)} + new_children = children_after - children_before + if not new_children: + break + time.sleep(0.2) + assert not new_children, f"Companion processes leaked after startup failure: {sorted(new_children)}" + finally: + # Best-effort: if anything is still around (e.g. ngserver itself, + # which lives outside the cleanup contract), shut it down so the + # test session doesn't leave orphans. + try: + ls.stop(shutdown_timeout=2.0) + except Exception: + pass diff --git a/test/solidlsp/ansible/__init__.py b/test/solidlsp/ansible/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/solidlsp/ansible/test_ansible_basic.py b/test/solidlsp/ansible/test_ansible_basic.py new file mode 100644 index 0000000..666a3f0 --- /dev/null +++ b/test/solidlsp/ansible/test_ansible_basic.py @@ -0,0 +1,68 @@ +""" +Basic integration tests for the Ansible language server. + +These tests validate initialization, hover, and completion capabilities +using the standard Ansible test repository. They work with the standard +@ansible/ansible-language-server from npm. +""" + +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.skipif(not language_tests_enabled(Language.ANSIBLE), reason="Ansible tests are disabled (no native Windows support)") +@pytest.mark.ansible +class TestAnsibleLanguageServerBasics: + """Test basic Ansible language server functionality.""" + + @pytest.mark.parametrize("language_server", [Language.ANSIBLE], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.ANSIBLE], indirect=True) + def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Language server starts and points to the correct repo.""" + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.ANSIBLE], indirect=True) + def test_hover_on_module_contains_documentation(self, language_server: SolidLanguageServer) -> None: + """Hover on ansible.builtin.package returns module documentation.""" + # playbook.yml line 10 (0-indexed): "ansible.builtin.package:" + result = language_server.request_hover("playbook.yml", 10, 8) + assert result is not None, "Expected hover info for ansible.builtin.package" + hover_value = result["contents"] + if isinstance(hover_value, dict): + hover_text = hover_value.get("value", "") + elif isinstance(hover_value, list): + hover_text = " ".join(str(v) for v in hover_value) + else: + hover_text = str(hover_value) + assert "package" in hover_text.lower(), f"Hover should mention 'package', got: {hover_text[:300]}" + + @pytest.mark.parametrize("language_server", [Language.ANSIBLE], indirect=True) + def test_completions_contain_module_names(self, language_server: SolidLanguageServer) -> None: + """Completions at a task keyword position return Ansible module names.""" + # playbook.yml line 10 (0-indexed), col 6: inside a task block + result = language_server.request_completions("playbook.yml", 10, 6) + assert result is not None, "Expected completion results" + assert len(result) > 0, "Expected non-empty completion list" + labels = [item["completionText"] for item in result if "completionText" in item] + assert labels, f"Expected completions with completionText, got: {result[:3]}" + + @pytest.mark.xfail(reason="Seems like ansible LS lacks basic functionality at the moment, textDocument/documentSymbol doesn't work") + @pytest.mark.parametrize("language_server", [Language.ANSIBLE], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/bash/__init__.py b/test/solidlsp/bash/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/solidlsp/bash/test_bash_basic.py b/test/solidlsp/bash/test_bash_basic.py new file mode 100644 index 0000000..c7715a2 --- /dev/null +++ b/test/solidlsp/bash/test_bash_basic.py @@ -0,0 +1,135 @@ +""" +Basic integration tests for the bash language server functionality. + +These tests validate the functionality of the language server APIs +like request_document_symbols using the bash test repository. +""" + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.bash +class TestBashLanguageServerBasics: + """Test basic functionality of the bash language server.""" + + @pytest.mark.parametrize("language_server", [Language.BASH], indirect=True) + def test_bash_language_server_initialization(self, language_server: SolidLanguageServer) -> None: + """Test that bash language server can be initialized successfully.""" + assert language_server is not None + assert language_server.language == Language.BASH + + @pytest.mark.parametrize("language_server", [Language.BASH], indirect=True) + def test_bash_request_document_symbols(self, language_server: SolidLanguageServer) -> None: + """Test request_document_symbols for bash files.""" + # Test getting symbols from main.sh + all_symbols, _root_symbols = language_server.request_document_symbols("main.sh").get_all_symbols_and_roots() + + # Extract function symbols (LSP Symbol Kind 12) + function_symbols = [symbol for symbol in all_symbols if symbol.get("kind") == 12] + function_names = [symbol["name"] for symbol in function_symbols] + + # Should detect all 3 functions from main.sh + assert "greet_user" in function_names, "Should find greet_user function" + assert "process_items" in function_names, "Should find process_items function" + assert "main" in function_names, "Should find main function" + assert len(function_symbols) >= 3, f"Should find at least 3 functions, found {len(function_symbols)}" + + @pytest.mark.parametrize("language_server", [Language.BASH], indirect=True) + def test_bash_request_document_symbols_with_body(self, language_server: SolidLanguageServer) -> None: + """Test request_document_symbols with body extraction.""" + # Test with include_body=True + all_symbols, _root_symbols = language_server.request_document_symbols("main.sh").get_all_symbols_and_roots() + + function_symbols = [symbol for symbol in all_symbols if symbol.get("kind") == 12] + + # Find greet_user function and check it has body + greet_user_symbol = next((sym for sym in function_symbols if sym["name"] == "greet_user"), None) + assert greet_user_symbol is not None, "Should find greet_user function" + + if "body" in greet_user_symbol: + body = greet_user_symbol["body"].get_text() + assert "function greet_user()" in body, "Function body should contain function definition" + assert "case" in body.lower(), "Function body should contain case statement" + + @pytest.mark.parametrize("language_server", [Language.BASH], indirect=True) + def test_bash_utils_functions(self, language_server: SolidLanguageServer) -> None: + """Test function detection in utils.sh file.""" + # Test with utils.sh as well + utils_all_symbols, _utils_root_symbols = language_server.request_document_symbols("utils.sh").get_all_symbols_and_roots() + + utils_function_symbols = [symbol for symbol in utils_all_symbols if symbol.get("kind") == 12] + utils_function_names = [symbol["name"] for symbol in utils_function_symbols] + + # Should detect functions from utils.sh + expected_utils_functions = [ + "to_uppercase", + "to_lowercase", + "trim_whitespace", + "backup_file", + "contains_element", + "log_message", + "is_valid_email", + "is_number", + ] + + for func_name in expected_utils_functions: + assert func_name in utils_function_names, f"Should find {func_name} function in utils.sh" + + assert len(utils_function_symbols) >= 8, f"Should find at least 8 functions in utils.sh, found {len(utils_function_symbols)}" + + @pytest.mark.parametrize("language_server", [Language.BASH], indirect=True) + def test_bash_function_syntax_patterns(self, language_server: SolidLanguageServer) -> None: + """Test that LSP detects different bash function syntax patterns correctly.""" + # Test main.sh (has both 'function' keyword and traditional syntax) + main_all_symbols, _main_root_symbols = language_server.request_document_symbols("main.sh").get_all_symbols_and_roots() + main_functions = [symbol for symbol in main_all_symbols if symbol.get("kind") == 12] + main_function_names = [func["name"] for func in main_functions] + + # Test utils.sh (all use 'function' keyword) + utils_all_symbols, _utils_root_symbols = language_server.request_document_symbols("utils.sh").get_all_symbols_and_roots() + utils_functions = [symbol for symbol in utils_all_symbols if symbol.get("kind") == 12] + utils_function_names = [func["name"] for func in utils_functions] + + # Verify LSP detects both syntax patterns + # main() uses traditional syntax: main() { + assert "main" in main_function_names, "LSP should detect traditional function syntax" + + # Functions with 'function' keyword: function name() { + assert "greet_user" in main_function_names, "LSP should detect function keyword syntax" + assert "process_items" in main_function_names, "LSP should detect function keyword syntax" + + # Verify all expected utils functions are detected by LSP + expected_utils = [ + "to_uppercase", + "to_lowercase", + "trim_whitespace", + "backup_file", + "contains_element", + "log_message", + "is_valid_email", + "is_number", + ] + + for expected_func in expected_utils: + assert expected_func in utils_function_names, f"LSP should detect {expected_func} function" + + # Verify total counts match expectations + assert len(main_functions) >= 3, f"Should find at least 3 functions in main.sh, found {len(main_functions)}" + assert len(utils_functions) >= 8, f"Should find at least 8 functions in utils.sh, found {len(utils_functions)}" + + @pytest.mark.parametrize("language_server", [Language.BASH], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/bash/test_bash_diagnostics.py b/test/solidlsp/bash/test_bash_diagnostics.py new file mode 100644 index 0000000..bd1cc49 --- /dev/null +++ b/test/solidlsp/bash/test_bash_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.bash +class TestBashDiagnostics: + @pytest.mark.parametrize("language_server", [Language.BASH], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.sh", + (), + min_count=1, + ) diff --git a/test/solidlsp/bsl/__init__.py b/test/solidlsp/bsl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/solidlsp/bsl/test_bsl_basic.py b/test/solidlsp/bsl/test_bsl_basic.py new file mode 100644 index 0000000..4e09374 --- /dev/null +++ b/test/solidlsp/bsl/test_bsl_basic.py @@ -0,0 +1,272 @@ +import os +from pathlib import Path +from unittest import mock + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from solidlsp.settings import SolidLSPSettings +from test.conftest import language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + +pytestmark = pytest.mark.skipif(not language_tests_enabled(Language.BSL), reason="BSL tests are disabled (niche, slow and flaky)") + + +@pytest.mark.bsl +class TestBSLLanguageServer: + @pytest.mark.parametrize("language_server", [Language.BSL], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.BSL], indirect=True) + def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Language server starts and attaches to the test repository.""" + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.BSL], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "ВывестиСообщение"), "ВывестиСообщение not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "ПолучитьПриветствие"), "ПолучитьПриветствие not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Инициализировать"), "Инициализировать not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.BSL], indirect=True) + def test_document_symbols(self, language_server: SolidLanguageServer) -> None: + doc_symbols = language_server.request_document_symbols("CommonModule.bsl") + all_symbols, _ = doc_symbols.get_all_symbols_and_roots() + names = [s.get("name") for s in all_symbols if s.get("name")] + assert "ВывестиСообщение" in names, f"ВывестиСообщение not found in CommonModule.bsl symbols. Found: {names}" + assert "ПолучитьПриветствие" in names, f"ПолучитьПриветствие not found in CommonModule.bsl symbols. Found: {names}" + assert "ВызватьПриветствие" in names, f"ВызватьПриветствие not found in CommonModule.bsl symbols. Found: {names}" + + @pytest.mark.parametrize("language_server", [Language.BSL], indirect=True) + def test_full_symbol_tree_within_file(self, language_server: SolidLanguageServer) -> None: + """Scoping the full-tree request to a single file returns that file's symbols.""" + symbols = language_server.request_full_symbol_tree(within_relative_path="ObjectModule.bsl") + assert SymbolUtils.symbol_tree_contains_name(symbols, "Инициализировать"), ( + "Инициализировать not found in ObjectModule.bsl symbol tree" + ) + assert SymbolUtils.symbol_tree_contains_name(symbols, "ПолучитьСостояние"), ( + "ПолучитьСостояние not found in ObjectModule.bsl symbol tree" + ) + + @pytest.mark.parametrize("language_server", [Language.BSL], indirect=True) + def test_find_references_within_file(self, language_server: SolidLanguageServer) -> None: + # CommonModule.bsl (0-indexed): + # line 2: Процедура ВывестиСообщение(Текст) Экспорт <- declaration (name starts at col 10) + # line 13: ВывестиСообщение(Сообщение); <- internal call + # This test asserts within-file resolution on a bare ``.bsl`` file (no surrounding + # 1C project metadata). Cross-module resolution is exercised separately via the + # dedicated ``src/`` fixture in ``test_find_references_across_files`` below. + refs = language_server.request_references("CommonModule.bsl", line=2, column=10) + assert refs, "Expected at least one reference to ВывестиСообщение" + file_names = [ref.get("relativePath", "") for ref in refs] + assert any("CommonModule.bsl" in f for f in file_names), f"Expected self-reference in CommonModule.bsl, got: {file_names}" + # the internal call is on line 13 + matching_lines = [ref["range"]["start"]["line"] for ref in refs if "CommonModule.bsl" in ref.get("relativePath", "")] + assert 12 in matching_lines, f"Expected a reference on line 12 (0-indexed), got lines: {matching_lines}" + + @pytest.mark.parametrize("language_server", [Language.BSL], indirect=True) + def test_find_references_to_function_within_file(self, language_server: SolidLanguageServer) -> None: + # CommonModule.bsl (0-indexed): + # line 6: Функция ПолучитьПриветствие(Имя) Экспорт <- declaration (name starts at col 8) + # line 12: Сообщение = ПолучитьПриветствие("Мир"); <- internal call at col 16 + refs = language_server.request_references("CommonModule.bsl", line=6, column=8) + assert refs, "Expected at least one reference to ПолучитьПриветствие" + matching_lines = [ref["range"]["start"]["line"] for ref in refs if "CommonModule.bsl" in ref.get("relativePath", "")] + assert 11 in matching_lines, f"Expected a reference on line 11 (0-indexed), got lines: {matching_lines}" + + # --- Cross-file reference resolution --- + # + # bsl-language-server links common modules across files only when the fixture ships + # proper 1C Configuration metadata (``Configuration.xml`` + per-module ``.mdo`` / + # ``Ext/Module.bsl`` layout). The ``src/`` subtree under the BSL test repo provides + # a minimal-but-complete Designer-format dump for exactly this purpose. The two + # tests below request references and the go-to-definition from the cross-module + # call site ``ОбщийМодуль1.ВывестиСообщение(...)`` in ``ОбщийМодуль2``. + + _CROSS_REF_MODULE1 = os.path.join("src", "CommonModules", "ОбщийМодуль1", "Ext", "Module.bsl") + _CROSS_REF_MODULE2 = os.path.join("src", "CommonModules", "ОбщийМодуль2", "Ext", "Module.bsl") + + @pytest.mark.parametrize("language_server", [Language.BSL], indirect=True) + def test_find_references_across_files(self, language_server: SolidLanguageServer) -> None: + """``request_references`` from the declaration must include the cross-module call-site.""" + # ОбщийМодуль1 / Ext / Module.bsl (0-indexed): + # line 2: Процедура ВывестиСообщение(Текст) Экспорт <- declaration (name at col 10) + # ОбщийМодуль2 / Ext / Module.bsl (0-indexed): + # line 3: ОбщийМодуль1.ВывестиСообщение("Hello from CommonModule2"); <- cross-module call (col 17) + refs = language_server.request_references(self._CROSS_REF_MODULE1, line=2, column=10) + assert refs, "Expected at least one reference to ВывестиСообщение across modules" + call_site_paths = [ref.get("relativePath", "") for ref in refs if "ОбщийМодуль2" in ref.get("relativePath", "")] + assert call_site_paths, ( + f"Expected a cross-module reference from ОбщийМодуль2, got relativePaths: {[ref.get('relativePath', '') for ref in refs]}" + ) + call_site_lines = [ref["range"]["start"]["line"] for ref in refs if "ОбщийМодуль2" in ref.get("relativePath", "")] + assert 3 in call_site_lines, f"Expected a reference at ОбщийМодуль2/Module.bsl line 3, got lines: {call_site_lines}" + + @pytest.mark.parametrize("language_server", [Language.BSL], indirect=True) + def test_find_definition_across_files(self, language_server: SolidLanguageServer) -> None: + """``request_definition`` from a cross-module call-site must resolve to the other module.""" + # cursor on "ВывестиСообщение" inside + # ОбщийМодуль1.ВывестиСообщение("Hello from CommonModule2"); (line 3, col 17 — 0-indexed) + definitions = language_server.request_definition(self._CROSS_REF_MODULE2, line=3, column=17) + assert definitions, "Expected a cross-module definition for ВывестиСообщение" + target_paths = [d.get("relativePath", "") for d in definitions] + assert any("ОбщийМодуль1" in p for p in target_paths), f"Expected definition to resolve to ОбщийМодуль1, got: {target_paths}" + target_lines = [d["range"]["start"]["line"] for d in definitions if "ОбщийМодуль1" in d.get("relativePath", "")] + assert 2 in target_lines, f"Expected the definition to point at ОбщийМодуль1/Module.bsl line 2, got: {target_lines}" + + @pytest.mark.parametrize("language_server", [Language.BSL], indirect=True) + def test_bare_symbol_names(self, language_server: SolidLanguageServer) -> None: + all_symbols = request_all_symbols(language_server) + malformed = [s for s in all_symbols if has_malformed_name(s)] + if malformed: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(s) for s in malformed]}", + pytrace=False, + ) + + +# --------------------------------------------------------------------------- +# Unit tests — no language server needed, always run regardless of Java +# --------------------------------------------------------------------------- + + +def test_bsl_filename_matcher() -> None: + matcher = Language.BSL.get_source_fn_matcher() + assert matcher.is_relevant_filename("module.bsl") + assert matcher.is_relevant_filename("script.os") + assert not matcher.is_relevant_filename("module.py") + + +def test_bsl_enum_registration() -> None: + assert Language.BSL.value == "bsl" + assert Language.BSL.get_ls_class().__name__ == "BSLLanguageServer" + + +def test_bsl_dependency_provider_default_version() -> None: + """DependencyProvider resolves the default version to the expected versioned JAR path.""" + from solidlsp.language_servers.bsl_language_server import ( + DEFAULT_BSL_LS_VERSION, + BSLLanguageServer, + ) + + settings = SolidLSPSettings() + provider = BSLLanguageServer.DependencyProvider( + settings.get_ls_specific_settings(Language.BSL), + "/tmp/ls_resources", + ) + + expected_version = DEFAULT_BSL_LS_VERSION + expected_jar_dir = os.path.join("/tmp/ls_resources", f"bsl-ls-{expected_version}") + expected_jar_path = os.path.join(expected_jar_dir, f"bsl-language-server-{expected_version}-exec.jar") + + # pretend JAR is already on disk so no download is attempted + with mock.patch("os.path.exists", return_value=True): + jar_path = provider._get_or_install_core_dependency() + + assert jar_path == expected_jar_path + + +def test_bsl_dependency_provider_custom_version_no_sha() -> None: + """User-overridden versions must install without SHA256 verification.""" + from solidlsp.language_servers.bsl_language_server import BSLLanguageServer + from solidlsp.language_servers.common import RuntimeDependencyCollection + + settings = SolidLSPSettings() + settings.ls_specific_settings[Language.BSL] = {"bsl_ls_version": "0.28.0"} + provider = BSLLanguageServer.DependencyProvider( + settings.get_ls_specific_settings(Language.BSL), + "/tmp/ls_resources", + ) + + custom_version = "0.28.0" + expected_jar_dir = os.path.join("/tmp/ls_resources", f"bsl-ls-{custom_version}") + expected_jar_path = os.path.join(expected_jar_dir, f"bsl-language-server-{custom_version}-exec.jar") + + installed_deps = [] + + def fake_install(self_inner, install_dir): + installed_deps.extend(self_inner.get_dependencies_for_current_platform()) + os.makedirs(install_dir, exist_ok=True) + open(expected_jar_path, "w").close() + + with mock.patch.object(RuntimeDependencyCollection, "install", fake_install): + jar_path = provider._get_or_install_core_dependency() + + assert jar_path == expected_jar_path + assert len(installed_deps) == 1 + assert installed_deps[0].sha256 is None, "SHA256 must be None for user-overridden version" + + if os.path.exists(expected_jar_path): + os.remove(expected_jar_path) + if os.path.exists(expected_jar_dir): + os.rmdir(expected_jar_dir) + + +def test_bsl_launch_command_uses_ls_path_without_download() -> None: + """ + When ``ls_path`` is set, the public launch-command flow must return the user's JAR + unchanged and must NOT invoke the download / install path. This covers the real + code path used at runtime (``create_launch_command``), not just private helpers. + """ + from solidlsp.language_servers import bsl_language_server + from solidlsp.language_servers.bsl_language_server import BSLLanguageServer + + settings = SolidLSPSettings() + settings.ls_specific_settings[Language.BSL] = {"ls_path": "/custom/path/bsl-language-server.jar"} + provider = BSLLanguageServer.DependencyProvider( + settings.get_ls_specific_settings(Language.BSL), + "/tmp/ls_resources", + ) + + with ( + mock.patch.object(bsl_language_server.shutil, "which", return_value="/usr/bin/java"), + mock.patch.object(bsl_language_server, "_get_java_major_version", return_value=21), + mock.patch.object( + BSLLanguageServer.DependencyProvider, + "_get_or_install_core_dependency", + side_effect=AssertionError("must not be called when ls_path is provided"), + ) as install_mock, + ): + cmd = provider.create_launch_command() + + assert cmd == ["java", "-jar", "/custom/path/bsl-language-server.jar"] + install_mock.assert_not_called() + + +def test_bsl_launch_command_requires_java() -> None: + """Launch command construction must fail fast when Java is missing.""" + from solidlsp.language_servers import bsl_language_server + from solidlsp.language_servers.bsl_language_server import BSLLanguageServer + + settings = SolidLSPSettings() + settings.ls_specific_settings[Language.BSL] = {"ls_path": "/custom/path/bsl-language-server.jar"} + provider = BSLLanguageServer.DependencyProvider( + settings.get_ls_specific_settings(Language.BSL), + "/tmp/ls_resources", + ) + + with mock.patch.object(bsl_language_server.shutil, "which", return_value=None): + with pytest.raises(RuntimeError, match="not found on PATH"): + provider.create_launch_command() + + +def test_bsl_launch_command_rejects_old_java() -> None: + """Java older than the minimum supported major version must be rejected up front.""" + from solidlsp.language_servers import bsl_language_server + from solidlsp.language_servers.bsl_language_server import BSL_LS_MIN_JAVA_VERSION, BSLLanguageServer + + settings = SolidLSPSettings() + settings.ls_specific_settings[Language.BSL] = {"ls_path": "/custom/path/bsl-language-server.jar"} + provider = BSLLanguageServer.DependencyProvider( + settings.get_ls_specific_settings(Language.BSL), + "/tmp/ls_resources", + ) + + with ( + mock.patch.object(bsl_language_server.shutil, "which", return_value="/usr/bin/java"), + mock.patch.object(bsl_language_server, "_get_java_major_version", return_value=BSL_LS_MIN_JAVA_VERSION - 1), + ): + with pytest.raises(RuntimeError, match=f"Java {BSL_LS_MIN_JAVA_VERSION}\\+"): + provider.create_launch_command() diff --git a/test/solidlsp/bsl/test_bsl_diagnostics.py b/test/solidlsp/bsl/test_bsl_diagnostics.py new file mode 100644 index 0000000..e5dfe69 --- /dev/null +++ b/test/solidlsp/bsl/test_bsl_diagnostics.py @@ -0,0 +1,20 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.bsl +@pytest.mark.skipif(not language_tests_enabled(Language.BSL), reason="BSL tests are disabled") +class TestBSLDiagnostics: + @pytest.mark.parametrize("language_server", [Language.BSL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + """bsl-language-server must flag the unterminated string literal in the fixture.""" + assert_file_diagnostics( + language_server, + "diagnostics_sample.bsl", + (), + min_count=1, + ) diff --git a/test/solidlsp/clojure/__init__.py b/test/solidlsp/clojure/__init__.py new file mode 100644 index 0000000..f7d9dad --- /dev/null +++ b/test/solidlsp/clojure/__init__.py @@ -0,0 +1,21 @@ +from pathlib import Path + +from solidlsp.language_servers.clojure_lsp import verify_clojure_cli + + +def _test_clojure_cli() -> bool: + try: + verify_clojure_cli() + return False + except (FileNotFoundError, RuntimeError): + return True + + +CLI_FAIL = _test_clojure_cli() +TEST_APP_PATH = Path("src") / "test_app" +CORE_PATH = str(TEST_APP_PATH / "core.clj") +UTILS_PATH = str(TEST_APP_PATH / "utils.clj") + + +def is_clojure_cli_available() -> bool: + return not CLI_FAIL diff --git a/test/solidlsp/clojure/test_clojure_basic.py b/test/solidlsp/clojure/test_clojure_basic.py new file mode 100644 index 0000000..3dd06d8 --- /dev/null +++ b/test/solidlsp/clojure/test_clojure_basic.py @@ -0,0 +1,243 @@ +import pytest + +from serena.project import Project +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation +from test.conftest import language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + +from . import CORE_PATH, UTILS_PATH + + +@pytest.mark.skipif(not language_tests_enabled(Language.CLOJURE), reason="Clojure tests are disabled") +@pytest.mark.clojure +class TestLanguageServerBasics: + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_basic_definition(self, language_server: SolidLanguageServer): + """ + Test finding definition of 'greet' function call in core.clj + """ + result = language_server.request_definition(CORE_PATH, 20, 12) # Position of 'greet' in (greet "World") + + assert isinstance(result, list) + assert len(result) >= 1 + + definition = result[0] + assert definition["relativePath"] == CORE_PATH + assert definition["range"]["start"]["line"] == 2, "Should find the definition of greet function at line 2" + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_cross_file_references(self, language_server: SolidLanguageServer): + """ + Test finding references to 'multiply' function from core.clj + """ + result = language_server.request_references(CORE_PATH, 12, 6) + + assert isinstance(result, list) and len(result) >= 2, "Should find definition + usage in utils.clj" + + usage_found = any( + item["relativePath"] == UTILS_PATH and item["range"]["start"]["line"] == 6 # multiply usage in calculate-area + for item in result + ) + assert usage_found, "Should find multiply usage in utils.clj" + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_completions(self, language_server: SolidLanguageServer): + with language_server.open_file(UTILS_PATH): + # After "core/" in calculate-area + result = language_server.request_completions(UTILS_PATH, 6, 8) + + assert isinstance(result, list) and len(result) > 0 + + completion_texts = [item["completionText"] for item in result] + assert any("multiply" in text for text in completion_texts), "Should find 'multiply' function in completions after 'core/'" + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_document_symbols(self, language_server: SolidLanguageServer): + symbols, _ = language_server.request_document_symbols(CORE_PATH).get_all_symbols_and_roots() + + assert isinstance(symbols, list) and len(symbols) >= 4, "greet, add, multiply, -main functions" + + # Check that we find the expected function symbols + symbol_names = [symbol["name"] for symbol in symbols] + expected_functions = ["greet", "add", "multiply", "-main"] + + for func_name in expected_functions: + assert func_name in symbol_names, f"Should find {func_name} function in symbols" + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_hover(self, language_server: SolidLanguageServer): + """Test hover on greet function""" + result = language_server.request_hover(CORE_PATH, 2, 7) + + assert result is not None, "Hover should return information for greet function" + assert "contents" in result + # Should contain function signature or documentation + contents = result["contents"] + if isinstance(contents, str): + assert "greet" in contents.lower() + elif isinstance(contents, dict) and "value" in contents: + assert "greet" in contents["value"].lower() + else: + assert False, f"Unexpected contents format: {type(contents)}" + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_workspace_symbols(self, language_server: SolidLanguageServer): + # Search for functions containing "add" + result = language_server.request_workspace_symbol("add") + + assert isinstance(result, list) and len(result) > 0, "Should find at least one symbol containing 'add'" + + # Should find the 'add' function + symbol_names = [symbol["name"] for symbol in result] + assert any("add" in name.lower() for name in symbol_names), f"Should find 'add' function in symbols: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_namespace_functions(self, language_server: SolidLanguageServer): + """Test definition lookup for core/greet usage in utils.clj""" + # Position of 'greet' in core/greet call + result = language_server.request_definition(UTILS_PATH, 11, 25) + + assert isinstance(result, list) + assert len(result) >= 1 + + definition = result[0] + assert definition["relativePath"] == CORE_PATH, "Should find the definition of greet in core.clj" + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_request_references_with_content(self, language_server: SolidLanguageServer): + """Test references to multiply function with content""" + references = language_server.request_references(CORE_PATH, 12, 6) + result = [ + language_server.retrieve_content_around_line(ref1["relativePath"], ref1["range"]["start"]["line"], 3, 0) for ref1 in references + ] + + assert result is not None, "Should find references with content" + assert isinstance(result, list) + assert len(result) >= 2, "Should find definition + usage in utils.clj" + + for ref in result: + assert ref.source_file_path is not None, "Each reference should have a source file path" + content_str = ref.to_display_string() + assert len(content_str) > 0, "Content should not be empty" + + # Verify we find the reference in utils.clj with context + utils_refs = [ref for ref in result if ref.source_file_path and "utils.clj" in ref.source_file_path] + assert len(utils_refs) > 0, "Should find reference in utils.clj" + + # The context should contain the calculate-area function + utils_content = utils_refs[0].to_display_string() + assert "calculate-area" in utils_content + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_request_full_symbol_tree(self, language_server: SolidLanguageServer): + """Test retrieving the full symbol tree for project overview + We just check that we find some expected symbols. + """ + result = language_server.request_full_symbol_tree() + + assert result is not None, "Should return symbol tree" + assert isinstance(result, list), "Symbol tree should be a list" + assert len(result) > 0, "Should find symbols in the project" + + def traverse_symbols(symbols, indent=0): + """Recursively traverse symbols to print their structure""" + info = [] + for s in symbols: + name = getattr(s, "name", "NO_NAME") + kind = getattr(s, "kind", "NO_KIND") + info.append(f"{' ' * indent}Symbol: {name}, Kind: {kind}") + if hasattr(s, "children") and s.children: + info.append(" " * indent + "Children:") + info.extend(traverse_symbols(s.children, indent + 2)) + return info + + def list_all_symbols(symbols: list[UnifiedSymbolInformation]): + found = [] + for symbol in symbols: + found.append(symbol["name"]) + found.extend(list_all_symbols(symbol["children"])) + return found + + all_symbol_names = list_all_symbols(result) + + expected_symbols = ["greet", "add", "multiply", "-main", "calculate-area", "format-greeting", "sum-list"] + found_expected = [name for name in expected_symbols if any(name in symbol_name for symbol_name in all_symbol_names)] + + if len(found_expected) < 7: + pytest.fail( + f"Expected to find at least 3 symbols from {expected_symbols}, but found: {found_expected}.\n" + f"All symbol names: {all_symbol_names}\n" + f"Symbol tree structure:\n{traverse_symbols(result)}" + ) + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_request_referencing_symbols(self, language_server: SolidLanguageServer): + """Test finding symbols that reference a given symbol + Finds references to the 'multiply' function. + """ + result = language_server.request_referencing_symbols(CORE_PATH, 12, 6) + assert isinstance(result, list) and len(result) > 0, "Should find at least one referencing symbol" + found_relevant_references = False + for ref in result: + if hasattr(ref, "symbol") and "calculate-area" in ref.symbol["name"]: + found_relevant_references = True + break + + assert found_relevant_references, f"Should have found calculate-area referencing multiply, but got: {result}" + + +@pytest.mark.skipif(not language_tests_enabled(Language.CLOJURE), reason="Clojure tests are disabled") +@pytest.mark.clojure +class TestProjectBasics: + @pytest.mark.parametrize("project", [Language.CLOJURE], indirect=True) + def test_retrieve_content_around_line(self, project: Project): + """Test retrieving content around specific lines""" + # Test retrieving content around the greet function definition (line 2) + result = project.retrieve_content_around_line(CORE_PATH, 2, 2) + + assert result is not None, "Should retrieve content around line 2" + content_str = result.to_display_string() + assert "greet" in content_str, "Should contain the greet function definition" + assert "defn" in content_str, "Should contain defn keyword" + + # Test retrieving content around multiply function (around line 13) + result = project.retrieve_content_around_line(CORE_PATH, 13, 1) + + assert result is not None, "Should retrieve content around line 13" + content_str = result.to_display_string() + assert "multiply" in content_str, "Should contain multiply function" + + @pytest.mark.parametrize("project", [Language.CLOJURE], indirect=True) + def test_search_files_for_pattern(self, project: Project) -> None: + result = project.search_project_files_for_pattern("defn.*greet") + + assert result is not None, "Pattern search should return results" + assert len(result) > 0, "Should find at least one match for 'defn.*greet'" + + core_matches = [match for match in result if match.source_file_path and "core.clj" in match.source_file_path] + assert len(core_matches) > 0, "Should find greet function in core.clj" + + result = project.search_project_files_for_pattern(":require") + + assert result is not None, "Should find require statements" + utils_matches = [match for match in result if match.source_file_path and "utils.clj" in match.source_file_path] + assert len(utils_matches) > 0, "Should find require statement in utils.clj" + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + # clojure-lsp exposes namespace and dependency/container entries in addition to real vars/functions. + # Those qualified names are not the target of this regression check. + if s["kind"] in {SymbolKind.Namespace, SymbolKind.Struct}: + continue + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/clojure/test_clojure_diagnostics.py b/test/solidlsp/clojure/test_clojure_diagnostics.py new file mode 100644 index 0000000..a7bff96 --- /dev/null +++ b/test/solidlsp/clojure/test_clojure_diagnostics.py @@ -0,0 +1,19 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.clojure +class TestClojureDiagnostics: + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + os.path.join("src", "test_app", "diagnostics_sample.clj"), + ("missing-greeting", "missing-consumer-value"), + min_count=2, + ) diff --git a/test/solidlsp/clojure/test_clojure_indexing.py b/test/solidlsp/clojure/test_clojure_indexing.py new file mode 100644 index 0000000..9335499 --- /dev/null +++ b/test/solidlsp/clojure/test_clojure_indexing.py @@ -0,0 +1,110 @@ +"""Regression tests for clojure-lsp project-wide indexing. + +Bug: ``find_referencing_symbols`` (and the underlying ``request_references``) +returns incomplete cross-file results unless the referencing files have already +been opened/indexed during the current session via ``find_symbol`` / +``get_symbols_overview``. The result is deterministically partial — it grows as +more files happen to get indexed by side-effect — which silently biases call +graphs towards files the user has already explored. + +The tests here use a fresh module-scoped language server and probe a symbol +whose references live in a file (``extra.clj``) that no other test in this +module touches. The references must be returned even though no prior tool call +has caused the file to be opened in the LSP. +""" + +from pathlib import Path + +import pytest + +from serena.util.text_utils import find_text_coordinates +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled +from test.solidlsp.conftest import read_repo_file + +from . import CORE_PATH, TEST_APP_PATH + +EXTRA_PATH = str(TEST_APP_PATH / "extra.clj") +SUBMODULE_CONSUMER_PATH = str(Path("sub_module") / "src" / "sub_module_app" / "consumer.clj") + + +@pytest.mark.skipif(not language_tests_enabled(Language.CLOJURE), reason="Clojure tests are disabled") +@pytest.mark.clojure +class TestClojureProjectIndexing: + """Covers the "indexing leaks through results" bug for clojure-lsp. + + The fixture is module-scoped, so all tests within this class share a single + server. We deliberately avoid any operation that would open ``extra.clj`` + (``get_symbols_overview``, ``find_symbol``, ``open_file``) before asserting + on references — otherwise the bug would be masked by the side-effectful + indexing of those calls. + """ + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_request_references_includes_unopened_file(self, language_server: SolidLanguageServer) -> None: + # locate the definition of `multiply` in core.clj without hardcoding coords + core_content = read_repo_file(language_server, CORE_PATH) + coords = find_text_coordinates(core_content, r"\(defn (multiply)\b", require_unique=True) + assert coords is not None, "Could not locate the 'multiply' definition in core.clj" + + # request references straight away — nothing has caused extra.clj to be opened in the LSP + refs = language_server.request_references(CORE_PATH, coords.line, coords.col) + ref_paths = {r.get("relativePath", "") for r in refs} + + # extra.clj contains two real call sites (in double-product and triple-product); + # they must be returned regardless of whether the file was opened beforehand + extra_refs = [r for r in refs if r.get("relativePath", "").endswith("extra.clj")] + assert extra_refs, ( + "Expected references to 'multiply' to include call sites from extra.clj, " + f"but got files: {sorted(ref_paths)}. " + "This indicates clojure-lsp is only indexing files that have been opened " + "during the session, so cross-file references are silently incomplete." + ) + assert len(extra_refs) >= 2, ( + f"Expected at least 2 references in extra.clj (double-product and triple-product), got {len(extra_refs)}: {extra_refs}" + ) + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_request_references_includes_unopened_sibling_module_file(self, language_server: SolidLanguageServer) -> None: + """Mirrors the real-world penpot bug repro: ``multiply`` is defined in one + module (root ``src/``) and consumed from a sibling module that has its + own ``deps.edn`` (``sub_module/``). With clojure-lsp running at the + repository root, references to ``multiply`` must include the sibling + module's call sites — even when no prior tool call has opened those + files in the LSP. + """ + core_content = read_repo_file(language_server, CORE_PATH) + coords = find_text_coordinates(core_content, r"\(defn (multiply)\b", require_unique=True) + assert coords is not None, "Could not locate the 'multiply' definition in core.clj" + + refs = language_server.request_references(CORE_PATH, coords.line, coords.col) + ref_paths = {r.get("relativePath", "") for r in refs} + + consumer_refs = [ + r for r in refs if r.get("relativePath", "").replace("\\", "/").endswith("sub_module/src/sub_module_app/consumer.clj") + ] + assert consumer_refs, ( + "Expected references to 'multiply' to include call sites from the sibling module " + "(sub_module/src/sub_module_app/consumer.clj), but got files: " + f"{sorted(ref_paths)}. This mirrors the penpot bug: clojure-lsp does not appear to " + "index files in sibling modules until they are explicitly opened via find_symbol / " + "get_symbols_overview, so reference search returns silently incomplete results." + ) + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_request_referencing_symbols_includes_unopened_file(self, language_server: SolidLanguageServer) -> None: + core_content = read_repo_file(language_server, CORE_PATH) + coords = find_text_coordinates(core_content, r"\(defn (multiply)\b", require_unique=True) + assert coords is not None, "Could not locate the 'multiply' definition in core.clj" + + # request_referencing_symbols is what the user-facing find_referencing_symbols tool calls into + refs = language_server.request_referencing_symbols(CORE_PATH, coords.line, coords.col) + + referencing_names = {ref.symbol.get("name", "") for ref in refs if hasattr(ref, "symbol")} + # double-product and triple-product live in extra.clj and both call multiply + assert "double-product" in referencing_names or "triple-product" in referencing_names, ( + "Expected referencing symbols to include functions from extra.clj (double-product / triple-product), " + f"but got: {sorted(referencing_names)}. " + "extra.clj appears not to have been indexed by clojure-lsp because no prior tool call opened it." + ) diff --git a/test/solidlsp/conftest.py b/test/solidlsp/conftest.py new file mode 100644 index 0000000..6586387 --- /dev/null +++ b/test/solidlsp/conftest.py @@ -0,0 +1,111 @@ +from pathlib import Path + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation + +PYTHON_BACKEND_LANGUAGES = [Language.PYTHON, Language.PYTHON_TY, Language.PYTHON_PYREFLY] + + +def read_repo_file(language_server: SolidLanguageServer, relative_path: str) -> str: + """Read the text content of ``relative_path`` resolved against the LS's repository root. + + Convenience for test code that needs to feed file content to + :func:`serena.util.text_utils.find_text_coordinates`. + """ + abs_path = Path(language_server.language_server.repository_root_path) / relative_path + return abs_path.read_text() + + +def is_diagnostics_test_file(relative_path: str) -> bool: + normalized_path = relative_path.replace("\\", "/") + filename = normalized_path.rsplit("/", 1)[-1].lower() + return filename.startswith(("diagnosticssample.", "diagnostics_sample.")) + + +def document_symbol_names(language_server: SolidLanguageServer, relative_path: str) -> list[str]: + """All symbol names in a file's document-symbol tree, including children.""" + symbols = language_server.request_document_symbols(relative_path).get_all_symbols_and_roots() + symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols + names: list[str] = [] + + def _collect(syms) -> None: + for sym in syms: + names.append(sym.get("name")) + _collect(sym.get("children", []) or []) + + _collect(symbol_list) + return names + + +def find_document_symbol(language_server: SolidLanguageServer, relative_path: str, name: str) -> UnifiedSymbolInformation: + """The first symbol called ``name`` in a file's document-symbol tree; fails the test if absent.""" + symbols = language_server.request_document_symbols(relative_path).get_all_symbols_and_roots() + symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols + + def _search(syms): + for sym in syms: + if sym.get("name") == name: + return sym + found = _search(sym.get("children", []) or []) + if found is not None: + return found + return None + + result = _search(symbol_list) + assert result is not None, f"Symbol '{name}' not found in {relative_path}" + return result + + +def has_malformed_name( + symbol: UnifiedSymbolInformation, + whitespace_allowed: bool = False, + period_allowed: bool = False, + colon_allowed: bool = False, + brace_allowed: bool = False, + parenthesis_allowed: bool = False, + comma_allowed: bool = False, +) -> bool: + forbidden_chars: list[str] = [] + + if not whitespace_allowed: + forbidden_chars.append(" ") + if not period_allowed: + forbidden_chars.append(".") + if not colon_allowed: + forbidden_chars.append(":") + if not brace_allowed: + forbidden_chars.append("{") + if not parenthesis_allowed: + forbidden_chars.append("(") + if not comma_allowed: + forbidden_chars.append(",") + + return any(separator in symbol["name"] for separator in forbidden_chars) + + +def request_all_symbols(language_server: SolidLanguageServer) -> list[UnifiedSymbolInformation]: + result: list[UnifiedSymbolInformation] = [] + + def visit(symbol: UnifiedSymbolInformation) -> None: + relative_path = symbol.get("location", {}).get("relativePath", "") + if relative_path and is_diagnostics_test_file(relative_path): + return + result.append(symbol) + for child in symbol.get("children", []): + visit(child) + + symbols = language_server.request_full_symbol_tree() + for symbol in symbols: + visit(symbol) + + return result + + +def format_symbol_for_assert(symbol: UnifiedSymbolInformation) -> str: + relative_path = symbol.get("location", {}).get("relativePath", "<unknown>") + try: + kind = SymbolKind(symbol["kind"]).name + except ValueError: + kind = str(symbol["kind"]) + return f"{symbol['name']} [{kind}] ({relative_path})" diff --git a/test/solidlsp/cpp/__init__.py b/test/solidlsp/cpp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/solidlsp/cpp/test_ccls_languages.py b/test/solidlsp/cpp/test_ccls_languages.py new file mode 100644 index 0000000..70cfac6 --- /dev/null +++ b/test/solidlsp/cpp/test_ccls_languages.py @@ -0,0 +1,24 @@ +import os + +import pytest + +from solidlsp.ls_config import Language +from test.conftest import start_ls_context + + +@pytest.mark.cpp +class TestCCLSLanguages: + @pytest.mark.parametrize( + "lang, unit, names", + [ + ("C", "a.c", {"main"}), + ("Objective-C", "a.m", {"main", "Hello", "-hello"}), + ], + ) + def test_get_document_symbols(self, lang: str, unit: str, names: set[str]) -> None: + with start_ls_context(Language.CPP) as ccls: + path = os.path.join(lang, unit) + symbols = ccls.request_document_symbols(path).get_all_symbols_and_roots() + symbols = symbols[0] if symbols and isinstance(symbols[0], list) else symbols + symbols = {s.get("name") for s in symbols} + assert names == symbols, f"Expected '{names}' in document symbols, got: {symbols}" diff --git a/test/solidlsp/cpp/test_clangd_languages.py b/test/solidlsp/cpp/test_clangd_languages.py new file mode 100644 index 0000000..8bf3a30 --- /dev/null +++ b/test/solidlsp/cpp/test_clangd_languages.py @@ -0,0 +1,30 @@ +import os + +import pytest + +from solidlsp.ls_config import Language +from test.conftest import start_ls_context + + +@pytest.mark.cpp +class TestClangdLanguages: + @pytest.mark.parametrize( + "lang, unit, names", + [ + ("C", "a.c", {"main"}), + ("CUDA", "a.cu", {"main", "cuda_hello"}), + ("CXX20", "a.cpp", {"main"}), + ("CXX20", "b.cpp", {"add", "add_f32"}), + ("CXX20", "b.cppm", {"add", "add_f32", "add_u32"}), + ("HIP", "a.hip", {"add_vectors"}), + ("OpenCL", "a.cl", {"add_vectors"}), + ("Objective-C", "a.m", {"main", "Hello", "-hello"}), + ], + ) + def test_get_document_symbols(self, lang: str, unit: str, names: set[str]) -> None: + with start_ls_context(Language.CPP) as clangd: + path = os.path.join(lang, unit) + symbols = clangd.request_document_symbols(path).get_all_symbols_and_roots() + symbols = symbols[0] if symbols and isinstance(symbols[0], list) else symbols + symbols = {s.get("name") for s in symbols} + assert names == symbols, f"Expected '{names}' in document symbols, got: {symbols}" diff --git a/test/solidlsp/cpp/test_clangd_logging.py b/test/solidlsp/cpp/test_clangd_logging.py new file mode 100644 index 0000000..8cef777 --- /dev/null +++ b/test/solidlsp/cpp/test_clangd_logging.py @@ -0,0 +1,43 @@ +import logging + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.language_servers.clangd_language_server import ClangdLanguageServer + + +@pytest.mark.cpp +class TestClangdLogging: + @pytest.mark.parametrize( + "expected, line", + [ + # Parametrize names instead of codes for human-readable output in test logs. + (logging.getLevelName(logging.INFO), "I[12:00:00.000] clangd version 22.1.3"), + (logging.getLevelName(logging.INFO), "V[12:00:00.000] verbose detail"), + (logging.getLevelName(logging.DEBUG), "D[12:00:00.000] debug detail"), + (logging.getLevelName(logging.ERROR), "E[12:00:00.000] something bad"), + (logging.getLevelName(logging.ERROR), "F[12:00:00.000] fatal"), + ], + ) + def test_log_record_detection(self, expected: str, line: str) -> None: + """ + Verifies clangd's stderr log classifier maps each level prefix to the + expected Python logging level and avoids misclassifying compile-command + payloads as ERROR. + """ + assert ClangdLanguageServer._determine_log_level(line) == logging.getLevelNamesMapping()[expected] + + def test_log_record_info_payload_mentions_fno_exceptions(self) -> None: + line = """clang++ -fno-exceptions -c no_exceptions.cpp""" + # Basic classifier incorrectly detects line as an error log. + assert SolidLanguageServer._determine_log_level(line) == logging.ERROR + # Clangd classifier correctly detects line as an info log. + assert ClangdLanguageServer._determine_log_level(line) == logging.INFO + + def test_log_record_info_payload_without_prefix(self) -> None: + # Multi-line records: first line carries the I[...] prefix, subsequent + # lines do not. Treat lack of prefix as continuation. + assert ClangdLanguageServer._determine_log_level("[/home/user/project/build]") == logging.INFO + + def test_log_record_info_payload_empty(self) -> None: + assert ClangdLanguageServer._determine_log_level("") == logging.INFO diff --git a/test/solidlsp/cpp/test_cpp_basic.py b/test/solidlsp/cpp/test_cpp_basic.py new file mode 100644 index 0000000..749efca --- /dev/null +++ b/test/solidlsp/cpp/test_cpp_basic.py @@ -0,0 +1,238 @@ +""" +Basic tests for C/C++ language server integration (clangd and ccls). + +This module tests both Language.CPP (clangd) and Language.CPP_CCLS (ccls) +using the same test repository. Tests are skipped if the respective language +server is not available. +""" + +import os +import pathlib +import shutil +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.conftest import get_repo_path, language_tests_enabled, start_ls_context +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + +_cpp_servers: list[Language] = [Language.CPP] +if language_tests_enabled(Language.CPP_CCLS): + _cpp_servers.append(Language.CPP_CCLS) + + +@pytest.mark.parametrize("language", [Language.CPP, Language.CPP_CCLS]) +def test_source_fn_matcher_includes_ino(language: Language) -> None: + """Arduino .ino sketches are C++ and must route to the C++ language server. + + This is a pure matcher check; it needs no running language server. + """ + matcher = language.get_source_fn_matcher() + assert matcher.is_relevant_filename("sketch.ino") + assert matcher.is_relevant_filename("BLINK.INO") # case-insensitive + assert matcher.is_relevant_filename("/path/to/s3_camera.ino") + assert matcher.is_relevant_filename("main.cpp") # regression: ordinary C++ still matches + assert not matcher.is_relevant_filename("notes.md") + + +@pytest.mark.cpp +@pytest.mark.skipif(not _cpp_servers, reason="No C++ language server (clangd or ccls) available") +class TestCppLanguageServer: + """Tests for C/C++ language servers (clangd and ccls).""" + + @pytest.mark.parametrize("language_server", _cpp_servers, indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + """Test that symbol tree contains expected functions.""" + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "Function 'add' not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "Function 'main' not found in symbol tree" + + @pytest.mark.parametrize("language_server", _cpp_servers, indirect=True) + def test_get_document_symbols(self, language_server: SolidLanguageServer) -> None: + """Test document symbols for a.cpp.""" + file_path = os.path.join("a.cpp") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + # Flatten nested structure if needed + symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols + names = [s.get("name") for s in symbol_list] + assert "main" in names, f"Expected 'main' in document symbols, got: {names}" + + @pytest.mark.parametrize("language_server", _cpp_servers, indirect=True) + def test_find_referencing_symbols_across_files(self, language_server: SolidLanguageServer) -> None: + """Test finding references to 'add' function across files.""" + # Locate 'add' in b.cpp + file_path = os.path.join("b.cpp") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols + add_symbol = None + for sym in symbol_list: + if sym.get("name") == "add": + add_symbol = sym + break + assert add_symbol is not None, "Could not find 'add' function symbol in b.cpp" + + sel_start = add_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + ref_files = [ref.get("relativePath", "") for ref in refs] + assert any("a.cpp" in ref_file for ref_file in ref_files), f"Should find reference in a.cpp, {refs=}" + + # Verify second call returns same results (stability check) + def _ref_key(ref: dict) -> tuple: + rp = ref.get("relativePath", "") + rng = ref.get("range") or {} + s = rng.get("start") or {} + e = rng.get("end") or {} + return ( + rp, + s.get("line", -1), + s.get("character", -1), + e.get("line", -1), + e.get("character", -1), + ) + + refs2 = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert sorted(map(_ref_key, refs2)) == sorted(map(_ref_key, refs)), "Reference results should be stable across calls" + + @pytest.mark.parametrize("language_server", _cpp_servers, indirect=True) + @pytest.mark.xfail( + strict=True, + reason=("Both clangd and ccls do not support cross-file references for newly created files that were never opened by the LS."), + ) + def test_find_references_in_newly_written_file(self, language_server: SolidLanguageServer) -> None: + # Create a new file that references the 'add' function from b.cpp + new_file_path = os.path.join("temp_new_file.cpp") + new_file_abs_path = os.path.join(language_server.repository_root_path, new_file_path) + + try: + # Write the new file with a reference to add() + with open(new_file_abs_path, "w", encoding="utf-8") as f: + f.write( + """ +#include "b.hpp" + +int use_add() { + int result = add(5, 3); + return result; +} +""" + ) + + # Open the new file so clangd knows about it + with language_server.open_file(new_file_path): + # Request document symbols to ensure the file is fully loaded by clangd + new_file_symbols = language_server.request_document_symbols(new_file_path).get_all_symbols_and_roots() + assert new_file_symbols, "New file should have symbols" + + # Verify the file stays in open_file_buffers after the context exits + uri = pathlib.Path(new_file_abs_path).as_uri() + assert uri in language_server.open_file_buffers, "File should remain in open_file_buffers" + + # Find the 'add' symbol in b.cpp + b_file_path = os.path.join("b.cpp") + symbols = language_server.request_document_symbols(b_file_path).get_all_symbols_and_roots() + symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols + add_symbol = None + for sym in symbol_list: + if sym.get("name") == "add": + add_symbol = sym + break + assert add_symbol is not None, "Could not find 'add' function symbol in b.cpp" + + # Request references for 'add' + sel_start = add_symbol["selectionRange"]["start"] + refs = language_server.request_references(b_file_path, sel_start["line"], sel_start["character"]) + ref_files = [ref.get("relativePath", "") for ref in refs] + + # Should find reference in the newly written file + assert any("temp_new_file.cpp" in ref_file for ref_file in ref_files), ( + f"Should find reference in newly written temp_new_file.cpp, {ref_files=}" + ) + finally: + # Clean up the new file + if os.path.exists(new_file_abs_path): + os.remove(new_file_abs_path) + + @pytest.mark.parametrize("language_server", _cpp_servers, indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) + + +@pytest.mark.cpp +class TestCppDocumentSymbolCache: + def _copy_cpp_fixture(self, tmp_path: Path) -> Path: + fixture_path = get_repo_path(Language.CPP) + target_path = tmp_path / "test_repo" + shutil.copytree(fixture_path, target_path) + return target_path + + def test_cache_invalidates_when_clangd_context_changes(self, tmp_path: Path) -> None: + repo_path = self._copy_cpp_fixture(tmp_path) + ls_settings_alt = { + Language.CPP: { + "compile_commands_dir": ".serena-alt", + } + } + + main_cpp = os.path.join("a.cpp") + + def _assert_caches_loaded_and_clean(ls: SolidLanguageServer) -> None: + assert ls._raw_document_symbols_cache, "Expected raw document-symbol cache to load from disk" + assert ls._document_symbols_cache, "Expected document-symbol cache to load from disk" + assert not ls._raw_document_symbols_cache_is_modified + assert not ls._document_symbols_cache_is_modified + + def _assert_caches_empty(ls: SolidLanguageServer) -> None: + assert ls._raw_document_symbols_cache == {} + assert ls._document_symbols_cache == {} + + def _assert_caches_modified(ls: SolidLanguageServer) -> None: + assert ls._raw_document_symbols_cache_is_modified + assert ls._document_symbols_cache_is_modified + + with start_ls_context(Language.CPP, repo_path=str(repo_path), solidlsp_dir=tmp_path) as ls_default: + _ = ls_default.request_document_symbols(main_cpp) + + default_raw_cache_version = ls_default._raw_document_symbols_cache_version() + default_doc_cache_version = ls_default._document_symbols_cache_version() + + ls_default.save_cache() + cache_dir = ls_default.cache_dir + cache_files = [p for p in cache_dir.rglob("*") if p.is_file()] + assert cache_files, f"Expected SolidLSP to create cache artifacts under {cache_dir}" + + with start_ls_context(Language.CPP, repo_path=str(repo_path), solidlsp_dir=tmp_path) as ls_default_again: + assert ls_default_again.cache_dir == cache_dir + _assert_caches_loaded_and_clean(ls_default_again) + _ = ls_default_again.request_document_symbols(main_cpp) + assert not ls_default_again._raw_document_symbols_cache_is_modified + assert not ls_default_again._document_symbols_cache_is_modified + + with start_ls_context( + Language.CPP, + repo_path=str(repo_path), + ls_specific_settings=ls_settings_alt, + solidlsp_dir=tmp_path, + ) as ls_alt: + assert ls_alt.cache_dir == cache_dir + alt_raw_cache_version = ls_alt._raw_document_symbols_cache_version() + alt_doc_cache_version = ls_alt._document_symbols_cache_version() + + assert alt_raw_cache_version != default_raw_cache_version + assert alt_doc_cache_version != default_doc_cache_version + + _assert_caches_empty(ls_alt) + + _ = ls_alt.request_document_symbols(main_cpp) + _assert_caches_modified(ls_alt) diff --git a/test/solidlsp/cpp/test_cpp_diagnostics.py b/test/solidlsp/cpp/test_cpp_diagnostics.py new file mode 100644 index 0000000..20a5836 --- /dev/null +++ b/test/solidlsp/cpp/test_cpp_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.cpp +class TestCppDiagnostics: + @pytest.mark.parametrize("language_server", [Language.CPP], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.cpp", + (), + min_count=1, + ) diff --git a/test/solidlsp/cpp/test_cpp_unreal.py b/test/solidlsp/cpp/test_cpp_unreal.py new file mode 100644 index 0000000..540becd --- /dev/null +++ b/test/solidlsp/cpp/test_cpp_unreal.py @@ -0,0 +1,187 @@ +""" +Unreal Engine 5 fixture tests for the clangd language server. + +UE game code is written against a macro-based reflection layer (UCLASS, UFUNCTION, +UPROPERTY, GENERATED_BODY) and engine container types (TArray, TMap). These tests +verify that the clangd backend resolves symbols, references, definitions, and +rename edits in the hand-written sources under Source/, and never in the +UnrealHeaderTool-style generated files under Intermediate/ (which contain the +same identifiers, as real generated reflection code does). + +The fixture's stub engine headers mirror UE 5.7's ObjectMacros.h: annotation +macros are empty in real UE compilation too (only UnrealHeaderTool parses them). + +The fixture lives in its own repository directory (ue_test_repo) served by +clangd only: ccls 0.20240202, the build shipped by Ubuntu and Homebrew, crashes +intermittently when its session covers the stub engine headers, and clangd is +the supported backend for Unreal Engine projects (see the setup guide). +""" + +import os +from collections.abc import Iterator + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.conftest import find_identifier_position, get_repo_path, start_ls_context +from test.solidlsp.conftest import document_symbol_names, find_document_symbol + +UE_REPO_PATH = get_repo_path(Language.CPP).parent / "ue_test_repo" +ABILITY_COMPONENT_H = os.path.join("Source", "TestGame", "AbilityComponent.h") +ABILITY_ACTOR_H = os.path.join("Source", "TestGame", "AbilityActor.h") +ABILITY_ACTOR_CPP = os.path.join("Source", "TestGame", "AbilityActor.cpp") + + +@pytest.fixture(scope="module") +def language_server() -> Iterator[SolidLanguageServer]: + """Clangd over ue_test_repo; overrides the shared fixture for this module.""" + with start_ls_context(Language.CPP, repo_path=str(UE_REPO_PATH)) as ls: + yield ls + + +@pytest.mark.cpp +class TestClangdUnrealEngine: + """clangd on Unreal Engine-shaped C++ (reflection macros, engine containers).""" + + def test_symbol_tree_contains_reflected_types(self, language_server: SolidLanguageServer) -> None: + """UCLASS/USTRUCT-decorated types appear in the full symbol tree.""" + symbols = language_server.request_full_symbol_tree() + for name in ("UAbilityComponent", "AAbilityActor", "FAbilityInfo"): + assert SymbolUtils.symbol_tree_contains_name(symbols, name), f"'{name}' not found in symbol tree" + + def test_document_symbols_show_uclass_members(self, language_server: SolidLanguageServer) -> None: + """UFUNCTION methods and UPROPERTY fields are visible despite the macro layer.""" + names = document_symbol_names(language_server, ABILITY_COMPONENT_H) + for expected in ("UAbilityComponent", "TriggerAbility", "GetRemainingCooldown", "Abilities", "ActiveCooldowns"): + assert expected in names, f"Expected '{expected}' in document symbols of AbilityComponent.h, got: {names}" + + def test_references_resolve_to_handwritten_sources(self, language_server: SolidLanguageServer) -> None: + """References to a UFUNCTION are found across files, and only in Source/, never Intermediate/.""" + # Precondition: the identifier appears verbatim in generated files on disk. + # A text search would return them; a symbol-level tool must not. + gen_cpp = UE_REPO_PATH / "Intermediate" / "TestGame" / "UHT" / "AbilityComponent.gen.cpp" + assert "TriggerAbility" in gen_cpp.read_text(encoding="utf-8"), "fixture broken: honeypot lost its bait" + + trigger = find_document_symbol(language_server, ABILITY_COMPONENT_H, "TriggerAbility") + sel_start = trigger["selectionRange"]["start"] + refs = language_server.request_references(ABILITY_COMPONENT_H, sel_start["line"], sel_start["character"]) + ref_files = [ref.get("relativePath", "") for ref in refs] + assert any("AbilityActor.cpp" in ref_file for ref_file in ref_files), ( + f"Expected cross-file reference in AbilityActor.cpp, got: {ref_files}" + ) + leaked = [ref_file for ref_file in ref_files if "Intermediate" in ref_file] + assert not leaked, f"References leaked into generated files: {leaked}" + + def test_definition_resolves_to_source_not_generated(self, language_server: SolidLanguageServer) -> None: + """Go-to-definition on a UCLASS usage lands in the hand-written header.""" + header_path = UE_REPO_PATH / "Source" / "TestGame" / "AbilityActor.h" + position = find_identifier_position(header_path, "UAbilityComponent") + assert position is not None, "UAbilityComponent is not used in AbilityActor.h" + definitions = language_server.request_definition(ABILITY_ACTOR_H, position[0], position[1]) + def_paths = [d.get("relativePath", "") for d in definitions] + assert any("AbilityComponent.h" in p for p in def_paths), f"Expected definition in AbilityComponent.h, got: {def_paths}" + assert all("Intermediate" not in p for p in def_paths), f"Definition resolved into generated files: {def_paths}" + + def test_symbol_locations_are_in_handwritten_sources(self, language_server: SolidLanguageServer) -> None: + """Each reflected symbol's defining declaration is locatable in Source/. + Serena's symbol edits (replace_symbol_body, insert_after_symbol) operate at + these locations, so this is the edit-targeting guarantee. Generated headers + may legitimately surface same-named forward declarations (real UHT output + contains them); what matters is that the Source/ declaration is present. + """ + expected_files = { + "UAbilityComponent": "AbilityComponent.h", + "AAbilityActor": "AbilityActor.h", + "FAbilityInfo": "AbilityTypes.h", + "TriggerAbility": "AbilityComponent.h", + "OnAbilityInput": "AbilityActor.h", + } + symbols = language_server.request_full_symbol_tree() + found: dict[str, list[str]] = {name: [] for name in expected_files} + + def _walk(syms): + for sym in syms: + name = sym.get("name") + if name in found: + location = sym.get("location") or {} + found[name].append(location.get("relativePath", "") or str(location.get("uri", ""))) + _walk(sym.get("children", []) or []) + + _walk(symbols) + for name, expected_file in expected_files.items(): + source_locations = [p for p in found[name] if "Source" in p and expected_file in p] + assert source_locations, f"'{name}' has no location in Source/{{...}}/{expected_file}, got: {found[name]}" + + def test_uenum_and_enumerators_visible(self, language_server: SolidLanguageServer) -> None: + """UENUM-decorated enum and its UMETA-annotated enumerators appear as symbols.""" + ability_types_h = os.path.join("Source", "TestGame", "AbilityTypes.h") + names = document_symbol_names(language_server, ability_types_h) + for expected in ("EAbilityState", "Idle", "Active", "Cooldown"): + assert expected in names, f"Expected '{expected}' in document symbols of AbilityTypes.h, got: {names}" + + def test_macro_generated_delegate_type_resolves_to_source(self, language_server: SolidLanguageServer) -> None: + """DECLARE_DYNAMIC_MULTICAST_DELEGATE manufactures a class, which must appear + as a symbol located at the macro expansion site in the hand-written header. + """ + delegate = find_document_symbol(language_server, ABILITY_COMPONENT_H, "FOnAbilityTriggered") + location_path = (delegate.get("location") or {}).get("relativePath", "") + assert location_path, f"FOnAbilityTriggered has no location: {delegate}" + assert "Intermediate" not in location_path, f"Delegate symbol located in generated files: {location_path}" + member_names = document_symbol_names(language_server, ABILITY_COMPONENT_H) + assert "OnAbilityTriggered" in member_names, "BlueprintAssignable delegate property not visible" + + def test_interface_pattern_symbols(self, language_server: SolidLanguageServer) -> None: + """The UINTERFACE pattern (paired UDamageable/IDamageable classes) yields both + classes and the interface method; the implementing class shows the override. + """ + damageable_h = os.path.join("Source", "TestGame", "Damageable.h") + names = document_symbol_names(language_server, damageable_h) + for expected in ("UDamageable", "IDamageable", "ReceiveDamage"): + assert expected in names, f"Expected '{expected}' in document symbols of Damageable.h, got: {names}" + + character_h = os.path.join("Source", "TestGame", "GameCharacter.h") + character_names = document_symbol_names(language_server, character_h) + assert "ReceiveDamage" in character_names, "Interface override not visible in implementing class" + + def test_log_category_symbol_in_source(self, language_server: SolidLanguageServer) -> None: + """DECLARE_LOG_CATEGORY_EXTERN manufactures a category object symbol in hand-written code.""" + log_h = os.path.join("Source", "TestGame", "TestGameLog.h") + names = document_symbol_names(language_server, log_h) + assert "LogTestGame" in names, f"Expected log category symbol 'LogTestGame', got: {names}" + + def test_nondynamic_delegate_type_in_source(self, language_server: SolidLanguageServer) -> None: + """DECLARE_MULTICAST_DELEGATE (non-dynamic family) also manufactures a type in Source/.""" + delegate = find_document_symbol(language_server, ABILITY_COMPONENT_H, "FOnCooldownExpired") + location_path = (delegate.get("location") or {}).get("relativePath", "") + assert location_path, f"FOnCooldownExpired has no location: {delegate}" + assert "Intermediate" not in location_path, f"Delegate symbol located in generated files: {location_path}" + + def test_character_members_with_ue_types_visible(self, language_server: SolidLanguageServer) -> None: + """Members typed with FVector/TObjectPtr/TWeakObjectPtr/TSoftObjectPtr and a + UPARAM(ref) UFUNCTION are all visible in the symbol tree. + """ + character_h = os.path.join("Source", "TestGame", "GameCharacter.h") + names = document_symbol_names(language_server, character_h) + for expected in ("AGameCharacter", "SpawnOffset", "Abilities", "CurrentTarget", "FallbackLoadout", "Heal"): + assert expected in names, f"Expected '{expected}' in document symbols of GameCharacter.h, got: {names}" + + def test_rename_edit_targets_only_source_files(self, language_server: SolidLanguageServer) -> None: + """A rename WorkspaceEdit for a UFUNCTION touches only hand-written files (edit is not applied).""" + trigger = find_document_symbol(language_server, ABILITY_COMPONENT_H, "TriggerAbility") + sel_start = trigger["selectionRange"]["start"] + edit = language_server.request_rename_symbol_edit(ABILITY_COMPONENT_H, sel_start["line"], sel_start["character"], "ActivateAbility") + assert edit is not None, "clangd should support rename" + + touched: list[str] = [] + for uri in edit.get("changes") or {}: + touched.append(uri) + for doc_change in edit.get("documentChanges") or []: + text_doc = doc_change.get("textDocument") or {} + if text_doc.get("uri"): + touched.append(text_doc["uri"]) + + assert any("AbilityComponent.h" in uri for uri in touched), f"Rename does not edit the declaring header, touched: {touched}" + leaked = [uri for uri in touched if "Intermediate" in uri] + assert not leaked, f"Rename would edit generated files: {leaked}" diff --git a/test/solidlsp/cpp/test_cpp_unreal_unit.py b/test/solidlsp/cpp/test_cpp_unreal_unit.py new file mode 100644 index 0000000..cde68c4 --- /dev/null +++ b/test/solidlsp/cpp/test_cpp_unreal_unit.py @@ -0,0 +1,75 @@ +"""Unreal Engine handling in the clangd and ccls servers, without starting a process. + +No ``cpp`` marker: these build server shells with ``object.__new__`` and call the no-database +and directory-ignore code paths directly, so they run in the default suite. +""" + +import pytest + +from solidlsp.language_servers.ccls_language_server import CCLS +from solidlsp.language_servers.clangd_language_server import ClangdLanguageServer +from solidlsp.language_servers.common import UE_IGNORED_DIRNAMES, is_unreal_engine_project +from solidlsp.ls_exceptions import SolidLSPException + + +def _make_clangd(root) -> ClangdLanguageServer: + server = object.__new__(ClangdLanguageServer) + server.repository_root_path = str(root) + return server + + +def _make_ccls(root) -> CCLS: + server = object.__new__(CCLS) + server.repository_root_path = str(root) + return server + + +def test_is_unreal_engine_project_detection(tmp_path): + assert is_unreal_engine_project(str(tmp_path)) is False + (tmp_path / "MyGame.uproject").write_text("{}") + assert is_unreal_engine_project(str(tmp_path)) is True + + +def test_missing_compile_commands_raises_for_unreal_project(tmp_path): + (tmp_path / "MyGame.uproject").write_text("{}") + server = _make_clangd(tmp_path) + + with pytest.raises(SolidLSPException, match="Unreal Engine"): + server._prepare_compile_commands() + + +def test_missing_compile_commands_returns_none_for_non_unreal_project(tmp_path): + server = _make_clangd(tmp_path) + assert server._prepare_compile_commands() is None + + +def test_empty_compile_commands_raises_for_unreal_project(tmp_path): + (tmp_path / "MyGame.uproject").write_text("{}") + (tmp_path / "compile_commands.json").write_text("[]") + server = _make_clangd(tmp_path) + + with pytest.raises(SolidLSPException, match="Unreal Engine"): + server._prepare_compile_commands() + + +def test_empty_compile_commands_returns_none_for_non_unreal_project(tmp_path): + (tmp_path / "compile_commands.json").write_text("[]") + server = _make_clangd(tmp_path) + assert server._prepare_compile_commands() is None + + +@pytest.mark.parametrize("make_server", [_make_clangd, _make_ccls]) +def test_unreal_dirs_ignored_only_for_unreal_project(make_server, tmp_path): + non_ue = make_server(tmp_path) + for dirname in UE_IGNORED_DIRNAMES: + assert not non_ue.is_ignored_dirname(dirname), f"{dirname} should not be pruned without a .uproject" + + ue_root = tmp_path / "ue" + ue_root.mkdir() + (ue_root / "MyGame.uproject").write_text("{}") + ue = make_server(ue_root) + for dirname in UE_IGNORED_DIRNAMES: + assert ue.is_ignored_dirname(dirname), f"{dirname} should be pruned for a UE project" + + assert non_ue.is_ignored_dirname(".ccls-cache") + assert ue.is_ignored_dirname(".ccls-cache") diff --git a/test/solidlsp/crystal/test_crystal_basic.py b/test/solidlsp/crystal/test_crystal_basic.py new file mode 100644 index 0000000..7af6612 --- /dev/null +++ b/test/solidlsp/crystal/test_crystal_basic.py @@ -0,0 +1,104 @@ +""" +Basic integration tests for the Crystal language server (Crystalline) functionality. + +These tests validate document symbols, go-to-definition, and find-references +using the Crystal test repository. + +Known Crystalline limitations: +- Only the first textDocument/definition request per server session returns results. +- textDocument/references is not functional (documented as partial support upstream). +""" + +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + +pytestmark = [ + pytest.mark.crystal, + pytest.mark.skipif(not language_tests_enabled(Language.CRYSTAL), reason="Crystal tests are disabled (crystalline not available)"), +] + + +class TestCrystalDocumentSymbols: + """Test document symbol retrieval, which works reliably in Crystalline.""" + + @pytest.mark.parametrize("language_server", [Language.CRYSTAL], indirect=True) + def test_ls_is_running(self, language_server: SolidLanguageServer) -> None: + """Test that the language server starts successfully.""" + assert language_server.is_running() + + @pytest.mark.parametrize("language_server", [Language.CRYSTAL], indirect=True) + def test_document_symbols_main(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols are returned for the main file.""" + file_path = os.path.join("src", "main.cr") + doc_symbols = language_server.request_document_symbols(file_path) + all_symbols, root_symbols = doc_symbols.get_all_symbols_and_roots() + + symbol_names = [s.get("name") for s in all_symbols if s.get("name")] + assert "Calculator" in symbol_names, f"Calculator not found in symbols. Found: {symbol_names}" + assert "User" in symbol_names, f"User not found in symbols. Found: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.CRYSTAL], indirect=True) + def test_document_symbols_utils(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols are returned for the utils file.""" + file_path = os.path.join("src", "utils.cr") + doc_symbols = language_server.request_document_symbols(file_path) + all_symbols, root_symbols = doc_symbols.get_all_symbols_and_roots() + + symbol_names = [s.get("name") for s in all_symbols if s.get("name")] + assert "Utils" in symbol_names, f"Utils not found in symbols. Found: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.CRYSTAL], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + """Test that the full symbol tree contains expected symbols.""" + from solidlsp.ls_utils import SymbolUtils + + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "Calculator"), "Calculator not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "User"), "User not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.CRYSTAL], indirect=True) + def test_bare_symbol_names(self, language_server: SolidLanguageServer) -> None: + """Test that symbol names do not contain unexpected formatting characters.""" + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) + + +class TestCrystalDefinition: + """Test go-to-definition. + + Crystalline only supports one definition request per server session, so + only a single test is included. A separate test class with its own + module-scoped ``language_server`` fixture ensures we get a fresh server. + """ + + @pytest.mark.parametrize("language_server", [Language.CRYSTAL], indirect=True) + def test_goto_definition_within_file(self, language_server: SolidLanguageServer) -> None: + """Test goto_definition for a symbol defined within the same file.""" + file_path = os.path.join("src", "main.cr") + + # wait for Crystalline to compile the project + language_server.language_server._wait_for_compilation() + + # Calculator.new on line 35 (0-indexed: 34), col 13 -> Calculator class on line 3 (0-indexed: 2) + definitions = language_server.request_definition(file_path, 34, 13) + assert isinstance(definitions, list), "Definitions should be a list" + assert len(definitions) > 0, "Should find definition for Calculator" + + calculator_def = definitions[0] + assert calculator_def.get("uri", "").endswith("main.cr"), "Definition should be in main.cr" + assert calculator_def["range"]["start"]["line"] == 2, "Calculator class should be defined at line 3 (0-indexed: 2)" diff --git a/test/solidlsp/crystal/test_crystal_diagnostics.py b/test/solidlsp/crystal/test_crystal_diagnostics.py new file mode 100644 index 0000000..b593b14 --- /dev/null +++ b/test/solidlsp/crystal/test_crystal_diagnostics.py @@ -0,0 +1,22 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled +from test.solidlsp.util.diagnostics import assert_file_diagnostics + +pytestmark = [ + pytest.mark.crystal, + pytest.mark.skipif(not language_tests_enabled(Language.CRYSTAL), reason="Crystal tests are disabled (crystalline not available)"), +] + + +class TestCrystalDiagnostics: + @pytest.mark.parametrize("language_server", [Language.CRYSTAL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/diagnostics_sample.cr", + (), + min_count=1, + ) diff --git a/test/solidlsp/csharp/test_csharp_basic.py b/test/solidlsp/csharp/test_csharp_basic.py new file mode 100644 index 0000000..932f73d --- /dev/null +++ b/test/solidlsp/csharp/test_csharp_basic.py @@ -0,0 +1,341 @@ +import os +import tempfile +from pathlib import Path +from typing import cast + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.language_servers.csharp_language_server import ( + breadth_first_file_scan, + find_solution_or_project_file, +) +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from solidlsp.ls_utils import SymbolUtils +from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.csharp +class TestCSharpLanguageServer: + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + """Test finding symbols in the full symbol tree.""" + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "Program"), "Program class not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Calculator"), "Calculator class not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Add"), "Add method not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_get_document_symbols(self, language_server: SolidLanguageServer) -> None: + """Test getting document symbols from a C# file.""" + file_path = os.path.join("Program.cs") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + # Check that we have symbols + assert len(symbols) > 0 + + # Flatten the symbols if they're nested + if isinstance(symbols[0], list): + symbols = symbols[0] + + # Look for expected classes + class_names = [s.get("name") for s in symbols if s.get("kind") == 5] # 5 is class + assert "Program" in class_names + assert "Calculator" in class_names + + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: + """Test finding references using symbol selection range.""" + file_path = os.path.join("Program.cs") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + add_symbol = None + # Handle nested symbol structure + symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols + for sym in symbol_list: + # Symbol names are normalized to base form (e.g., "Add" not "Add(int, int) : int") + if sym.get("name") == "Add": + add_symbol = sym + break + assert add_symbol is not None, "Could not find 'Add' method symbol in Program.cs" + sel_start = add_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1) + assert any("Program.cs" in ref.get("relativePath", "") for ref in refs), ( + "Program.cs should reference Add method (tried all positions in selectionRange)" + ) + + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_nested_namespace_symbols(self, language_server: SolidLanguageServer) -> None: + """Test getting symbols from nested namespace.""" + file_path = os.path.join("Models", "Person.cs") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + # Check that we have symbols + assert len(symbols) > 0 + + # Flatten the symbols if they're nested + if isinstance(symbols[0], list): + symbols = symbols[0] + + # Check that we have the Person class + assert any(s.get("name") == "Person" and s.get("kind") == 5 for s in symbols) + + # Check for properties and methods (names are normalized to base form) + symbol_names = [s.get("name") for s in symbols] + assert "Name" in symbol_names, "Name property not found" + assert "Age" in symbol_names, "Age property not found" + assert "Email" in symbol_names, "Email property not found" + assert "ToString" in symbol_names, "ToString method not found" + assert "IsAdult" in symbol_names, "IsAdult method not found" + + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_find_referencing_symbols_across_files(self, language_server: SolidLanguageServer) -> None: + """Test finding references to Calculator.Subtract method across files.""" + # First, find the Subtract method in Program.cs + file_path = os.path.join("Program.cs") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + # Flatten the symbols if they're nested + symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols + + subtract_symbol = None + for sym in symbol_list: + # Symbol names are normalized to base form (e.g., "Subtract" not "Subtract(int, int) : int") + if sym.get("name") == "Subtract": + subtract_symbol = sym + break + + assert subtract_symbol is not None, "Could not find 'Subtract' method symbol in Program.cs" + + # Get references to the Subtract method + sel_start = subtract_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1) + + # Should find references where the method is called + ref_files = cast(list[str], [ref.get("relativePath", "") for ref in refs]) + print(f"Found references: {refs}") + print(f"Reference files: {ref_files}") + + # Check that we have reference in Models/Person.cs where Calculator.Subtract is called + # Note: New Roslyn version doesn't include the definition itself as a reference (more correct behavior) + assert any(os.path.join("Models", "Person.cs") in ref_file for ref_file in ref_files), ( + "Should find reference in Models/Person.cs where Calculator.Subtract is called" + ) + assert len(refs) > 0, "Should find at least one reference" + + # check for a second time, since the first call may trigger initialization and change the state of the LS + refs_second_call = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1) + assert refs_second_call == refs, "Second call to request_references should return the same results" + + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_hover_includes_type_information(self, language_server: SolidLanguageServer) -> None: + """Test that hover information is available and includes type information.""" + file_path = os.path.join("Models", "Person.cs") + + # Open the file first + language_server.open_file(file_path) + + # Test 1: Hover over the Name property (line 6, column 23 - on "Name") + # Source: public string Name { get; set; } + hover_info = language_server.request_hover(file_path, 6, 23) + + # Verify hover returns content + assert hover_info is not None, "Hover should return information for Name property" + assert isinstance(hover_info, dict), "Hover should be a dict" + assert "contents" in hover_info, "Hover should have contents" + + contents = hover_info["contents"] + assert isinstance(contents, dict), "Hover contents should be a dict" + assert "value" in contents, "Hover contents should have value" + hover_text = contents["value"] + + # Verify the hover contains property signature with type + assert "string" in hover_text, f"Hover should include 'string' type, got: {hover_text}" + assert "Name" in hover_text, f"Hover should include 'Name' property name, got: {hover_text}" + + # Test 2: Hover over the IsAdult method (line 22, column 21 - on "IsAdult") + # Source: public bool IsAdult() + hover_method = language_server.request_hover(file_path, 22, 21) + + # Verify method hover returns content + assert hover_method is not None, "Hover should return information for IsAdult method" + assert isinstance(hover_method, dict), "Hover should be a dict" + assert "contents" in hover_method, "Hover should have contents" + + contents = hover_method["contents"] + assert isinstance(contents, dict), "Hover contents should be a dict" + assert "value" in contents, "Hover contents should have value" + method_hover_text = contents["value"] + + # Verify the hover contains method signature with return type + assert "bool" in method_hover_text, f"Hover should include 'bool' return type, got: {method_hover_text}" + assert "IsAdult" in method_hover_text, f"Hover should include 'IsAdult' method name, got: {method_hover_text}" + + if language_has_verified_implementation_support(Language.CSHARP): + + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.CSHARP) + pos = find_identifier_position(repo_path / "Services" / "IGreeter.cs", "FormatGreeting") + assert pos is not None, "Could not find IGreeter.FormatGreeting in fixture" + + implementations = language_server.request_implementation("Services/IGreeter.cs", *pos) + assert implementations, "Expected at least one implementation of IGreeter.FormatGreeting" + assert any("ConsoleGreeter.cs" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected ConsoleGreeter.FormatGreeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.CSHARP) + pos = find_identifier_position(repo_path / "Services" / "IGreeter.cs", "FormatGreeting") + assert pos is not None, "Could not find IGreeter.FormatGreeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols("Services/IGreeter.cs", *pos) + assert implementing_symbols, "Expected implementing symbols for IGreeter.FormatGreeting" + assert any( + symbol.get("name") == "FormatGreeting" and "ConsoleGreeter.cs" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected ConsoleGreeter.FormatGreeting symbol, got: {implementing_symbols}" + + +@pytest.mark.csharp +class TestCSharpSolutionProjectOpening: + """Test C# language server solution and project opening functionality.""" + + def test_breadth_first_file_scan(self): + """Test that breadth_first_file_scan finds files in breadth-first order.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create test directory structure + (temp_path / "file1.txt").touch() + (temp_path / "subdir1").mkdir() + (temp_path / "subdir1" / "file2.txt").touch() + (temp_path / "subdir2").mkdir() + (temp_path / "subdir2" / "file3.txt").touch() + (temp_path / "subdir1" / "subdir3").mkdir() + (temp_path / "subdir1" / "subdir3" / "file4.txt").touch() + + # Scan files + files = list(breadth_first_file_scan(str(temp_path))) + filenames = [os.path.basename(f) for f in files] + + # Should find all files + assert len(files) == 4 + assert "file1.txt" in filenames + assert "file2.txt" in filenames + assert "file3.txt" in filenames + assert "file4.txt" in filenames + + # file1.txt should be found first (breadth-first) + assert filenames[0] == "file1.txt" + + def test_find_solution_or_project_file_with_solution(self): + """Test that find_solution_or_project_file prefers .sln files.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create both .sln and .csproj files + solution_file = temp_path / "MySolution.sln" + project_file = temp_path / "MyProject.csproj" + solution_file.touch() + project_file.touch() + + result = find_solution_or_project_file(str(temp_path)) + + # Should prefer .sln file + assert result == str(solution_file) + + def test_find_solution_or_project_file_with_project_only(self): + """Test that find_solution_or_project_file falls back to .csproj files.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create only .csproj file + project_file = temp_path / "MyProject.csproj" + project_file.touch() + + result = find_solution_or_project_file(str(temp_path)) + + # Should return .csproj file + assert result == str(project_file) + + def test_find_solution_or_project_file_with_nested_files(self): + """Test that find_solution_or_project_file finds files in subdirectories.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create nested structure + (temp_path / "src").mkdir() + solution_file = temp_path / "src" / "MySolution.sln" + solution_file.touch() + + result = find_solution_or_project_file(str(temp_path)) + + # Should find nested .sln file + assert result == str(solution_file) + + def test_find_solution_or_project_file_returns_none_when_no_files(self): + """Test that find_solution_or_project_file returns None when no .sln or .csproj files exist.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create some other files + (temp_path / "readme.txt").touch() + (temp_path / "other.cs").touch() + + result = find_solution_or_project_file(str(temp_path)) + + # Should return None + assert result is None + + def test_find_solution_or_project_file_prefers_solution_breadth_first(self): + """Test that solution files are preferred even when deeper in the tree.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create .csproj at root and .sln in subdirectory + project_file = temp_path / "MyProject.csproj" + project_file.touch() + + (temp_path / "src").mkdir() + solution_file = temp_path / "src" / "MySolution.sln" + solution_file.touch() + + result = find_solution_or_project_file(str(temp_path)) + + # Should still prefer .sln file even though it's deeper + assert result == str(solution_file) + + def test_solution_and_project_opening_with_real_test_repo(self): + """Test solution and project opening with the actual C# test repository.""" + # Get the C# test repo path + test_repo_path = Path(__file__).parent.parent.parent / "resources" / "repos" / "csharp" / "test_repo" + + if not test_repo_path.exists(): + pytest.skip("C# test repository not found") + + # Test solution/project discovery in the real test repo + result = find_solution_or_project_file(str(test_repo_path)) + + # Should find either .sln or .csproj file + assert result is not None + assert result.endswith((".sln", ".csproj")) + + # Verify the file actually exists + assert os.path.exists(result) + + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s, period_allowed=s["kind"] == SymbolKind.Namespace): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/csharp/test_csharp_diagnostics.py b/test/solidlsp/csharp/test_csharp_diagnostics.py new file mode 100644 index 0000000..de32fd1 --- /dev/null +++ b/test/solidlsp/csharp/test_csharp_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.csharp +class TestCsharpDiagnostics: + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "DiagnosticsSample.cs", + (), + min_count=1, + ) diff --git a/test/solidlsp/csharp/test_csharp_nuget_download.py b/test/solidlsp/csharp/test_csharp_nuget_download.py new file mode 100644 index 0000000..548dfeb --- /dev/null +++ b/test/solidlsp/csharp/test_csharp_nuget_download.py @@ -0,0 +1,160 @@ +"""Tests for C# language server NuGet package download from NuGet.org.""" + +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from solidlsp.language_servers.common import RuntimeDependency +from solidlsp.language_servers.csharp_language_server import ROSLYN_LONGEST_KNOWN_PACKAGE_MEMBER, CSharpLanguageServer +from solidlsp.settings import SolidLSPSettings + + +@pytest.mark.csharp +class TestNuGetOrgDownload: + """Test downloading Roslyn language server packages from NuGet.org.""" + + def test_download_nuget_package_uses_direct_url(self): + """Test that _download_nuget_package uses the URL and checksum from RuntimeDependency directly.""" + with tempfile.TemporaryDirectory() as temp_dir: + # Create a RuntimeDependency with a NuGet.org URL + test_dependency = RuntimeDependency( + id="TestPackage", + description="Test package from NuGet.org", + package_name="roslyn-language-server.linux-x64", + package_version="5.5.0-2.26078.4", + url="https://www.nuget.org/api/v2/package/roslyn-language-server.linux-x64/5.5.0-2.26078.4", + platform_id="linux-x64", + archive_type="nupkg", + binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", + extract_path="content/LanguageServer/linux-x64", + ) + + # Mock the dependency provider + mock_settings = SolidLSPSettings() + custom_settings = SolidLSPSettings.CustomLSSettings({}) + + dependency_provider = CSharpLanguageServer.DependencyProvider( + custom_settings=custom_settings, + ls_resources_dir=temp_dir, + solidlsp_settings=mock_settings, + repository_root_path="/fake/repo", + ) + + captured_calls: list[tuple[str, str, str, str | None, tuple[str, ...] | list[str] | None]] = [] + + def fake_download_and_extract( + url: str, + target_path: str, + archive_type: str, + expected_sha256: str | None = None, + allowed_hosts: tuple[str, ...] | list[str] | None = None, + ) -> None: + captured_calls.append((url, target_path, archive_type, expected_sha256, allowed_hosts)) + Path(target_path).mkdir(parents=True, exist_ok=True) + + with patch( + "solidlsp.language_servers.csharp_language_server.FileUtils.download_and_extract_archive_verified", + side_effect=fake_download_and_extract, + ): + package_dir = dependency_provider._download_nuget_package(test_dependency) + + assert package_dir == Path(temp_dir) / "temp_downloads" / "roslyn-language-server.linux-x64.5.5.0-2.26078.4" + assert captured_calls == [ + ( + test_dependency.url, + str(package_dir), + "zip", + test_dependency.sha256, + test_dependency.allowed_hosts, + ) + ] + called_url = captured_calls[0][0] + assert called_url == test_dependency.url, f"Should use URL from RuntimeDependency: {test_dependency.url}" + assert "nuget.org" in called_url, "Should use NuGet.org URL" + assert "azure" not in called_url.lower(), "Should not use Azure feed" + + def test_runtime_dependencies_use_nuget_org_urls(self): + """Test that _RUNTIME_DEPENDENCIES are configured with NuGet.org URLs.""" + from solidlsp.language_servers.csharp_language_server import _RUNTIME_DEPENDENCIES + + # Check language server dependencies + lang_server_deps = [dep for dep in _RUNTIME_DEPENDENCIES if dep.id == "CSharpLanguageServer"] + + assert len(lang_server_deps) == 6, "Should have 6 language server platform variants" + + for dep in lang_server_deps: + # Verify package name uses roslyn-language-server + assert dep.package_name is not None, f"Package name should be set for {dep.platform_id}" + assert dep.package_name.startswith("roslyn-language-server."), ( + f"Package name should start with 'roslyn-language-server.' but got: {dep.package_name}" + ) + + # Verify version is the newer NuGet.org version + assert dep.package_version == "5.5.0-2.26078.4", f"Should use NuGet.org version 5.5.0-2.26078.4, got: {dep.package_version}" + + # Verify URL points to NuGet.org + assert dep.url is not None, f"URL should be set for {dep.platform_id}" + assert "nuget.org" in dep.url, f"URL should point to nuget.org, got: {dep.url}" + assert "azure" not in dep.url.lower(), f"URL should not point to Azure feed, got: {dep.url}" + + def test_download_method_does_not_call_azure_feed(self): + """Test that the new download method does not attempt to access Azure feed.""" + with tempfile.TemporaryDirectory() as temp_dir: + test_dependency = RuntimeDependency( + id="TestPackage", + description="Test package", + package_name="roslyn-language-server.linux-x64", + package_version="5.5.0-2.26078.4", + url="https://www.nuget.org/api/v2/package/roslyn-language-server.linux-x64/5.5.0-2.26078.4", + platform_id="linux-x64", + archive_type="nupkg", + binary_name="test.dll", + ) + + mock_settings = SolidLSPSettings() + custom_settings = SolidLSPSettings.CustomLSSettings({}) + + dependency_provider = CSharpLanguageServer.DependencyProvider( + custom_settings=custom_settings, + ls_resources_dir=temp_dir, + solidlsp_settings=mock_settings, + repository_root_path="/fake/repo", + ) + + # Mock urllib.request.urlopen to track if Azure feed is accessed + with patch( + "solidlsp.language_servers.csharp_language_server.FileUtils.download_and_extract_archive_verified", + ): + dependency_provider._download_nuget_package(test_dependency) + + def test_download_nuget_package_uses_short_extraction_path_for_deep_windows_cache(self, tmp_path: Path): + provider = object.__new__(CSharpLanguageServer.DependencyProvider) + provider._ls_resources_dir = str(tmp_path.joinpath(*(["nested-cache-root"] * 8))) + + test_dependency = RuntimeDependency( + id="TestPackage", + package_name="roslyn-language-server.win-x64", + package_version="5.5.0-2.26078.4", + url="https://www.nuget.org/api/v2/package/roslyn-language-server.win-x64/5.5.0-2.26078.4", + extract_path="tools/net10.0/win-x64", + ) + + def fake_download_and_extract( + _url: str, + target_path: str, + *_args: object, + **_kwargs: object, + ) -> None: + deepest_package_member = Path(target_path) / f"tools/net10.0/win-x64/{ROSLYN_LONGEST_KNOWN_PACKAGE_MEMBER}" + assert len(str(deepest_package_member)) < 260 + + with ( + patch("solidlsp.language_servers.csharp_language_server.platform.system", return_value="Windows"), + patch( + "solidlsp.language_servers.csharp_language_server.FileUtils.download_and_extract_archive_verified", + side_effect=fake_download_and_extract, + ), + ): + provider._download_nuget_package(test_dependency) diff --git a/test/solidlsp/cue/__init__.py b/test/solidlsp/cue/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/solidlsp/cue/test_cue_basic.py b/test/solidlsp/cue/test_cue_basic.py new file mode 100644 index 0000000..f2a580b --- /dev/null +++ b/test/solidlsp/cue/test_cue_basic.py @@ -0,0 +1,145 @@ +"""Basic integration tests for the CUE language server (``cue lsp``).""" + +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.cue +class TestCueLanguageServer: + """Verifies that ``cue lsp`` drives the symbol and reference APIs Serena depends on. + + The test repo (``test/resources/repos/cue/test_repo``) defines three CUE files that + share package ``testrepo`` and cross-reference each other's definitions: + + - ``schema.cue`` defines ``#Person``, ``#Greeting``, ``defaultLocale``. + - ``lib.cue`` defines ``#BuildGreeting`` (which uses ``#Person`` and ``#Greeting``) and + ``locales``. + - ``main.cue`` defines ``alice: #Person & {...}``, ``greetingForAlice: (#BuildGreeting & {...})``, + and ``locale: defaultLocale``. + + Line/character positions below are 0-indexed (LSP convention) and were captured from + ``cue lsp v0.16.1`` responses against this repository. + """ + + @pytest.mark.parametrize("language_server", [Language.CUE], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.CUE], indirect=True) + def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """The server starts and reports the expected repository root.""" + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.CUE], indirect=True) + def test_document_symbols_schema(self, language_server: SolidLanguageServer) -> None: + """``schema.cue`` exposes its top-level definitions with the expected hierarchy.""" + # request hierarchical document symbols for schema.cue + all_symbols, root_symbols = language_server.request_document_symbols("schema.cue").get_all_symbols_and_roots() + + # schema.cue's three top-level definitions must appear as roots + root_names = [s.get("name") for s in root_symbols] + for expected in ("#Person", "#Greeting", "defaultLocale"): + assert expected in root_names, f"{expected} missing from schema.cue roots: {root_names}" + + # #Person's field children must be nested under it (hierarchical response, not flat) + person = next(s for s in root_symbols if s.get("name") == "#Person") + person_children = [c.get("name") for c in person.get("children", [])] + for expected_field in ("name", "age", "email"): + assert expected_field in person_children, f"{expected_field} missing under #Person: {person_children}" + + # fields must not also appear at root level (that would be the flat fallback) + assert "name" not in root_names, f"name should be a child of #Person, not a root. Roots: {root_names}" + + @pytest.mark.parametrize("language_server", [Language.CUE], indirect=True) + def test_full_symbol_tree_contains_cross_file_names(self, language_server: SolidLanguageServer) -> None: + """The repository-wide symbol tree contains definitions from all three CUE files.""" + symbols = language_server.request_full_symbol_tree() + + for expected in ( + "#Person", # schema.cue + "#Greeting", # schema.cue + "defaultLocale", # schema.cue + "#BuildGreeting", # lib.cue + "locales", # lib.cue + "alice", # main.cue + "greetingForAlice", # main.cue + "locale", # main.cue + ): + assert SymbolUtils.symbol_tree_contains_name(symbols, expected), f"{expected} missing from full symbol tree" + + @pytest.mark.parametrize("language_server", [Language.CUE], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.CUE], indirect=True) + def test_find_definition_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """``#Person`` used in ``main.cue`` resolves to its definition in ``schema.cue``.""" + # main.cue line 3 (0-indexed): "alice: #Person & {" + # ^ char 7 — cursor on '#Person' + definitions = language_server.request_definition(str(repo_path / "main.cue"), 3, 8) + + assert definitions, f"Expected a definition for #Person, got {definitions=}" + # definition must live in schema.cue at line 3 (0-indexed), char 0 + target = definitions[0] + assert target["uri"].endswith("schema.cue") + assert target["range"]["start"] == {"line": 3, "character": 0} + + @pytest.mark.parametrize("language_server", [Language.CUE], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.CUE], indirect=True) + def test_find_references_across_files_person(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """``#Person`` has references in ``lib.cue`` and ``main.cue`` beyond its declaration.""" + schema_path = str(repo_path / "schema.cue") + # schema.cue line 3 (0-indexed): "#Person: {" — cursor on the identifier + references = language_server.request_references(schema_path, 3, 1) + + assert references, f"Expected references for #Person, got {references=}" + + # collapse to (filename, start_line) tuples for set-based assertions + ref_pairs = {(ref["uri"].split("/")[-1], ref["range"]["start"]["line"]) for ref in references} + + # must include use sites in lib.cue (line 5, in `for_: #Person`) and + # main.cue (line 3, in `alice: #Person & {`) + assert ("lib.cue", 5) in ref_pairs, f"Expected lib.cue:5 reference, got {sorted(ref_pairs)}" + assert ("main.cue", 3) in ref_pairs, f"Expected main.cue:3 reference, got {sorted(ref_pairs)}" + + @pytest.mark.parametrize("language_server", [Language.CUE], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.CUE], indirect=True) + def test_find_references_within_file_build_greeting(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """``#BuildGreeting`` (defined in lib.cue) is referenced from main.cue.""" + lib_path = str(repo_path / "lib.cue") + # lib.cue line 4 (0-indexed): "#BuildGreeting: {" + references = language_server.request_references(lib_path, 4, 1) + + assert references, f"Expected references for #BuildGreeting, got {references=}" + + ref_pairs = {(ref["uri"].split("/")[-1], ref["range"]["start"]["line"]) for ref in references} + # use site in main.cue is line 10: "greetingForAlice: (#BuildGreeting & {for_: alice}).result" + assert ("main.cue", 10) in ref_pairs, f"Expected main.cue:10 reference, got {sorted(ref_pairs)}" + + @pytest.mark.parametrize("language_server", [Language.CUE], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.CUE], indirect=True) + def test_find_references_default_locale(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """``defaultLocale`` (schema.cue) is used in main.cue.""" + schema_path = str(repo_path / "schema.cue") + # schema.cue line 16 (0-indexed): 'defaultLocale: "en-US"' + references = language_server.request_references(schema_path, 16, 1) + + assert references, f"Expected references for defaultLocale, got {references=}" + ref_pairs = {(ref["uri"].split("/")[-1], ref["range"]["start"]["line"]) for ref in references} + # use site in main.cue is line 13: "locale: defaultLocale" + assert ("main.cue", 13) in ref_pairs, f"Expected main.cue:13 reference, got {sorted(ref_pairs)}" + + @pytest.mark.parametrize("language_server", [Language.CUE], indirect=True) + def test_bare_symbol_names(self, language_server: SolidLanguageServer) -> None: + """CUE symbols must have bare names (no whitespace/bracket/paren/comma/colon pollution).""" + # `.` is allowed because the synthetic directory symbol for `cue.mod/` contains a literal + # period — CUE modules are always anchored under a directory named `cue.mod`. + # `#` in names like `#Person` is part of the identifier in CUE's grammar and is not + # in the forbidden-char set. + malformed_symbols = [s for s in request_all_symbols(language_server) if has_malformed_name(s, period_allowed=True)] + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/dart/__init__.py b/test/solidlsp/dart/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/solidlsp/dart/test_dart_basic.py b/test/solidlsp/dart/test_dart_basic.py new file mode 100644 index 0000000..7dab1fe --- /dev/null +++ b/test/solidlsp/dart/test_dart_basic.py @@ -0,0 +1,426 @@ +import os +from pathlib import Path + +import pytest + +from serena.util.text_utils import find_text_coordinates +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from solidlsp.ls_utils import SymbolUtils +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.dart +class TestDartLanguageServer: + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.DART], indirect=True) + def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that the language server starts and stops successfully.""" + # The fixture already handles start and stop + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.DART], indirect=True) + def test_find_definition_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test finding definition of a method within the same file.""" + # In lib/main.dart: + # Line 105: final result1 = calc.add(5, 3); // Reference to add method + # Line 12: int add(int a, int b) { // Definition of add method + # Find definition of 'add' method from its usage + main_dart_path = str(repo_path / "lib" / "main.dart") + + # Position: calc.add(5, 3) - cursor on 'add' + # Line 105 (1-indexed) = line 104 (0-indexed), char position around 22 + definition_location_list = language_server.request_definition(main_dart_path, 104, 22) + + assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" + assert len(definition_location_list) >= 1 + definition_location = definition_location_list[0] + assert definition_location["uri"].endswith("main.dart") + # Definition of add method should be around line 11 (0-indexed) + # But language server may return different positions + assert definition_location["range"]["start"]["line"] >= 0 + + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.DART], indirect=True) + def test_find_definition_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test finding definition across different files.""" + # Test finding definition of MathHelper class which is in helper.dart + # In lib/main.dart line 50: MathHelper.power(step1, 2) + main_dart_path = str(repo_path / "lib" / "main.dart") + + # Position: MathHelper.power(step1, 2) - cursor on 'MathHelper' + # Line 50 (1-indexed) = line 49 (0-indexed), char position around 18 + definition_location_list = language_server.request_definition(main_dart_path, 49, 18) + + # Skip the test if language server doesn't find cross-file references + # This is acceptable for a basic test - the important thing is that LS is working + if not definition_location_list: + pytest.skip("Language server doesn't support cross-file definition lookup for this case") + + assert len(definition_location_list) >= 1 + definition_location = definition_location_list[0] + assert definition_location["uri"].endswith("helper.dart") + assert definition_location["range"]["start"]["line"] >= 0 + + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.DART], indirect=True) + def test_find_definition_class_method(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test finding definition of a class method.""" + # In lib/main.dart: + # Line 50: final step2 = MathHelper.power(step1, 2); // Reference to MathHelper.power method + # In lib/helper.dart: + # Line 14: static double power(double base, int exponent) { // Definition of power method + main_dart_path = str(repo_path / "lib" / "main.dart") + + # Position: MathHelper.power(step1, 2) - cursor on 'power' + # Line 50 (1-indexed) = line 49 (0-indexed), char position around 30 + definition_location_list = language_server.request_definition(main_dart_path, 49, 30) + + assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" + assert len(definition_location_list) >= 1 + definition_location = definition_location_list[0] + assert definition_location["uri"].endswith("helper.dart") + # Definition of power method should be around line 13 (0-indexed) + assert 12 <= definition_location["range"]["start"]["line"] <= 16 + + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.DART], indirect=True) + def test_find_references_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test finding references to a method within the same file.""" + main_dart_path = str(repo_path / "lib" / "main.dart") + + # Find references to the 'add' method from its definition + # Line 12: int add(int a, int b) { // Definition of add method + # Line 105: final result1 = calc.add(5, 3); // Usage of add method + references = language_server.request_references(main_dart_path, 11, 6) # cursor on 'add' in definition + + assert references, f"Expected non-empty references but got {references=}" + # Should find at least the usage of add method + assert len(references) >= 1 + + # Check that we have a reference in main.dart + main_dart_references = [ref for ref in references if ref["uri"].endswith("main.dart")] + assert len(main_dart_references) >= 1 + + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.DART], indirect=True) + def test_find_references_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test finding references across different files.""" + helper_dart_path = str(repo_path / "lib" / "helper.dart") + + # Find references to the 'subtract' function from its definition in helper.dart + # Definition is in helper.dart, usage is in main.dart + references = language_server.request_references(helper_dart_path, 4, 4) # cursor on 'subtract' in definition + + assert references, f"Expected non-empty references for subtract function but got {references=}" + + # Should find references in main.dart + main_dart_references = [ref for ref in references if ref["uri"].endswith("main.dart")] + assert len(main_dart_references) >= 1 + + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.DART], indirect=True) + def test_find_definition_constructor(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test finding definition of a constructor call.""" + main_dart_path = str(repo_path / "lib" / "main.dart") + + # In lib/main.dart: + # Line 104: final calc = Calculator(); // Reference to Calculator constructor + # Line 4: class Calculator { // Definition of Calculator class + definition_location_list = language_server.request_definition(main_dart_path, 103, 18) # cursor on 'Calculator' + + assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" + assert len(definition_location_list) >= 1 + definition_location = definition_location_list[0] + assert definition_location["uri"].endswith("main.dart") + # Definition of Calculator class should be around line 3 (0-indexed) + assert 3 <= definition_location["range"]["start"]["line"] <= 7 + + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.DART], indirect=True) + def test_find_definition_import(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test finding definition through imports.""" + models_dart_path = str(repo_path / "lib" / "models.dart") + + # Test finding definition of User class name where it's used + # In lib/models.dart line 27 (constructor): User(this.id, this.name, this.email, this._age); + definition_location_list = language_server.request_definition(models_dart_path, 26, 2) # cursor on 'User' in constructor + + # Skip if language server doesn't find definition in this case + if not definition_location_list: + pytest.skip("Language server doesn't support definition lookup for this case") + + assert len(definition_location_list) >= 1 + definition_location = definition_location_list[0] + # Language server might return SDK files instead of local files + # This is acceptable behavior - the important thing is that it found a definition + assert "dart" in definition_location["uri"].lower() + + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + """Test finding symbols in the full symbol tree.""" + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "Calculator"), "Calculator class not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add method not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "subtract"), "subtract function not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "MathHelper"), "MathHelper class not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "User"), "User class not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: + """Test finding references using symbol selection range.""" + file_path = os.path.join("lib", "main.dart") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + # Handle nested symbol structure - symbols can be nested in lists + symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols + + # Find the 'add' method symbol in Calculator class + add_symbol = None + for sym in symbol_list: + if sym.get("name") == "add": + add_symbol = sym + break + # Check for nested symbols (methods inside classes) + if "children" in sym and sym.get("name") == "Calculator": + for child in sym["children"]: + if child.get("name") == "add": + add_symbol = child + break + if add_symbol: + break + + assert add_symbol is not None, "Could not find 'add' method symbol in main.dart" + sel_start = add_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + + # Check that we found references - at least one should be in main.dart + assert any("main.dart" in ref.get("relativePath", "") or "main.dart" in ref.get("uri", "") for ref in refs), ( + "main.dart should reference add method (tried all positions in selectionRange)" + ) + + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + def test_request_containing_symbol_method(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol for a method.""" + file_path = os.path.join("lib", "main.dart") + # Line 14 is inside the add method body (around 'final result = a + b;') + containing_symbol = language_server.request_containing_symbol(file_path, 13, 10, include_body=True) + + # Verify that we found the containing symbol + if containing_symbol is not None: + assert containing_symbol["name"] == "add" + assert containing_symbol["kind"] == SymbolKind.Method + if "body" in containing_symbol: + body = containing_symbol["body"].get_text() + assert "add" in body or "final result" in body + + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + def test_request_containing_symbol_class(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol for a class.""" + file_path = os.path.join("lib", "main.dart") + # Line 4 is the Calculator class definition line + containing_symbol = language_server.request_containing_symbol(file_path, 4, 6) + + # Verify that we found the containing symbol + if containing_symbol is not None: + assert containing_symbol["name"] == "Calculator" + assert containing_symbol["kind"] == SymbolKind.Class + + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + def test_request_containing_symbol_nested(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol with nested scopes.""" + file_path = os.path.join("lib", "main.dart") + # Line 14 is inside the add method inside Calculator class + containing_symbol = language_server.request_containing_symbol(file_path, 13, 20) + + # Verify that we found the innermost containing symbol (the method) + if containing_symbol is not None: + assert containing_symbol["name"] == "add" + assert containing_symbol["kind"] == SymbolKind.Method + + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + def test_request_defining_symbol_variable(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for a variable usage.""" + file_path = os.path.join("lib", "main.dart") + + # Find coordinates of 'final result = a + b;' - test position on 'result' + with language_server.open_file(file_path, open_in_ls=False) as f: + pos = find_text_coordinates(f.contents, r"final (result) = a \+ b;") + + defining_symbol = language_server.request_defining_symbol(file_path, pos.line, pos.col) + + # The defining symbol might be the variable itself or the containing method + # This is acceptable behavior - different language servers handle this differently + if defining_symbol is not None: + assert defining_symbol.get("name") in ["result", "add"] + if defining_symbol.get("name") == "add": + assert defining_symbol.get("kind") == SymbolKind.Method.value + + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + def test_request_defining_symbol_imported_class(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for an imported class/function.""" + file_path = os.path.join("lib", "main.dart") + # Line 20 references 'subtract' which was imported from helper.dart + defining_symbol = language_server.request_defining_symbol(file_path, 19, 18) + + # Verify that we found the defining symbol - this should be the subtract function from helper.dart + if defining_symbol is not None: + assert defining_symbol.get("name") == "subtract" + # Could be Function or Method depending on language server interpretation + assert defining_symbol.get("kind") in [SymbolKind.Function.value, SymbolKind.Method.value] + + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + def test_request_defining_symbol_class_method(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for a static class method.""" + file_path = os.path.join("lib", "main.dart") + # Line 50 references MathHelper.power - test position on 'power' + defining_symbol = language_server.request_defining_symbol(file_path, 49, 30) + + # Verify that we found the defining symbol - should be the power method + if defining_symbol is not None: + assert defining_symbol.get("name") == "power" + assert defining_symbol.get("kind") == SymbolKind.Method.value + + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + def test_request_document_symbols(self, language_server: SolidLanguageServer) -> None: + """Test getting document symbols from a Dart file.""" + file_path = os.path.join("lib", "main.dart") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + # Check that we have symbols + assert len(symbols) > 0 + + # Flatten the symbols if they're nested + symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols + + # Look for expected classes and methods + symbol_names = [s.get("name") for s in symbol_list] + assert "Calculator" in symbol_names + + # Check for nested symbols (methods inside classes) - optional + calculator_symbol = next((s for s in symbol_list if s.get("name") == "Calculator"), None) + if calculator_symbol and "children" in calculator_symbol and calculator_symbol["children"]: + method_names = [child.get("name") for child in calculator_symbol["children"]] + # If children are populated, we should find the add method + assert "add" in method_names + else: + # Some language servers may not populate children in document symbols + # This is acceptable behavior - the important thing is we found the class + pass + + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + def test_request_referencing_symbols_comprehensive(self, language_server: SolidLanguageServer) -> None: + """Test comprehensive referencing symbols functionality.""" + file_path = os.path.join("lib", "main.dart") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + # Handle nested symbol structure + symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols + + # Find Calculator class and test its references + calculator_symbol = None + for sym in symbol_list: + if sym.get("name") == "Calculator": + calculator_symbol = sym + break + + if calculator_symbol and "selectionRange" in calculator_symbol: + sel_start = calculator_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + + # Should find references to Calculator (constructor calls, etc.) + if refs: + # Verify the structure of referencing symbols + for ref in refs: + assert "uri" in ref or "relativePath" in ref + if "range" in ref: + assert "start" in ref["range"] + assert "end" in ref["range"] + + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + def test_cross_file_symbol_resolution(self, language_server: SolidLanguageServer) -> None: + """Test symbol resolution across multiple files.""" + helper_file_path = os.path.join("lib", "helper.dart") + + # Test finding references to subtract function from helper.dart in main.dart + helper_symbols = language_server.request_document_symbols(helper_file_path).get_all_symbols_and_roots() + symbol_list = helper_symbols[0] if helper_symbols and isinstance(helper_symbols[0], list) else helper_symbols + + subtract_symbol = next((s for s in symbol_list if s.get("name") == "subtract"), None) + + if subtract_symbol and "selectionRange" in subtract_symbol: + sel_start = subtract_symbol["selectionRange"]["start"] + refs = language_server.request_references(helper_file_path, sel_start["line"], sel_start["character"]) + + # Should find references in main.dart + main_dart_refs = [ref for ref in refs if "main.dart" in ref.get("uri", "") or "main.dart" in ref.get("relativePath", "")] + # Note: This may not always work depending on language server capabilities + # So we don't assert - just verify the structure if we get results + if main_dart_refs: + for ref in main_dart_refs: + assert "range" in ref or "location" in ref + + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) + + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + def test_symbol_body_contains_full_method(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols return the full method body range, not just the identifier. + + Regression test: when hierarchicalDocumentSymbolSupport was not declared in client + capabilities, the Dart LSP returned SymbolInformation[] (flat format) where + location.range only covered the identifier name (single line), causing find_symbol + with include_body=True to return just the symbol name instead of its implementation. + """ + file_path = os.path.join("lib", "main.dart") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols + + # Find the 'add' method — defined across multiple lines in main.dart: + # int add(int a, int b) { + # final result = a + b; + # _history.add('$a + $b = $result'); + # return result; + # } + add_symbol = None + for sym in symbol_list: + if sym.get("name") == "add": + add_symbol = sym + break + if sym.get("name") == "Calculator" and "children" in sym: + for child in sym["children"]: + if child.get("name") == "add": + add_symbol = child + break + if add_symbol: + break + + assert add_symbol is not None, "Could not find 'add' method symbol in main.dart" + + # The body range must span multiple lines (not just the identifier line). + # With hierarchicalDocumentSymbolSupport declared, the Dart LSP returns + # DocumentSymbol[] where range covers the full method body. + body_start = add_symbol["location"]["range"]["start"]["line"] + body_end = add_symbol["location"]["range"]["end"]["line"] + assert body_end > body_start, ( + f"Expected multi-line body range for 'add' method, got start={body_start}, end={body_end}. " + f"This likely means hierarchicalDocumentSymbolSupport is not declared in client capabilities." + ) + + # The body text must contain the method implementation, not just the name. + if add_symbol.get("body"): + body_text = add_symbol["body"].get_text() + assert "return result" in body_text, f"Expected method body to contain implementation, got: {body_text!r}" diff --git a/test/solidlsp/dart/test_dart_diagnostics.py b/test/solidlsp/dart/test_dart_diagnostics.py new file mode 100644 index 0000000..ddc5e39 --- /dev/null +++ b/test/solidlsp/dart/test_dart_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.dart +class TestDartDiagnostics: + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "lib/diagnostics_sample.dart", + (), + min_count=1, + ) diff --git a/test/solidlsp/elixir/__init__.py b/test/solidlsp/elixir/__init__.py new file mode 100644 index 0000000..f8532a0 --- /dev/null +++ b/test/solidlsp/elixir/__init__.py @@ -0,0 +1,21 @@ +def _test_expert_available() -> str: + """Test if Expert is available and return error reason if not.""" + # Try to import and check Elixir availability + try: + from solidlsp.language_servers.elixir_tools.elixir_tools import ElixirTools + + # Check if Elixir is installed + elixir_version = ElixirTools._get_elixir_version() + if not elixir_version: + return "Elixir is not installed or not in PATH" + + return "" # No error, Expert should be available + + except ImportError as e: + return f"Failed to import ElixirTools: {e}" + except Exception as e: + return f"Error checking Expert availability: {e}" + + +EXPERT_UNAVAILABLE_REASON = _test_expert_available() +EXPERT_UNAVAILABLE = bool(EXPERT_UNAVAILABLE_REASON) diff --git a/test/solidlsp/elixir/conftest.py b/test/solidlsp/elixir/conftest.py new file mode 100644 index 0000000..3452376 --- /dev/null +++ b/test/solidlsp/elixir/conftest.py @@ -0,0 +1,174 @@ +""" +Elixir-specific test configuration and fixtures. +""" + +import os +import subprocess +import time +from pathlib import Path + +import pytest + + +def ensure_elixir_test_repo_compiled(repo_path: str) -> None: + """Ensure the Elixir test repository dependencies are installed and project is compiled. + + Next LS requires the project to be fully compiled and indexed before providing + complete references and symbol resolution. This function: + 1. Installs dependencies via 'mix deps.get' + 2. Compiles the project via 'mix compile' + + This is essential in CI environments where dependencies aren't pre-installed. + + Args: + repo_path: Path to the Elixir project root directory + + """ + # Check if this looks like an Elixir project + mix_file = os.path.join(repo_path, "mix.exs") + if not os.path.exists(mix_file): + return + + # Check if already compiled (optimization for repeated runs) + build_path = os.path.join(repo_path, "_build") + deps_path = os.path.join(repo_path, "deps") + + if os.path.exists(build_path) and os.path.exists(deps_path): + print(f"Elixir test repository already compiled in {repo_path}") + return + + try: + print("Installing dependencies and compiling Elixir test repository for optimal Next LS performance...") + + # First, install dependencies with increased timeout for CI + print("=" * 60) + print("Step 1/2: Installing Elixir dependencies...") + print("=" * 60) + start_time = time.time() + + deps_result = subprocess.run( + ["mix", "deps.get"], + cwd=repo_path, + capture_output=True, + text=True, + timeout=180, + check=False, # 3 minutes for dependency installation (CI can be slow) + ) + + deps_duration = time.time() - start_time + print(f"Dependencies installation completed in {deps_duration:.2f} seconds") + + # Always log the output for transparency + if deps_result.stdout.strip(): + print("Dependencies stdout:") + print("-" * 40) + print(deps_result.stdout) + print("-" * 40) + + if deps_result.stderr.strip(): + print("Dependencies stderr:") + print("-" * 40) + print(deps_result.stderr) + print("-" * 40) + + if deps_result.returncode != 0: + print(f"⚠️ Warning: Dependencies installation failed with exit code {deps_result.returncode}") + # Continue anyway - some projects might not have dependencies + else: + print("✓ Dependencies installed successfully") + + # Then compile the project with increased timeout for CI + print("=" * 60) + print("Step 2/2: Compiling Elixir project...") + print("=" * 60) + start_time = time.time() + + compile_result = subprocess.run( + ["mix", "compile"], + cwd=repo_path, + capture_output=True, + text=True, + timeout=300, + check=False, # 5 minutes for compilation (Credo compilation can be slow in CI) + ) + + compile_duration = time.time() - start_time + print(f"Compilation completed in {compile_duration:.2f} seconds") + + # Always log the output for transparency + if compile_result.stdout.strip(): + print("Compilation stdout:") + print("-" * 40) + print(compile_result.stdout) + print("-" * 40) + + if compile_result.stderr.strip(): + print("Compilation stderr:") + print("-" * 40) + print(compile_result.stderr) + print("-" * 40) + + if compile_result.returncode == 0: + print(f"✓ Elixir test repository compiled successfully in {repo_path}") + else: + print(f"⚠️ Warning: Compilation completed with exit code {compile_result.returncode}") + # Still continue - warnings are often non-fatal + + print("=" * 60) + print(f"Total setup time: {time.time() - (start_time - compile_duration - deps_duration):.2f} seconds") + print("=" * 60) + + except subprocess.TimeoutExpired as e: + print("=" * 60) + print(f"❌ TIMEOUT: Elixir setup timed out after {e.timeout} seconds") + print(f"Command: {' '.join(e.cmd)}") + print("This may indicate slow CI environment - Next LS may still work but with reduced functionality") + + # Try to get partial output if available + if hasattr(e, "stdout") and e.stdout: + print("Partial stdout before timeout:") + print("-" * 40) + print(e.stdout) + print("-" * 40) + if hasattr(e, "stderr") and e.stderr: + print("Partial stderr before timeout:") + print("-" * 40) + print(e.stderr) + print("-" * 40) + print("=" * 60) + + except FileNotFoundError: + print("❌ ERROR: 'mix' command not found - Elixir test repository may not be compiled") + print("Please ensure Elixir is installed and available in PATH") + except Exception as e: + print(f"❌ ERROR: Failed to prepare Elixir test repository: {e}") + + +@pytest.fixture(scope="session", autouse=True) +def setup_elixir_test_environment(): + """Automatically prepare Elixir test environment for all Elixir tests. + + This fixture runs once per test session and automatically: + 1. Installs dependencies via 'mix deps.get' + 2. Compiles the Elixir test repository via 'mix compile' + + It uses autouse=True so it runs automatically without needing to be explicitly + requested by tests. This ensures Next LS has a fully prepared project to work with. + + Uses generous timeouts (3-5 minutes) to accommodate slow CI environments. + All output is logged for transparency and debugging. + """ + # Get the test repo path relative to this conftest.py file + test_repo_path = Path(__file__).parent.parent.parent / "resources" / "repos" / "elixir" / "test_repo" + ensure_elixir_test_repo_compiled(str(test_repo_path)) + return str(test_repo_path) + + +@pytest.fixture(scope="session") +def elixir_test_repo_path(setup_elixir_test_environment): + """Get the path to the prepared Elixir test repository. + + This fixture depends on setup_elixir_test_environment to ensure dependencies + are installed and compilation has completed before returning the path. + """ + return setup_elixir_test_environment diff --git a/test/solidlsp/elixir/test_elixir_basic.py b/test/solidlsp/elixir/test_elixir_basic.py new file mode 100644 index 0000000..ac32edd --- /dev/null +++ b/test/solidlsp/elixir/test_elixir_basic.py @@ -0,0 +1,149 @@ +""" +Basic integration tests for the Elixir language server functionality. + +These tests validate the functionality of the language server APIs +like request_references using the test repository. +""" + +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from test.conftest import language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics + +# These marks will be applied to all tests in this module +pytestmark = [pytest.mark.elixir, pytest.mark.skipif(not language_tests_enabled(Language.ELIXIR), reason="Elixir tests are disabled")] + + +class TestElixirBasic: + """Basic Elixir language server functionality tests.""" + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_references_function_definition(self, language_server: SolidLanguageServer): + """Test finding references to a function definition.""" + file_path = os.path.join("lib", "models.ex") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + # Find the User module's 'new' function + user_new_symbol = None + for symbol in symbols[0]: # Top level symbols + if symbol.get("name") == "User" and symbol.get("kind") == 2: # Module + for child in symbol.get("children", []): + if child.get("name", "").startswith("def new(") and child.get("kind") == 12: # Function + user_new_symbol = child + break + break + + if not user_new_symbol or "selectionRange" not in user_new_symbol: + pytest.skip("User.new function or its selectionRange not found") + + sel_start = user_new_symbol["selectionRange"]["start"] + references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + + assert references is not None + assert len(references) > 0 + + # Should find at least one reference (the definition itself) + found_definition = any(ref["uri"].endswith("models.ex") for ref in references) + assert found_definition, "Should find the function definition" + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_references_create_user_function(self, language_server: SolidLanguageServer): + """Test finding references to create_user function.""" + file_path = os.path.join("lib", "services.ex") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + # Find the UserService module's 'create_user' function + create_user_symbol = None + for symbol in symbols[0]: # Top level symbols + if symbol.get("name") == "UserService" and symbol.get("kind") == 2: # Module + for child in symbol.get("children", []): + if child.get("name", "").startswith("def create_user(") and child.get("kind") == 12: # Function + create_user_symbol = child + break + break + + if not create_user_symbol or "selectionRange" not in create_user_symbol: + pytest.skip("UserService.create_user function or its selectionRange not found") + + sel_start = create_user_symbol["selectionRange"]["start"] + references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + + assert references is not None + assert len(references) > 0 + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_referencing_symbols_function(self, language_server: SolidLanguageServer): + """Test finding symbols that reference a specific function.""" + file_path = os.path.join("lib", "models.ex") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + # Find the User module's 'new' function + user_new_symbol = None + for symbol in symbols[0]: # Top level symbols + if symbol.get("name") == "User" and symbol.get("kind") == 2: # Module + for child in symbol.get("children", []): + if child.get("name", "").startswith("def new(") and child.get("kind") == 12: # Function + user_new_symbol = child + break + break + + if not user_new_symbol or "selectionRange" not in user_new_symbol: + pytest.skip("User.new function or its selectionRange not found") + + sel_start = user_new_symbol["selectionRange"]["start"] + referencing_symbols = language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) + + assert referencing_symbols is not None + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_timeout_enumeration_bug(self, language_server: SolidLanguageServer): + """Test that enumeration doesn't timeout (regression test).""" + # This should complete without timing out + symbols = language_server.request_document_symbols("lib/models.ex").get_all_symbols_and_roots() + assert symbols is not None + + # Test multiple symbol requests in succession + for _ in range(3): + symbols = language_server.request_document_symbols("lib/services.ex").get_all_symbols_and_roots() + assert symbols is not None + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if s["kind"] in {SymbolKind.Module, SymbolKind.Namespace, SymbolKind.Struct}: + continue + + allow_test_style_name = s["name"].startswith(('test "', 'describe "')) + allow_struct_name = s["name"].startswith("%") + allow_type_name = s["name"].startswith("@type ") + if has_malformed_name( + s, + whitespace_allowed=allow_test_style_name or allow_type_name, + period_allowed=allow_struct_name, + colon_allowed=allow_test_style_name, + brace_allowed=allow_test_style_name or allow_struct_name, + comma_allowed=allow_test_style_name, + ): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "lib/diagnostics_sample.ex", + (), + min_count=1, + ) diff --git a/test/solidlsp/elixir/test_elixir_ignored_dirs.py b/test/solidlsp/elixir/test_elixir_ignored_dirs.py new file mode 100644 index 0000000..75f4870 --- /dev/null +++ b/test/solidlsp/elixir/test_elixir_ignored_dirs.py @@ -0,0 +1,162 @@ +import os +from collections.abc import Generator +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled, start_ls_context + +# These marks will be applied to all tests in this module +pytestmark = [pytest.mark.elixir, pytest.mark.skipif(not language_tests_enabled(Language.ELIXIR), reason="Elixir tests are disabled")] + +# Skip slow tests in CI - they require multiple Expert instances which is too slow +IN_CI = bool(os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS")) +SKIP_SLOW_IN_CI = pytest.mark.skipif( + IN_CI, + reason="Slow tests skipped in CI - require multiple Expert instances (~60-90s each)", +) + + +@pytest.fixture(scope="session") +def ls_with_ignored_dirs() -> Generator[SolidLanguageServer, None, None]: + """Fixture to set up an LS for the elixir test repo with the 'scripts' directory ignored. + + Uses session scope to avoid restarting Expert for each test. + """ + ignored_paths = ["scripts", "ignored_dir"] + with start_ls_context(language=Language.ELIXIR, ignored_paths=ignored_paths) as ls: + yield ls + + +@pytest.mark.slow +@SKIP_SLOW_IN_CI +def test_symbol_tree_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): + """Tests that request_full_symbol_tree ignores the configured directory. + + Note: This test uses a separate Expert instance with custom ignored paths, + which adds ~60-90s startup time. + """ + root = ls_with_ignored_dirs.request_full_symbol_tree()[0] + root_children = root["children"] + children_names = {child["name"] for child in root_children} + + # Should have lib and test directories, but not scripts or ignored_dir + expected_dirs = {"lib", "test"} + assert expected_dirs.issubset(children_names), f"Expected {expected_dirs} to be in {children_names}" + assert "scripts" not in children_names, f"scripts should not be in {children_names}" + assert "ignored_dir" not in children_names, f"ignored_dir should not be in {children_names}" + + +@pytest.mark.slow +@SKIP_SLOW_IN_CI +def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): + """Tests that find_references ignores the configured directory. + + Note: This test uses a separate Expert instance with custom ignored paths, + which adds ~60-90s startup time. + """ + # Location of User struct, which is referenced in scripts and ignored_dir + definition_file = "lib/models.ex" + + # Find the User struct definition + symbols = ls_with_ignored_dirs.request_document_symbols(definition_file).get_all_symbols_and_roots() + user_symbol = None + for symbol_group in symbols: + user_symbol = next((s for s in symbol_group if "User" in s.get("name", "")), None) + if user_symbol: + break + + if not user_symbol or "selectionRange" not in user_symbol: + pytest.skip("User symbol not found for reference testing") + + sel_start = user_symbol["selectionRange"]["start"] + references = ls_with_ignored_dirs.request_references(definition_file, sel_start["line"], sel_start["character"]) + + # Assert that scripts and ignored_dir do not appear in the references + assert not any("scripts" in ref["relativePath"] for ref in references), "scripts should be ignored" + assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored" + + +@pytest.mark.slow +@SKIP_SLOW_IN_CI +@pytest.mark.parametrize("repo_path", [Language.ELIXIR], indirect=True) +def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None: + """Tests that refs and symbols with glob patterns are ignored. + + Note: This test uses a separate Expert instance with custom ignored paths, + which adds ~60-90s startup time. + """ + ignored_paths = ["*cripts", "ignored_*"] # codespell:ignore cripts + with start_ls_context(language=Language.ELIXIR, repo_path=str(repo_path), ignored_paths=ignored_paths) as ls: + # Same as in the above tests + root = ls.request_full_symbol_tree()[0] + root_children = root["children"] + children_names = {child["name"] for child in root_children} + + # Should have lib and test directories, but not scripts or ignored_dir + expected_dirs = {"lib", "test"} + assert expected_dirs.issubset(children_names), f"Expected {expected_dirs} to be in {children_names}" + assert "scripts" not in children_names, f"scripts should not be in {children_names} (glob pattern)" + assert "ignored_dir" not in children_names, f"ignored_dir should not be in {children_names} (glob pattern)" + + # Test that the refs and symbols with glob patterns are ignored + definition_file = "lib/models.ex" + + # Find the User struct definition + symbols = ls.request_document_symbols(definition_file).get_all_symbols_and_roots() + user_symbol = None + for symbol_group in symbols: + user_symbol = next((s for s in symbol_group if "User" in s.get("name", "")), None) + if user_symbol: + break + + if user_symbol and "selectionRange" in user_symbol: + sel_start = user_symbol["selectionRange"]["start"] + references = ls.request_references(definition_file, sel_start["line"], sel_start["character"]) + + # Assert that scripts and ignored_dir do not appear in references + assert not any("scripts" in ref["relativePath"] for ref in references), "scripts should be ignored (glob)" + assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored (glob)" + + +@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) +def test_default_ignored_directories(language_server: SolidLanguageServer): + """Test that default Elixir directories are ignored.""" + # Test that Elixir-specific directories are ignored by default + assert language_server.is_ignored_dirname("_build"), "_build should be ignored" + assert language_server.is_ignored_dirname("deps"), "deps should be ignored" + assert language_server.is_ignored_dirname(".elixir_ls"), ".elixir_ls should be ignored" + assert language_server.is_ignored_dirname("cover"), "cover should be ignored" + assert language_server.is_ignored_dirname("node_modules"), "node_modules should be ignored" + + # Test that important directories are not ignored + assert not language_server.is_ignored_dirname("lib"), "lib should not be ignored" + assert not language_server.is_ignored_dirname("test"), "test should not be ignored" + assert not language_server.is_ignored_dirname("config"), "config should not be ignored" + assert not language_server.is_ignored_dirname("priv"), "priv should not be ignored" + + +@pytest.mark.xfail( + reason="Expert 0.1.0 bug: document_symbols may return nil for some files (flaky)", + raises=Exception, +) +@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) +def test_symbol_tree_excludes_build_dirs(language_server: SolidLanguageServer): + """Test that symbol tree excludes build and dependency directories.""" + symbol_tree = language_server.request_full_symbol_tree() + + if symbol_tree: + root = symbol_tree[0] + children_names = {child["name"] for child in root.get("children", [])} + + # Build and dependency directories should not appear + ignored_dirs = {"_build", "deps", ".elixir_ls", "cover", "node_modules"} + found_ignored = ignored_dirs.intersection(children_names) + assert len(found_ignored) == 0, f"Found ignored directories in symbol tree: {found_ignored}" + + # Important directories should appear + important_dirs = {"lib", "test"} + found_important = important_dirs.intersection(children_names) + assert len(found_important) > 0, f"Expected to find important directories: {important_dirs}, got: {children_names}" diff --git a/test/solidlsp/elixir/test_elixir_integration.py b/test/solidlsp/elixir/test_elixir_integration.py new file mode 100644 index 0000000..1658993 --- /dev/null +++ b/test/solidlsp/elixir/test_elixir_integration.py @@ -0,0 +1,159 @@ +""" +Integration tests for Elixir language server with test repository. + +These tests verify that the language server works correctly with a real Elixir project +and can perform advanced operations like cross-file symbol resolution. +""" + +import os +from pathlib import Path + +import pytest + +from serena.project import Project +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled + +# These marks will be applied to all tests in this module +pytestmark = [pytest.mark.elixir, pytest.mark.skipif(not language_tests_enabled(Language.ELIXIR), reason="Elixir tests are disabled")] + + +class TestElixirIntegration: + """Integration tests for Elixir language server with test repository.""" + + @pytest.fixture + def elixir_test_repo_path(self): + """Get the path to the Elixir test repository.""" + test_dir = Path(__file__).parent.parent.parent + return str(test_dir / "resources" / "repos" / "elixir" / "test_repo") + + def test_elixir_repo_structure(self, elixir_test_repo_path): + """Test that the Elixir test repository has the expected structure.""" + repo_path = Path(elixir_test_repo_path) + + # Check that key files exist + assert (repo_path / "mix.exs").exists(), "mix.exs should exist" + assert (repo_path / "lib" / "test_repo.ex").exists(), "main module should exist" + assert (repo_path / "lib" / "utils.ex").exists(), "utils module should exist" + assert (repo_path / "lib" / "models.ex").exists(), "models module should exist" + assert (repo_path / "lib" / "services.ex").exists(), "services module should exist" + assert (repo_path / "lib" / "examples.ex").exists(), "examples module should exist" + assert (repo_path / "test" / "test_repo_test.exs").exists(), "test file should exist" + assert (repo_path / "test" / "models_test.exs").exists(), "models test should exist" + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_cross_file_symbol_resolution(self, language_server: SolidLanguageServer): + """Test that symbols can be resolved across different files.""" + # Test that User struct from models.ex can be found when referenced in services.ex + services_file = os.path.join("lib", "services.ex") + + # Find where User is referenced in services.ex + content = language_server.retrieve_full_file_content(services_file) + lines = content.split("\n") + user_reference_line = None + for i, line in enumerate(lines): + if "alias TestRepo.Models.{User" in line: + user_reference_line = i + break + + if user_reference_line is None: + pytest.skip("Could not find User reference in services.ex") + + # Try to find the definition + defining_symbol = language_server.request_defining_symbol(services_file, user_reference_line, 30) + + if defining_symbol and "location" in defining_symbol: + # Should point to models.ex + assert "models.ex" in defining_symbol["location"]["uri"] + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_module_hierarchy_understanding(self, language_server: SolidLanguageServer): + """Test that the language server understands Elixir module hierarchy.""" + models_file = os.path.join("lib", "models.ex") + symbols = language_server.request_document_symbols(models_file).get_all_symbols_and_roots() + + if symbols: + # Flatten symbol structure + all_symbols = [] + for symbol_group in symbols: + if isinstance(symbol_group, list): + all_symbols.extend(symbol_group) + else: + all_symbols.append(symbol_group) + + symbol_names = [s.get("name", "") for s in all_symbols] + + # Should understand nested module structure + expected_modules = ["TestRepo.Models", "User", "Item", "Order"] + found_modules = [name for name in expected_modules if any(name in symbol_name for symbol_name in symbol_names)] + assert len(found_modules) > 0, f"Expected modules {expected_modules}, found symbols {symbol_names}" + + def test_file_extension_matching(self): + """Test that the Elixir language recognizes the correct file extensions.""" + language = Language.ELIXIR + matcher = language.get_source_fn_matcher() + + # Test Elixir file extensions + assert matcher.is_relevant_filename("lib/test_repo.ex") + assert matcher.is_relevant_filename("test/test_repo_test.exs") + assert matcher.is_relevant_filename("config/config.exs") + assert matcher.is_relevant_filename("mix.exs") + assert matcher.is_relevant_filename("lib/models.ex") + assert matcher.is_relevant_filename("lib/services.ex") + + # Test non-Elixir files + assert not matcher.is_relevant_filename("README.md") + assert not matcher.is_relevant_filename("lib/test_repo.py") + assert not matcher.is_relevant_filename("package.json") + assert not matcher.is_relevant_filename("Cargo.toml") + + +class TestElixirProject: + @pytest.mark.parametrize("project", [Language.ELIXIR], indirect=True) + def test_comprehensive_symbol_search(self, project: Project): + """Test comprehensive symbol search across the entire project.""" + # Search for all function definitions + function_pattern = r"def\s+\w+\s*[\(\s]" + function_matches = project.search_project_files_for_pattern(function_pattern) + + # Should find functions across multiple files + if function_matches: + files_with_functions = set() + for match in function_matches: + if match.source_file_path: + files_with_functions.add(os.path.basename(match.source_file_path)) + + # Should find functions in multiple files + expected_files = {"models.ex", "services.ex", "examples.ex", "utils.ex", "test_repo.ex"} + found_files = expected_files.intersection(files_with_functions) + assert len(found_files) > 0, f"Expected functions in {expected_files}, found in {files_with_functions}" + + # Search for struct definitions + struct_pattern = r"defstruct\s+\[" + struct_matches = project.search_project_files_for_pattern(struct_pattern) + + if struct_matches: + # Should find structs primarily in models.ex + models_structs = [m for m in struct_matches if m.source_file_path and "models.ex" in m.source_file_path] + assert len(models_structs) > 0, "Should find struct definitions in models.ex" + + @pytest.mark.parametrize("project", [Language.ELIXIR], indirect=True) + def test_protocol_and_implementation_understanding(self, project: Project): + """Test that the language server understands Elixir protocols and implementations.""" + # Search for protocol definitions + protocol_pattern = r"defprotocol\s+\w+" + protocol_matches = project.search_project_files_for_pattern(protocol_pattern, paths_include_glob="**/models.ex") + + if protocol_matches: + # Should find the Serializable protocol + serializable_matches = [m for m in protocol_matches if "Serializable" in str(m)] + assert len(serializable_matches) > 0, "Should find Serializable protocol definition" + + # Search for protocol implementations + impl_pattern = r"defimpl\s+\w+" + impl_matches = project.search_project_files_for_pattern(impl_pattern, paths_include_glob="**/models.ex") + + if impl_matches: + # Should find multiple implementations + assert len(impl_matches) >= 3, f"Should find at least 3 protocol implementations, found {len(impl_matches)}" diff --git a/test/solidlsp/elixir/test_elixir_symbol_retrieval.py b/test/solidlsp/elixir/test_elixir_symbol_retrieval.py new file mode 100644 index 0000000..219cfe1 --- /dev/null +++ b/test/solidlsp/elixir/test_elixir_symbol_retrieval.py @@ -0,0 +1,348 @@ +""" +Tests for the Elixir language server symbol-related functionality. + +These tests focus on the following methods: +- request_containing_symbol +- request_referencing_symbols +- request_defining_symbol +""" + +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from test.conftest import language_tests_enabled + +# These marks will be applied to all tests in this module +pytestmark = [pytest.mark.elixir, pytest.mark.skipif(not language_tests_enabled(Language.ELIXIR), reason="Elixir tests are disabled")] + + +class TestElixirLanguageServerSymbols: + """Test the Elixir language server's symbol-related functionality.""" + + @pytest.mark.xfail( + reason="Expert 0.1.0 bug: document_symbols returns nil for some files (FunctionClauseError in XPExpert.EngineApi.document_symbols/2)" + ) + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_containing_symbol_function(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol for a function.""" + # Test for a position inside the create_user function + file_path = os.path.join("lib", "services.ex") + + # Find the create_user function in the file + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + create_user_line = None + for i, line in enumerate(lines): + if "def create_user(" in line: + create_user_line = i + 2 # Go inside the function body + break + + if create_user_line is None: + pytest.skip("Could not find create_user function") + + containing_symbol = language_server.request_containing_symbol(file_path, create_user_line, 10, include_body=True) + + # Verify that we found the containing symbol + if containing_symbol: + # Next LS returns the full function signature instead of just the function name + assert containing_symbol["name"] == "def create_user(pid, id, name, email, roles \\\\ [])" + assert containing_symbol["kind"] == SymbolKind.Method or containing_symbol["kind"] == SymbolKind.Function + if "body" in containing_symbol: + assert "def create_user" in containing_symbol["body"].get_text() + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_containing_symbol_module(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol for a module.""" + # Test for a position inside the UserService module but outside any function + file_path = os.path.join("lib", "services.ex") + + # Find the UserService module definition + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + user_service_line = None + for i, line in enumerate(lines): + if "defmodule UserService do" in line: + user_service_line = i + 1 # Go inside the module + break + + if user_service_line is None: + pytest.skip("Could not find UserService module") + + containing_symbol = language_server.request_containing_symbol(file_path, user_service_line, 5) + + # Verify that we found the containing symbol + if containing_symbol: + assert "UserService" in containing_symbol["name"] + assert containing_symbol["kind"] == SymbolKind.Module or containing_symbol["kind"] == SymbolKind.Class + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_containing_symbol_nested(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol with nested scopes.""" + # Test for a position inside a function which is inside a module + file_path = os.path.join("lib", "services.ex") + + # Find a function inside UserService + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + function_body_line = None + for i, line in enumerate(lines): + if "def create_user(" in line: + function_body_line = i + 3 # Go deeper into the function body + break + + if function_body_line is None: + pytest.skip("Could not find function body") + + containing_symbol = language_server.request_containing_symbol(file_path, function_body_line, 15) + + # Verify that we found the innermost containing symbol (the function) + if containing_symbol: + expected_names = ["create_user", "UserService"] + assert any(name in containing_symbol["name"] for name in expected_names) + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_containing_symbol_none(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol for a position with no containing symbol.""" + # Test for a position outside any function/module (e.g., in module doc) + file_path = os.path.join("lib", "services.ex") + # Line 1-3 are likely in module documentation or imports + containing_symbol = language_server.request_containing_symbol(file_path, 2, 10) + + # Should return None or an empty dictionary, or the top-level module + # This is acceptable behavior for module-level positions + assert containing_symbol is None or containing_symbol == {} or "TestRepo.Services" in str(containing_symbol) + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_referencing_symbols_struct(self, language_server: SolidLanguageServer) -> None: + """Test request_referencing_symbols for a struct.""" + # Test referencing symbols for User struct + file_path = os.path.join("lib", "models.ex") + + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + user_symbol = None + for symbol_group in symbols: + user_symbol = next((s for s in symbol_group if "User" in s.get("name", "")), None) + if user_symbol: + break + + if not user_symbol or "selectionRange" not in user_symbol: + pytest.skip("User symbol or its selectionRange not found") + + sel_start = user_symbol["selectionRange"]["start"] + ref_symbols = [ + ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) + ] + + if ref_symbols: + services_references = [ + symbol + for symbol in ref_symbols + if "location" in symbol and "uri" in symbol["location"] and "services.ex" in symbol["location"]["uri"] + ] + # We expect some references from services.ex + assert len(services_references) >= 0 # At least attempt to find references + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_referencing_symbols_none(self, language_server: SolidLanguageServer) -> None: + """Test request_referencing_symbols for a position with no symbol.""" + file_path = os.path.join("lib", "services.ex") + # Line 3 is likely a blank line or comment + try: + ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, 3, 0)] + # If we get here, make sure we got an empty result + assert ref_symbols == [] or ref_symbols is None + except Exception: + # The method might raise an exception for invalid positions + # which is acceptable behavior + pass + + # Tests for request_defining_symbol + @pytest.mark.xfail( + reason="Expert 0.1.0 bug: definition request crashes (FunctionClauseError in XPExpert.Protocol.Conversions.to_elixir/2)" + ) + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_defining_symbol_function_call(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for a function call.""" + # Find a place where User.new is called in services.ex + file_path = os.path.join("lib", "services.ex") + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + user_new_call_line = None + for i, line in enumerate(lines): + if "User.new(" in line: + user_new_call_line = i + break + + if user_new_call_line is None: + pytest.skip("Could not find User.new call") + + # Try to find the definition of User.new + defining_symbol = language_server.request_defining_symbol(file_path, user_new_call_line, 15) + + if defining_symbol: + assert defining_symbol.get("name") == "new" or "User" in defining_symbol.get("name", "") + if "location" in defining_symbol and "uri" in defining_symbol["location"]: + assert "models.ex" in defining_symbol["location"]["uri"] + + @pytest.mark.xfail( + reason="Expert 0.1.0 bug: definition request crashes (FunctionClauseError in XPExpert.Protocol.Conversions.to_elixir/2)" + ) + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_defining_symbol_struct_usage(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for a struct usage.""" + # Find a place where User struct is used in services.ex + file_path = os.path.join("lib", "services.ex") + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + user_usage_line = None + for i, line in enumerate(lines): + if "alias TestRepo.Models.{User" in line: + user_usage_line = i + break + + if user_usage_line is None: + pytest.skip("Could not find User struct usage") + + defining_symbol = language_server.request_defining_symbol(file_path, user_usage_line, 30) + + if defining_symbol: + assert "User" in defining_symbol.get("name", "") + + @pytest.mark.xfail( + reason="Expert 0.1.0 bug: definition request crashes (FunctionClauseError in XPExpert.Protocol.Conversions.to_elixir/2)" + ) + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_defining_symbol_none(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for a position with no symbol.""" + # Test for a position with no symbol (e.g., whitespace or comment) + file_path = os.path.join("lib", "services.ex") + # Line 3 is likely a blank line + defining_symbol = language_server.request_defining_symbol(file_path, 3, 0) + + # Should return None or empty + assert defining_symbol is None or defining_symbol == {} + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_symbol_methods_integration(self, language_server: SolidLanguageServer) -> None: + """Test integration between different symbol methods.""" + file_path = os.path.join("lib", "models.ex") + + # Find User struct definition + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + user_struct_line = None + for i, line in enumerate(lines): + if "defmodule User do" in line: + user_struct_line = i + break + + if user_struct_line is None: + pytest.skip("Could not find User struct") + + # Test containing symbol + containing = language_server.request_containing_symbol(file_path, user_struct_line + 5, 10) + + if containing: + # Test that we can find references to this symbol + if "location" in containing and "range" in containing["location"]: + start_pos = containing["location"]["range"]["start"] + refs = [ + ref.symbol for ref in language_server.request_referencing_symbols(file_path, start_pos["line"], start_pos["character"]) + ] + # We should find some references or none (both are valid outcomes) + assert isinstance(refs, list) + + @pytest.mark.xfail(reason="Flaky test, sometimes fails with an Expert-internal error") + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_symbol_tree_structure(self, language_server: SolidLanguageServer) -> None: + """Test that symbol tree structure is correctly built.""" + symbol_tree = language_server.request_full_symbol_tree() + + # Should get a tree structure + assert len(symbol_tree) > 0 + + # Should have our test repository structure + root = symbol_tree[0] + assert "children" in root + + # Look for lib directory + lib_dir = None + for child in root["children"]: + if child["name"] == "lib": + lib_dir = child + break + + if lib_dir: + # Expert returns module names instead of file names (e.g., 'services' instead of 'services.ex') + file_names = [child["name"] for child in lib_dir.get("children", [])] + expected_modules = ["models", "services", "examples", "utils", "test_repo"] + found_modules = [name for name in expected_modules if name in file_names] + assert len(found_modules) > 0, f"Expected to find some modules from {expected_modules}, but got {file_names}" + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_dir_overview(self, language_server: SolidLanguageServer) -> None: + """Test request_dir_overview functionality.""" + lib_overview = language_server.request_dir_overview("lib") + + # Should get an overview of the lib directory + assert lib_overview is not None + # Expert returns keys like 'lib/services.ex' instead of just 'lib' + overview_keys = list(lib_overview.keys()) if hasattr(lib_overview, "keys") else [] + lib_files = [key for key in overview_keys if key.startswith("lib/")] + assert len(lib_files) > 0, f"Expected to find lib/ files in overview keys: {overview_keys}" + + # Should contain information about our modules + overview_text = str(lib_overview).lower() + expected_terms = ["models", "services", "user", "item"] + found_terms = [term for term in expected_terms if term in overview_text] + assert len(found_terms) > 0, f"Expected to find some terms from {expected_terms} in overview" + + # @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + # def test_request_document_overview(self, language_server: SolidLanguageServer) -> None: + # """Test request_document_overview functionality.""" + # # COMMENTED OUT: Expert document overview doesn't contain expected terms + # # Expert return value: [('TestRepo.Models', 2, 0, 0)] - only module info, no detailed content + # # Expected terms like 'user', 'item', 'order', 'struct', 'defmodule' are not present + # # This appears to be a limitation of Expert document overview functionality + # # + # file_path = os.path.join("lib", "models.ex") + # doc_overview = language_server.request_document_overview(file_path) + # + # # Should get an overview of the models.ex file + # assert doc_overview is not None + # + # # Should contain information about our structs and functions + # overview_text = str(doc_overview).lower() + # expected_terms = ["user", "item", "order", "struct", "defmodule"] + # found_terms = [term for term in expected_terms if term in overview_text] + # assert len(found_terms) > 0, f"Expected to find some terms from {expected_terms} in overview" + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_containing_symbol_of_module_attribute(self, language_server: SolidLanguageServer) -> None: + """Test containing symbol for module attributes.""" + file_path = os.path.join("lib", "models.ex") + + # Find a module attribute like @type or @doc + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + attribute_line = None + for i, line in enumerate(lines): + if line.strip().startswith("@type") or line.strip().startswith("@doc"): + attribute_line = i + break + + if attribute_line is None: + pytest.skip("Could not find module attribute") + + containing_symbol = language_server.request_containing_symbol(file_path, attribute_line, 5) + + if containing_symbol: + # Should be contained within a module + assert "name" in containing_symbol + # The containing symbol should be a module + expected_names = ["User", "Item", "Order", "TestRepo.Models"] + assert any(name in containing_symbol["name"] for name in expected_names) diff --git a/test/solidlsp/elm/test_elm_basic.py b/test/solidlsp/elm/test_elm_basic.py new file mode 100644 index 0000000..a886ede --- /dev/null +++ b/test/solidlsp/elm/test_elm_basic.py @@ -0,0 +1,70 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.conftest import language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.skipif(not language_tests_enabled(Language.ELM), reason="Elm tests are disabled (elm compiler not available)") +@pytest.mark.elm +class TestElmLanguageServer: + @pytest.mark.parametrize("language_server", [Language.ELM], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "greet"), "greet function not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "calculateSum"), "calculateSum function not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "formatMessage"), "formatMessage function not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "addNumbers"), "addNumbers function not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.ELM], indirect=True) + def test_find_references_within_file(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("Main.elm") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + greet_symbol = None + for sym in symbols[0]: + if sym.get("name") == "greet": + greet_symbol = sym + break + assert greet_symbol is not None, "Could not find 'greet' symbol in Main.elm" + sel_start = greet_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert any("Main.elm" in ref.get("relativePath", "") for ref in refs), "Main.elm should reference greet function" + + @pytest.mark.parametrize("language_server", [Language.ELM], indirect=True) + def test_find_references_across_files(self, language_server: SolidLanguageServer) -> None: + # Test formatMessage function which is defined in Utils.elm and used in Main.elm + utils_path = os.path.join("Utils.elm") + symbols = language_server.request_document_symbols(utils_path).get_all_symbols_and_roots() + formatMessage_symbol = None + for sym in symbols[0]: + if sym.get("name") == "formatMessage": + formatMessage_symbol = sym + break + assert formatMessage_symbol is not None, "Could not find 'formatMessage' symbol in Utils.elm" + + # Get references from the definition in Utils.elm + sel_start = formatMessage_symbol["selectionRange"]["start"] + refs = language_server.request_references(utils_path, sel_start["line"], sel_start["character"]) + + # Verify that we found references + assert refs, "Expected to find references for formatMessage" + + # Verify that at least one reference is in Main.elm (where formatMessage is used) + assert any("Main.elm" in ref.get("relativePath", "") for ref in refs), "Expected to find usage of formatMessage in Main.elm" + + @pytest.mark.parametrize("language_server", [Language.ELM], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/elm/test_elm_diagnostics.py b/test/solidlsp/elm/test_elm_diagnostics.py new file mode 100644 index 0000000..744201a --- /dev/null +++ b/test/solidlsp/elm/test_elm_diagnostics.py @@ -0,0 +1,20 @@ +import shutil + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.elm +@pytest.mark.skipif(shutil.which("node") is None or shutil.which("npm") is None, reason="Elm diagnostics require Node.js and npm") +class TestElmDiagnostics: + @pytest.mark.parametrize("language_server", [Language.ELM], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "DiagnosticsSample.elm", + (), + min_count=1, + ) diff --git a/test/solidlsp/erlang/__init__.py b/test/solidlsp/erlang/__init__.py new file mode 100644 index 0000000..6c9c2f0 --- /dev/null +++ b/test/solidlsp/erlang/__init__.py @@ -0,0 +1,33 @@ +import platform + + +def _test_erlang_ls_available() -> str: + """Test if Erlang LS is available and return error reason if not.""" + # Check if we're on Windows (Erlang LS doesn't support Windows) + if platform.system() == "Windows": + return "Erlang LS does not support Windows" + + # Try to import and check Erlang availability + try: + from solidlsp.language_servers.erlang_language_server import ErlangLanguageServer + + # Check if Erlang/OTP is installed + erlang_version = ErlangLanguageServer._get_erlang_version() + if not erlang_version: + return "Erlang/OTP is not installed or not in PATH" + + # Check if rebar3 is available (commonly used build tool) + rebar3_available = ErlangLanguageServer._check_rebar3_available() + if not rebar3_available: + return "rebar3 is not installed or not in PATH (required for project compilation)" + + return "" # No error, Erlang LS should be available + + except ImportError as e: + return f"Failed to import ErlangLanguageServer: {e}" + except Exception as e: + return f"Error checking Erlang LS availability: {e}" + + +ERLANG_LS_UNAVAILABLE_REASON = _test_erlang_ls_available() +ERLANG_LS_UNAVAILABLE = bool(ERLANG_LS_UNAVAILABLE_REASON) diff --git a/test/solidlsp/erlang/conftest.py b/test/solidlsp/erlang/conftest.py new file mode 100644 index 0000000..5ac7779 --- /dev/null +++ b/test/solidlsp/erlang/conftest.py @@ -0,0 +1,174 @@ +""" +Erlang-specific test configuration and fixtures. +""" + +import os +import subprocess +import time +from pathlib import Path + +import pytest + + +def ensure_erlang_test_repo_compiled(repo_path: str) -> None: + """Ensure the Erlang test repository dependencies are installed and project is compiled. + + Erlang LS requires the project to be fully compiled and indexed before providing + complete references and symbol resolution. This function: + 1. Installs dependencies via 'rebar3 deps' + 2. Compiles the project via 'rebar3 compile' + + This is essential in CI environments where dependencies aren't pre-installed. + + Args: + repo_path: Path to the Erlang project root directory + + """ + # Check if this looks like an Erlang project + rebar_config = os.path.join(repo_path, "rebar.config") + if not os.path.exists(rebar_config): + return + + # Check if already compiled (optimization for repeated runs) + build_path = os.path.join(repo_path, "_build") + deps_path = os.path.join(repo_path, "deps") + + if os.path.exists(build_path) and os.path.exists(deps_path): + print(f"Erlang test repository already compiled in {repo_path}") + return + + try: + print("Installing dependencies and compiling Erlang test repository for optimal Erlang LS performance...") + + # First, install dependencies with increased timeout for CI + print("=" * 60) + print("Step 1/2: Installing Erlang dependencies...") + print("=" * 60) + start_time = time.time() + + deps_result = subprocess.run( + ["rebar3", "deps"], + cwd=repo_path, + capture_output=True, + text=True, + timeout=180, + check=False, # 3 minutes for dependency installation (CI can be slow) + ) + + deps_duration = time.time() - start_time + print(f"Dependencies installation completed in {deps_duration:.2f} seconds") + + # Always log the output for transparency + if deps_result.stdout.strip(): + print("Dependencies stdout:") + print("-" * 40) + print(deps_result.stdout) + print("-" * 40) + + if deps_result.stderr.strip(): + print("Dependencies stderr:") + print("-" * 40) + print(deps_result.stderr) + print("-" * 40) + + if deps_result.returncode != 0: + print(f"⚠️ Warning: Dependencies installation failed with exit code {deps_result.returncode}") + # Continue anyway - some projects might not have dependencies + else: + print("✓ Dependencies installed successfully") + + # Then compile the project with increased timeout for CI + print("=" * 60) + print("Step 2/2: Compiling Erlang project...") + print("=" * 60) + start_time = time.time() + + compile_result = subprocess.run( + ["rebar3", "compile"], + cwd=repo_path, + capture_output=True, + text=True, + timeout=300, + check=False, # 5 minutes for compilation (Dialyzer can be slow in CI) + ) + + compile_duration = time.time() - start_time + print(f"Compilation completed in {compile_duration:.2f} seconds") + + # Always log the output for transparency + if compile_result.stdout.strip(): + print("Compilation stdout:") + print("-" * 40) + print(compile_result.stdout) + print("-" * 40) + + if compile_result.stderr.strip(): + print("Compilation stderr:") + print("-" * 40) + print(compile_result.stderr) + print("-" * 40) + + if compile_result.returncode == 0: + print(f"✓ Erlang test repository compiled successfully in {repo_path}") + else: + print(f"⚠️ Warning: Compilation completed with exit code {compile_result.returncode}") + # Still continue - warnings are often non-fatal + + print("=" * 60) + print(f"Total setup time: {time.time() - (start_time - compile_duration - deps_duration):.2f} seconds") + print("=" * 60) + + except subprocess.TimeoutExpired as e: + print("=" * 60) + print(f"❌ TIMEOUT: Erlang setup timed out after {e.timeout} seconds") + print(f"Command: {' '.join(e.cmd)}") + print("This may indicate slow CI environment - Erlang LS may still work but with reduced functionality") + + # Try to get partial output if available + if hasattr(e, "stdout") and e.stdout: + print("Partial stdout before timeout:") + print("-" * 40) + print(e.stdout) + print("-" * 40) + if hasattr(e, "stderr") and e.stderr: + print("Partial stderr before timeout:") + print("-" * 40) + print(e.stderr) + print("-" * 40) + print("=" * 60) + + except FileNotFoundError: + print("❌ ERROR: 'rebar3' command not found - Erlang test repository may not be compiled") + print("Please ensure rebar3 is installed and available in PATH") + except Exception as e: + print(f"❌ ERROR: Failed to prepare Erlang test repository: {e}") + + +@pytest.fixture(scope="session", autouse=True) +def setup_erlang_test_environment(): + """Automatically prepare Erlang test environment for all Erlang tests. + + This fixture runs once per test session and automatically: + 1. Installs dependencies via 'rebar3 deps' + 2. Compiles the Erlang test repository via 'rebar3 compile' + + It uses autouse=True so it runs automatically without needing to be explicitly + requested by tests. This ensures Erlang LS has a fully prepared project to work with. + + Uses generous timeouts (3-5 minutes) to accommodate slow CI environments. + All output is logged for transparency and debugging. + """ + # Get the test repo path relative to this conftest.py file + test_repo_path = Path(__file__).parent.parent.parent / "resources" / "repos" / "erlang" / "test_repo" + ensure_erlang_test_repo_compiled(str(test_repo_path)) + return str(test_repo_path) + + +@pytest.fixture(scope="session") +def erlang_test_repo_path(setup_erlang_test_environment): + """Get the path to the prepared Erlang test repository. + + This fixture depends on setup_erlang_test_environment to ensure dependencies + are installed and compilation has completed before returning the path. + """ + return setup_erlang_test_environment diff --git a/test/solidlsp/erlang/test_erlang_basic.py b/test/solidlsp/erlang/test_erlang_basic.py new file mode 100644 index 0000000..491c71a --- /dev/null +++ b/test/solidlsp/erlang/test_erlang_basic.py @@ -0,0 +1,66 @@ +""" +Basic integration tests for the Erlang language server functionality. + +These tests validate the functionality of the language server APIs +like request_references using the test repository. +""" + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.erlang +@pytest.mark.skipif(not language_tests_enabled(Language.ERLANG), reason="Erlang tests are disabled") +class TestErlangLanguageServerBasics: + """Test basic functionality of the Erlang language server.""" + + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_language_server_initialization(self, language_server: SolidLanguageServer) -> None: + """Test that the Erlang language server initializes properly.""" + assert language_server is not None + assert language_server.language == Language.ERLANG + + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_document_symbols(self, language_server: SolidLanguageServer) -> None: + """Test document symbols retrieval for Erlang files.""" + try: + file_path = "hello.erl" + symbols_tuple = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + assert isinstance(symbols_tuple, tuple) + assert len(symbols_tuple) == 2 + + all_symbols, root_symbols = symbols_tuple + assert isinstance(all_symbols, list) + assert isinstance(root_symbols, list) + except Exception as e: + if "not fully initialized" in str(e): + pytest.skip("Erlang language server not fully initialized") + else: + raise + + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) + + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/diagnostics_sample.erl", + (), + min_count=1, + ) diff --git a/test/solidlsp/erlang/test_erlang_ignored_dirs.py b/test/solidlsp/erlang/test_erlang_ignored_dirs.py new file mode 100644 index 0000000..d6fdb0a --- /dev/null +++ b/test/solidlsp/erlang/test_erlang_ignored_dirs.py @@ -0,0 +1,198 @@ +from collections.abc import Generator +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled, start_ls_context + +# These marks will be applied to all tests in this module +pytestmark = [ + pytest.mark.erlang, + pytest.mark.skipif(not language_tests_enabled(Language.ERLANG), reason="Erlang tests are disabled"), +] + + +@pytest.fixture(scope="module") +def ls_with_ignored_dirs() -> Generator[SolidLanguageServer, None, None]: + """Fixture to set up an LS for the erlang test repo with the 'ignored_dir' directory ignored.""" + ignored_paths = ["_build", "ignored_dir"] + with start_ls_context(language=Language.ERLANG, ignored_paths=ignored_paths) as ls: + yield ls + + +@pytest.mark.timeout(60) # Add 60 second timeout +@pytest.mark.xfail(reason="Known timeout issue on Ubuntu CI with Erlang LS server startup", strict=False) +@pytest.mark.parametrize("ls_with_ignored_dirs", [Language.ERLANG], indirect=True) +def test_symbol_tree_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): + """Tests that request_full_symbol_tree ignores the configured directory.""" + root = ls_with_ignored_dirs.request_full_symbol_tree()[0] + root_children = root["children"] + children_names = {child["name"] for child in root_children} + + # Should have src, include, and test directories, but not _build or ignored_dir + expected_dirs = {"src", "include", "test"} + found_expected = expected_dirs.intersection(children_names) + assert len(found_expected) > 0, f"Expected some dirs from {expected_dirs} to be in {children_names}" + assert "_build" not in children_names, f"_build should not be in {children_names}" + assert "ignored_dir" not in children_names, f"ignored_dir should not be in {children_names}" + + +@pytest.mark.timeout(60) # Add 60 second timeout +@pytest.mark.xfail(reason="Known timeout issue on Ubuntu CI with Erlang LS server startup", strict=False) +@pytest.mark.parametrize("ls_with_ignored_dirs", [Language.ERLANG], indirect=True) +def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): + """Tests that find_references ignores the configured directory.""" + # Location of user record, which might be referenced in ignored_dir + definition_file = "include/records.hrl" + + # Find the user record definition + symbols = ls_with_ignored_dirs.request_document_symbols(definition_file).get_all_symbols_and_roots() + user_symbol = None + for symbol_group in symbols: + user_symbol = next((s for s in symbol_group if "user" in s.get("name", "").lower()), None) + if user_symbol: + break + + if not user_symbol or "selectionRange" not in user_symbol: + pytest.skip("User record symbol not found for reference testing") + + sel_start = user_symbol["selectionRange"]["start"] + references = ls_with_ignored_dirs.request_references(definition_file, sel_start["line"], sel_start["character"]) + + # Assert that _build and ignored_dir do not appear in the references + assert not any("_build" in ref["relativePath"] for ref in references), "_build should be ignored" + assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored" + + +@pytest.mark.timeout(60) # Add 60 second timeout +@pytest.mark.xfail(reason="Known timeout issue on Ubuntu CI with Erlang LS server startup", strict=False) +@pytest.mark.parametrize("repo_path", [Language.ERLANG], indirect=True) +def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None: + """Tests that refs and symbols with glob patterns are ignored.""" + ignored_paths = ["_build*", "ignored_*", "*.tmp"] + with start_ls_context(language=Language.ERLANG, repo_path=str(repo_path), ignored_paths=ignored_paths) as ls: + # Same as in the above tests + root = ls.request_full_symbol_tree()[0] + root_children = root["children"] + children_names = {child["name"] for child in root_children} + + # Should have src, include, and test directories, but not _build or ignored_dir + expected_dirs = {"src", "include", "test"} + found_expected = expected_dirs.intersection(children_names) + assert len(found_expected) > 0, f"Expected some dirs from {expected_dirs} to be in {children_names}" + assert "_build" not in children_names, f"_build should not be in {children_names} (glob pattern)" + assert "ignored_dir" not in children_names, f"ignored_dir should not be in {children_names} (glob pattern)" + + # Test that the refs and symbols with glob patterns are ignored + definition_file = "include/records.hrl" + + # Find the user record definition + symbols = ls.request_document_symbols(definition_file).get_all_symbols_and_roots() + user_symbol = None + for symbol_group in symbols: + user_symbol = next((s for s in symbol_group if "user" in s.get("name", "").lower()), None) + if user_symbol: + break + + if user_symbol and "selectionRange" in user_symbol: + sel_start = user_symbol["selectionRange"]["start"] + references = ls.request_references(definition_file, sel_start["line"], sel_start["character"]) + + # Assert that _build and ignored_dir do not appear in references + assert not any("_build" in ref["relativePath"] for ref in references), "_build should be ignored (glob)" + assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored (glob)" + + +@pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) +def test_default_ignored_directories(language_server: SolidLanguageServer): + """Test that default Erlang directories are ignored.""" + # Test that Erlang-specific directories are ignored by default + assert language_server.is_ignored_dirname("_build"), "_build should be ignored" + assert language_server.is_ignored_dirname("ebin"), "ebin should be ignored" + assert language_server.is_ignored_dirname("deps"), "deps should be ignored" + assert language_server.is_ignored_dirname(".rebar3"), ".rebar3 should be ignored" + assert language_server.is_ignored_dirname("_checkouts"), "_checkouts should be ignored" + assert language_server.is_ignored_dirname("node_modules"), "node_modules should be ignored" + + # Test that important directories are not ignored + assert not language_server.is_ignored_dirname("src"), "src should not be ignored" + assert not language_server.is_ignored_dirname("include"), "include should not be ignored" + assert not language_server.is_ignored_dirname("test"), "test should not be ignored" + assert not language_server.is_ignored_dirname("priv"), "priv should not be ignored" + + +@pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) +def test_symbol_tree_excludes_build_dirs(language_server: SolidLanguageServer): + """Test that symbol tree excludes build and dependency directories.""" + symbol_tree = language_server.request_full_symbol_tree() + + if symbol_tree: + root = symbol_tree[0] + children_names = {child["name"] for child in root.get("children", [])} + + # Build and dependency directories should not appear + ignored_dirs = {"_build", "ebin", "deps", ".rebar3", "_checkouts", "node_modules"} + found_ignored = ignored_dirs.intersection(children_names) + assert len(found_ignored) == 0, f"Found ignored directories in symbol tree: {found_ignored}" + + # Important directories should appear + important_dirs = {"src", "include", "test"} + found_important = important_dirs.intersection(children_names) + assert len(found_important) > 0, f"Expected to find important directories: {important_dirs}, got: {children_names}" + + +@pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) +def test_ignore_compiled_files(language_server: SolidLanguageServer): + """Test that compiled Erlang files are ignored.""" + # Test that beam files are ignored + assert language_server.is_ignored_filename("module.beam"), "BEAM files should be ignored" + assert language_server.is_ignored_filename("app.beam"), "BEAM files should be ignored" + + # Test that source files are not ignored + assert not language_server.is_ignored_filename("module.erl"), "Erlang source files should not be ignored" + assert not language_server.is_ignored_filename("records.hrl"), "Header files should not be ignored" + + +@pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) +def test_rebar_directories_ignored(language_server: SolidLanguageServer): + """Test that rebar-specific directories are ignored.""" + # Test rebar3-specific directories + assert language_server.is_ignored_dirname("_build"), "rebar3 _build should be ignored" + assert language_server.is_ignored_dirname("_checkouts"), "rebar3 _checkouts should be ignored" + assert language_server.is_ignored_dirname(".rebar3"), "rebar3 cache should be ignored" + + # Test that rebar.lock and rebar.config are not ignored (they are configuration files) + assert not language_server.is_ignored_filename("rebar.config"), "rebar.config should not be ignored" + assert not language_server.is_ignored_filename("rebar.lock"), "rebar.lock should not be ignored" + + +@pytest.mark.parametrize("ls_with_ignored_dirs", [Language.ERLANG], indirect=True) +def test_document_symbols_ignores_dirs(ls_with_ignored_dirs: SolidLanguageServer): + """Test that document symbols from ignored directories are not included.""" + # Try to get symbols from a file in ignored directory (should not find it) + try: + ignored_file = "ignored_dir/ignored_module.erl" + symbols = ls_with_ignored_dirs.request_document_symbols(ignored_file).get_all_symbols_and_roots() + # If we get here, the file was found - symbols should be empty or None + if symbols: + assert len(symbols) == 0, "Should not find symbols in ignored directory" + except Exception: + # This is expected - the file should not be accessible + pass + + +@pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) +def test_erlang_specific_ignore_patterns(language_server: SolidLanguageServer): + """Test Erlang-specific ignore patterns work correctly.""" + erlang_ignored_dirs = ["_build", "ebin", ".rebar3", "_checkouts", "cover"] + + # These should be ignored + for dirname in erlang_ignored_dirs: + assert language_server.is_ignored_dirname(dirname), f"{dirname} should be ignored" + + # These should not be ignored + erlang_important_dirs = ["src", "include", "test", "priv"] + for dirname in erlang_important_dirs: + assert not language_server.is_ignored_dirname(dirname), f"{dirname} should not be ignored" diff --git a/test/solidlsp/erlang/test_erlang_symbol_retrieval.py b/test/solidlsp/erlang/test_erlang_symbol_retrieval.py new file mode 100644 index 0000000..918237c --- /dev/null +++ b/test/solidlsp/erlang/test_erlang_symbol_retrieval.py @@ -0,0 +1,443 @@ +""" +Tests for the Erlang language server symbol-related functionality. + +These tests focus on the following methods: +- request_containing_symbol +- request_referencing_symbols +- request_defining_symbol +""" + +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from test.conftest import language_tests_enabled + +# These marks will be applied to all tests in this module +pytestmark = [ + pytest.mark.erlang, + pytest.mark.skipif(not language_tests_enabled(Language.ERLANG), reason="Erlang tests are disabled"), +] + + +class TestErlangLanguageServerSymbols: + """Test the Erlang language server's symbol-related functionality.""" + + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_request_containing_symbol_function(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol for a function.""" + # Test for a position inside the create_user function + file_path = os.path.join("src", "models.erl") + + # Find the create_user function in the file + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + create_user_line = None + for i, line in enumerate(lines): + if "create_user(" in line and "-spec" not in line: + create_user_line = i + 1 # Go inside the function body + break + + if create_user_line is None: + pytest.skip("Could not find create_user function") + + containing_symbol = language_server.request_containing_symbol(file_path, create_user_line, 10, include_body=True) + + # Verify that we found the containing symbol + if containing_symbol: + assert "create_user" in containing_symbol["name"] + assert containing_symbol["kind"] == SymbolKind.Method or containing_symbol["kind"] == SymbolKind.Function + if "body" in containing_symbol: + assert "create_user" in containing_symbol["body"].get_text() + + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_request_containing_symbol_module(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol for a module.""" + # Test for a position inside the models module but outside any function + file_path = os.path.join("src", "models.erl") + + # Find the module definition + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + module_line = None + for i, line in enumerate(lines): + if "-module(models)" in line: + module_line = i + 2 # Go inside the module + break + + if module_line is None: + pytest.skip("Could not find models module") + + containing_symbol = language_server.request_containing_symbol(file_path, module_line, 5) + + # Verify that we found the containing symbol + if containing_symbol: + assert "models" in containing_symbol["name"] or "module" in containing_symbol["name"].lower() + assert containing_symbol["kind"] == SymbolKind.Module or containing_symbol["kind"] == SymbolKind.Class + + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_request_containing_symbol_nested(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol with nested scopes.""" + # Test for a position inside a function which is inside a module + file_path = os.path.join("src", "models.erl") + + # Find a function inside models module + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + function_body_line = None + for i, line in enumerate(lines): + if "create_user(" in line and "-spec" not in line: + # Go deeper into the function body where there might be case expressions + for j in range(i + 1, min(i + 10, len(lines))): + if lines[j].strip() and not lines[j].strip().startswith("%"): + function_body_line = j + break + break + + if function_body_line is None: + pytest.skip("Could not find function body") + + containing_symbol = language_server.request_containing_symbol(file_path, function_body_line, 15) + + # Verify that we found the innermost containing symbol (the function) + if containing_symbol: + expected_names = ["create_user", "models"] + assert any(name in containing_symbol["name"] for name in expected_names) + + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_request_containing_symbol_none(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol for a position with no containing symbol.""" + # Test for a position outside any function/module (e.g., in comments) + file_path = os.path.join("src", "models.erl") + # Line 1-2 are likely module declaration or comments + containing_symbol = language_server.request_containing_symbol(file_path, 2, 10) + + # Should return None or an empty dictionary, or the top-level module + # This is acceptable behavior for module-level positions + assert containing_symbol is None or containing_symbol == {} or "models" in str(containing_symbol) + + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_request_referencing_symbols_record(self, language_server: SolidLanguageServer) -> None: + """Test request_referencing_symbols for a record.""" + # Test referencing symbols for user record + file_path = os.path.join("include", "records.hrl") + + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + user_symbol = None + for symbol_group in symbols: + user_symbol = next((s for s in symbol_group if "user" in s.get("name", "")), None) + if user_symbol: + break + + if not user_symbol or "selectionRange" not in user_symbol: + pytest.skip("User record symbol or its selectionRange not found") + + sel_start = user_symbol["selectionRange"]["start"] + ref_symbols = [ + ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) + ] + + if ref_symbols: + models_references = [ + symbol + for symbol in ref_symbols + if "location" in symbol and "uri" in symbol["location"] and "models.erl" in symbol["location"]["uri"] + ] + # We expect some references from models.erl + assert len(models_references) >= 0 # At least attempt to find references + + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_request_referencing_symbols_function(self, language_server: SolidLanguageServer) -> None: + """Test request_referencing_symbols for a function.""" + # Test referencing symbols for create_user function + file_path = os.path.join("src", "models.erl") + + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + create_user_symbol = None + for symbol_group in symbols: + create_user_symbol = next((s for s in symbol_group if "create_user" in s.get("name", "")), None) + if create_user_symbol: + break + + if not create_user_symbol or "selectionRange" not in create_user_symbol: + pytest.skip("create_user function symbol or its selectionRange not found") + + sel_start = create_user_symbol["selectionRange"]["start"] + ref_symbols = [ + ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) + ] + + if ref_symbols: + # We might find references from services.erl or test files + service_references = [ + symbol + for symbol in ref_symbols + if "location" in symbol + and "uri" in symbol["location"] + and ("services.erl" in symbol["location"]["uri"] or "test" in symbol["location"]["uri"]) + ] + assert len(service_references) >= 0 # At least attempt to find references + + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_request_referencing_symbols_none(self, language_server: SolidLanguageServer) -> None: + """Test request_referencing_symbols for a position with no symbol.""" + file_path = os.path.join("src", "models.erl") + # Line 3 is likely a blank line or comment + try: + ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, 3, 0)] + # If we get here, make sure we got an empty result + assert ref_symbols == [] or ref_symbols is None + except Exception: + # The method might raise an exception for invalid positions + # which is acceptable behavior + pass + + # Tests for request_defining_symbol + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_request_defining_symbol_function_call(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for a function call.""" + # Find a place where models:create_user is called in services.erl + file_path = os.path.join("src", "services.erl") + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + models_call_line = None + for i, line in enumerate(lines): + if "models:create_user(" in line: + models_call_line = i + break + + if models_call_line is None: + pytest.skip("Could not find models:create_user call") + + # Try to find the definition of models:create_user + defining_symbol = language_server.request_defining_symbol(file_path, models_call_line, 20) + + if defining_symbol: + assert "create_user" in defining_symbol.get("name", "") or "models" in defining_symbol.get("name", "") + if "location" in defining_symbol and "uri" in defining_symbol["location"]: + assert "models.erl" in defining_symbol["location"]["uri"] + + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_request_defining_symbol_record_usage(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for a record usage.""" + # Find a place where #user{} record is used in models.erl + file_path = os.path.join("src", "models.erl") + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + record_usage_line = None + for i, line in enumerate(lines): + if "#user{" in line: + record_usage_line = i + break + + if record_usage_line is None: + pytest.skip("Could not find #user{} record usage") + + defining_symbol = language_server.request_defining_symbol(file_path, record_usage_line, 10) + + if defining_symbol: + assert "user" in defining_symbol.get("name", "").lower() + if "location" in defining_symbol and "uri" in defining_symbol["location"]: + assert "records.hrl" in defining_symbol["location"]["uri"] + + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_request_defining_symbol_module_call(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for a module function call.""" + # Find a place where utils:validate_input is called + file_path = os.path.join("src", "models.erl") + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + utils_call_line = None + for i, line in enumerate(lines): + if "validate_email(" in line: + utils_call_line = i + break + + if utils_call_line is None: + pytest.skip("Could not find function call in models.erl") + + defining_symbol = language_server.request_defining_symbol(file_path, utils_call_line, 15) + + if defining_symbol: + assert "validate" in defining_symbol.get("name", "") or "email" in defining_symbol.get("name", "") + + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_request_defining_symbol_none(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for a position with no symbol.""" + # Test for a position with no symbol (e.g., whitespace or comment) + file_path = os.path.join("src", "models.erl") + # Line 3 is likely a blank line or comment + defining_symbol = language_server.request_defining_symbol(file_path, 3, 0) + + # Should return None or empty + assert defining_symbol is None or defining_symbol == {} + + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_symbol_methods_integration(self, language_server: SolidLanguageServer) -> None: + """Test integration between different symbol methods.""" + file_path = os.path.join("src", "models.erl") + + # Find create_user function definition + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + create_user_line = None + for i, line in enumerate(lines): + if "create_user(" in line and "-spec" not in line: + create_user_line = i + break + + if create_user_line is None: + pytest.skip("Could not find create_user function") + + # Test containing symbol + containing = language_server.request_containing_symbol(file_path, create_user_line + 2, 10) + + if containing: + # Test that we can find references to this symbol + if "location" in containing and "range" in containing["location"]: + start_pos = containing["location"]["range"]["start"] + refs = [ + ref.symbol for ref in language_server.request_referencing_symbols(file_path, start_pos["line"], start_pos["character"]) + ] + # We should find some references or none (both are valid outcomes) + assert isinstance(refs, list) + + @pytest.mark.timeout(60) # Add 60 second timeout + @pytest.mark.xfail( + reason="Known intermittent timeout issue in Erlang LS in CI environments. May pass locally but can timeout on slower CI systems.", + strict=False, + ) + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_symbol_tree_structure(self, language_server: SolidLanguageServer) -> None: + """Test that symbol tree structure is correctly built.""" + symbol_tree = language_server.request_full_symbol_tree() + + # Should get a tree structure + assert len(symbol_tree) > 0 + + # Should have our test repository structure + root = symbol_tree[0] + assert "children" in root + + # Look for src directory + src_dir = None + for child in root["children"]: + if child["name"] == "src": + src_dir = child + break + + if src_dir: + # Check for our Erlang modules + file_names = [child["name"] for child in src_dir.get("children", [])] + expected_modules = ["models", "services", "utils", "app"] + found_modules = [name for name in expected_modules if any(name in fname for fname in file_names)] + assert len(found_modules) > 0, f"Expected to find some modules from {expected_modules}, but got {file_names}" + + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_request_dir_overview(self, language_server: SolidLanguageServer) -> None: + """Test request_dir_overview functionality.""" + src_overview = language_server.request_dir_overview("src") + + # Should get an overview of the src directory + assert src_overview is not None + overview_keys = list(src_overview.keys()) if hasattr(src_overview, "keys") else [] + src_files = [key for key in overview_keys if key.startswith("src/") or "src" in key] + assert len(src_files) > 0, f"Expected to find src/ files in overview keys: {overview_keys}" + + # Should contain information about our modules + overview_text = str(src_overview).lower() + expected_terms = ["models", "services", "user", "create_user", "gen_server"] + found_terms = [term for term in expected_terms if term in overview_text] + assert len(found_terms) > 0, f"Expected to find some terms from {expected_terms} in overview" + + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_containing_symbol_of_record_field(self, language_server: SolidLanguageServer) -> None: + """Test containing symbol for record field access.""" + file_path = os.path.join("src", "models.erl") + + # Find a record field access like User#user.name + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + record_field_line = None + for i, line in enumerate(lines): + if "#user{" in line and ("name" in line or "email" in line or "id" in line): + record_field_line = i + break + + if record_field_line is None: + pytest.skip("Could not find record field access") + + containing_symbol = language_server.request_containing_symbol(file_path, record_field_line, 10) + + if containing_symbol: + # Should be contained within a function + assert "name" in containing_symbol + expected_names = ["create_user", "update_user", "format_user_info"] + assert any(name in containing_symbol["name"] for name in expected_names) + + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_containing_symbol_of_spec(self, language_server: SolidLanguageServer) -> None: + """Test containing symbol for function specs.""" + file_path = os.path.join("src", "models.erl") + + # Find a -spec directive + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + spec_line = None + for i, line in enumerate(lines): + if line.strip().startswith("-spec") and "create_user" in line: + spec_line = i + break + + if spec_line is None: + pytest.skip("Could not find -spec directive") + + containing_symbol = language_server.request_containing_symbol(file_path, spec_line, 5) + + if containing_symbol: + # Should be contained within the module or the function it specifies + assert "name" in containing_symbol + expected_names = ["models", "create_user"] + assert any(name in containing_symbol["name"] for name in expected_names) + + @pytest.mark.timeout(60) # Add 60 second timeout + @pytest.mark.xfail( + reason="Known intermittent timeout issue in Erlang LS in CI environments. " + "May pass locally but can timeout on slower CI systems, especially macOS. " + "Similar to known Next LS timeout issues.", + strict=False, + ) + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_referencing_symbols_across_files(self, language_server: SolidLanguageServer) -> None: + """Test finding references across different files.""" + # Test that we can find references to models module functions in services.erl + file_path = os.path.join("src", "models.erl") + + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + create_user_symbol = None + for symbol_group in symbols: + create_user_symbol = next((s for s in symbol_group if "create_user" in s.get("name", "")), None) + if create_user_symbol: + break + + if not create_user_symbol or "selectionRange" not in create_user_symbol: + pytest.skip("create_user function symbol not found") + + sel_start = create_user_symbol["selectionRange"]["start"] + ref_symbols = [ + ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) + ] + + # Look for cross-file references + cross_file_refs = [ + symbol + for symbol in ref_symbols + if "location" in symbol and "uri" in symbol["location"] and not symbol["location"]["uri"].endswith("models.erl") + ] + + # We might find references in services.erl or test files + if cross_file_refs: + assert len(cross_file_refs) > 0, "Should find some cross-file references" diff --git a/test/solidlsp/fortran/__init__.py b/test/solidlsp/fortran/__init__.py new file mode 100644 index 0000000..2ce5142 --- /dev/null +++ b/test/solidlsp/fortran/__init__.py @@ -0,0 +1 @@ +# Fortran language server tests diff --git a/test/solidlsp/fortran/test_fortran_basic.py b/test/solidlsp/fortran/test_fortran_basic.py new file mode 100644 index 0000000..9a73ece --- /dev/null +++ b/test/solidlsp/fortran/test_fortran_basic.py @@ -0,0 +1,316 @@ +""" +Basic tests for Fortran language server integration. + +These tests validate some low-level LSP functionality and high-level Serena APIs. +Note: These tests require fortls to be installed: pip install fortls +""" + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from solidlsp.ls_utils import SymbolUtils +from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + +# Mark all tests in this module as fortran tests +pytestmark = pytest.mark.fortran + + +class TestFortranLanguageServer: + """Test Fortran language server functionality.""" + + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + """Test finding symbols using request_full_symbol_tree.""" + symbols = language_server.request_full_symbol_tree() + + # Verify program symbol + assert SymbolUtils.symbol_tree_contains_name(symbols, "test_program"), "test_program not found in symbol tree" + + # Verify module symbol + assert SymbolUtils.symbol_tree_contains_name(symbols, "math_utils"), "math_utils module not found in symbol tree" + + # Verify function symbols + assert SymbolUtils.symbol_tree_contains_name(symbols, "add_numbers"), "add_numbers function not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "multiply_numbers"), "multiply_numbers function not found in symbol tree" + + # Verify subroutine symbol + assert SymbolUtils.symbol_tree_contains_name(symbols, "print_result"), "print_result subroutine not found in symbol tree" + + if language_has_verified_implementation_support(Language.FORTRAN): + + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.FORTRAN) + pos = find_identifier_position(repo_path / "modules" / "geometry.f90", "distance") + assert pos is not None, "Could not find interface distance in geometry.f90" + + implementations = language_server.request_implementation("modules/geometry.f90", *pos) + assert implementations, "Expected implementations for geometry_types.distance" + implementation_files = {implementation.get("relativePath", "") for implementation in implementations} + assert implementation_files == {"modules/geometry.f90"}, f"Unexpected implementation locations: {implementations}" + assert len(implementations) >= 2, f"Expected module procedure implementations, got: {implementations}" + + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.FORTRAN) + pos = find_identifier_position(repo_path / "modules" / "geometry.f90", "distance") + assert pos is not None, "Could not find interface distance in geometry.f90" + + implementing_symbols = language_server.request_implementing_symbols("modules/geometry.f90", *pos) + assert implementing_symbols, "Expected implementing symbols for geometry_types.distance" + implementing_symbol_names = {symbol.get("name") for symbol in implementing_symbols} + assert {"distance_2d", "distance_3d"}.issubset(implementing_symbol_names), ( + f"Expected distance_2d and distance_3d, got: {implementing_symbols}" + ) + + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) + def test_request_document_symbols(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols can be retrieved from Fortran files.""" + # Test main.f90 - should have a program symbol + main_symbols, _ = language_server.request_document_symbols("main.f90").get_all_symbols_and_roots() + program_names = [s.get("name") for s in main_symbols] + assert "test_program" in program_names, f"Program 'test_program' not found in main.f90. Found: {program_names}" + + # Test modules/math_utils.f90 - should have module and function symbols + module_symbols, _ = language_server.request_document_symbols("modules/math_utils.f90").get_all_symbols_and_roots() + all_names = [s.get("name") for s in module_symbols] + assert "math_utils" in all_names, f"Module 'math_utils' not found. Found: {all_names}" + assert "add_numbers" in all_names, f"Function 'add_numbers' not found. Found: {all_names}" + assert "multiply_numbers" in all_names, f"Function 'multiply_numbers' not found. Found: {all_names}" + assert "print_result" in all_names, f"Subroutine 'print_result' not found. Found: {all_names}" + + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) + def test_find_references_cross_file(self, language_server: SolidLanguageServer) -> None: + """Test finding references across files using low-level request_references. + + This tests the LSP textDocument/references capability. + """ + file_path = "modules/math_utils.f90" + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + # Find the add_numbers function + add_numbers_symbol = None + for sym in symbols[0]: + if sym.get("name") == "add_numbers": + add_numbers_symbol = sym + break + + assert add_numbers_symbol is not None, "Could not find 'add_numbers' function symbol in math_utils.f90" + + # Use selectionRange to query for references + # Note: FortranLanguageServer automatically fixes fortls's incorrect selectionRange + sel_start = add_numbers_symbol["selectionRange"]["start"] + + # Query from the function name position using corrected selectionRange + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + + # Should find references (usage in main.f90 + definition in math_utils.f90) + assert len(refs) > 0, "Should find references to add_numbers function" + + # Verify that main.f90 references the function + main_refs = [ref for ref in refs if "main.f90" in ref.get("relativePath", "")] + assert len(main_refs) > 0, ( + f"Expected to find reference in main.f90, but found references in: {[ref.get('relativePath') for ref in refs]}" + ) + + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) + def test_find_definition_cross_file(self, language_server: SolidLanguageServer) -> None: + """Test finding definition across files using request_definition.""" + # In main.f90, line 7 (0-indexed: line 6) contains: result = add_numbers(5.0, 3.0) + # We want to find the definition of add_numbers in modules/math_utils.f90 + main_file = "main.f90" + + # Position on 'add_numbers' usage (approximately column 13) + definition_location_list = language_server.request_definition(main_file, 6, 13) + + if not definition_location_list: + pytest.skip("fortls does not support cross-file go-to-definition for this case") + + assert len(definition_location_list) >= 1, "Should find at least one definition" + definition_location = definition_location_list[0] + + # The definition should be in modules/math_utils.f90 + assert "math_utils.f90" in definition_location.get("uri", ""), ( + f"Expected definition to be in math_utils.f90, but found in: {definition_location.get('uri')}" + ) + + # Verify the definition is around the correct line (line 4, 0-indexed) + assert definition_location["range"]["start"]["line"] == 4, ( + f"Expected definition at line 4, but found at line {definition_location['range']['start']['line']}" + ) + + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) + def test_request_referencing_symbols(self, language_server: SolidLanguageServer) -> None: + """Test finding symbols that reference a function - Serena's high-level API. + + This tests request_referencing_symbols which returns not just locations but also + the containing symbols that have the references. This is different from + test_find_references_cross_file which only returns locations. + + Note: FortranLanguageServer automatically fixes fortls's incorrect selectionRange. + """ + # Get the add_numbers function symbol from math_utils.f90 + file_path = "modules/math_utils.f90" + symbols, _ = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + # Find the add_numbers function + add_numbers_symbol = None + for sym in symbols: + if sym.get("name") == "add_numbers": + add_numbers_symbol = sym + break + + assert add_numbers_symbol is not None, "Could not find 'add_numbers' function symbol" + + # Use selectionRange to query for referencing symbols + # FortranLanguageServer automatically corrects fortls's incorrect selectionRange + sel_start = add_numbers_symbol["selectionRange"]["start"] + referencing_symbols = language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) + + # Should find referencing symbols (not just locations, but symbols containing the references) + assert len(referencing_symbols) > 0, "Should find referencing symbols when querying from function name position" + + # Extract the symbols from ReferenceInSymbol objects + # This is what makes this test different from test_find_references_cross_file: + # we're testing that we get back SYMBOLS (with name, kind, location) not just locations + ref_symbols = [ref.symbol for ref in referencing_symbols] + + # Verify we got valid symbol structures with all required fields + for symbol in ref_symbols: + assert "name" in symbol, f"Symbol should have a name: {symbol}" + assert "kind" in symbol, f"Symbol should have a kind: {symbol}" + # Each symbol should have location information + assert "location" in symbol, f"Symbol should have location: {symbol}" + + # Note: fortls may not return all cross-file references through request_referencing_symbols + # because it depends on finding containing symbols for each reference. We verify that + # the API works and returns valid symbols with proper structure. + + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) + def test_request_defining_symbol(self, language_server: SolidLanguageServer) -> None: + """Test finding the defining symbol - Serena's high-level API. + + This is similar to test_find_definition_cross_file but uses the high-level + request_defining_symbol which returns a full symbol with metadata, not just a location. + """ + # In main.f90, line 7 (0-indexed: line 6) contains: result = add_numbers(5.0, 3.0) + # We want to find the definition of add_numbers + main_file = "main.f90" + + # Get the position of add_numbers usage in main.f90 + # Position on 'add_numbers' (approximately column 13) + defining_symbol = language_server.request_defining_symbol(main_file, 6, 13) + + if defining_symbol is None: + pytest.skip("fortls does not support cross-file go-to-definition for this case") + + # Should find the add_numbers function with full symbol information + assert defining_symbol.get("name") == "add_numbers", f"Expected to find 'add_numbers' but got '{defining_symbol.get('name')}'" + + # Check if we have location information + if "location" not in defining_symbol or "relativePath" not in defining_symbol["location"]: + pytest.skip("fortls found the symbol but doesn't provide complete location information") + + # The definition should be in modules/math_utils.f90 + defining_path = defining_symbol["location"]["relativePath"] + assert "math_utils.f90" in defining_path, f"Expected definition to be in math_utils.f90, but found in: {defining_path}" + + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) + def test_request_containing_symbol(self, language_server: SolidLanguageServer) -> None: + """Test finding the containing symbol for a position in the code.""" + # Test finding the containing symbol for a position inside the add_numbers function + file_path = "modules/math_utils.f90" + + # Line 8 (0-indexed: line 7) is inside the add_numbers function: "sum = a + b" + containing_symbol = language_server.request_containing_symbol(file_path, 7, 10, include_body=False) + + if containing_symbol is None: + pytest.skip("fortls does not support request_containing_symbol or couldn't find the containing symbol") + + # Should find the add_numbers function as the containing symbol + assert containing_symbol.get("name") == "add_numbers", ( + f"Expected containing symbol 'add_numbers', got '{containing_symbol.get('name')}'" + ) + + # Verify the symbol kind is Function + assert containing_symbol.get("kind") == SymbolKind.Function.value, ( + f"Expected Function kind ({SymbolKind.Function.value}), got {containing_symbol.get('kind')}" + ) + + # Verify location information exists + assert "location" in containing_symbol, "Containing symbol should have location information" + location = containing_symbol["location"] + assert "range" in location, "Location should contain range information" + assert "start" in location["range"] and "end" in location["range"], "Range should have start and end positions" + + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) + def test_type_and_interface_symbols(self, language_server: SolidLanguageServer) -> None: + """Test that type definitions and interfaces are properly recognized with corrected selectionRange. + + This verifies that the regex pattern correctly handles: + - Simple type definitions (type Name) + - Type with double colon (type :: Name) + - Type with extends (type, extends(Base) :: Derived) + - Named interfaces + + fortls returns these as SymbolKind.Class (11) for types and SymbolKind.Interface (5) for interfaces. + """ + file_path = "modules/geometry.f90" + symbols, _ = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + # Find type and interface symbols + type_names = [] + interface_names = [] + for sym in symbols: + if sym.get("kind") == SymbolKind.Class.value: # Type definitions + type_names.append(sym.get("name")) + elif sym.get("kind") == SymbolKind.Interface.value: # Interfaces + interface_names.append(sym.get("name")) + + # Verify type definitions are found + assert "Point2D" in type_names, f"Simple type 'Point2D' not found. Found types: {type_names}" + assert "Circle" in type_names, f"Type with :: syntax 'Circle' not found. Found types: {type_names}" + assert "Point3D" in type_names, f"Type with extends 'Point3D' not found. Found types: {type_names}" + + # Verify interface is found + assert "distance" in interface_names, f"Interface 'distance' not found. Found interfaces: {interface_names}" + + # Verify selectionRange is corrected for a type symbol + point3d_symbol = None + for sym in symbols: + if sym.get("name") == "Point3D": + point3d_symbol = sym + break + + assert point3d_symbol is not None, "Could not find 'Point3D' type symbol" + + # Use corrected selectionRange to find references + # This tests that the fix works for types (not just functions) + sel_start = point3d_symbol["selectionRange"]["start"] + + # Verify selectionRange points to identifier name, not line start + # Line for "type, extends(Point2D) :: Point3D" has Point3D at position > 0 + assert sel_start["character"] > 0, ( + f"selectionRange should point to identifier, not line start. Got character: {sel_start['character']}" + ) + + # Test that we can find references using the corrected position + _refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + # refs might be empty if Point3D isn't used elsewhere, but the call should not fail + # The important thing is that it doesn't error due to wrong character position + + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/fortran/test_fortran_diagnostics.py b/test/solidlsp/fortran/test_fortran_diagnostics.py new file mode 100644 index 0000000..f607cd0 --- /dev/null +++ b/test/solidlsp/fortran/test_fortran_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.fortran +class TestFortranDiagnostics: + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.f90", + (), + min_count=1, + ) diff --git a/test/solidlsp/fsharp/test_fsharp_basic.py b/test/solidlsp/fsharp/test_fsharp_basic.py new file mode 100644 index 0000000..37ed0bb --- /dev/null +++ b/test/solidlsp/fsharp/test_fsharp_basic.py @@ -0,0 +1,220 @@ +import os +import threading +from typing import Any + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.conftest import ( + find_identifier_position, + get_repo_path, + language_has_verified_implementation_support, + language_tests_enabled, +) +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics + +# Currently, most F# tests fail (regression/instability), so the suite is disabled on CI. +pytestmark = [pytest.mark.fsharp, pytest.mark.skipif(not language_tests_enabled(Language.FSHARP), reason="F# tests are disabled")] + + +class TestFSharpLanguageServer: + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + """Test finding symbols in the full symbol tree.""" + symbols = language_server.request_full_symbol_tree() + + # Check for main program module symbols + assert SymbolUtils.symbol_tree_contains_name(symbols, "Program"), "Program module not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main function not found in symbol tree" + + # Check for Calculator module symbols + assert SymbolUtils.symbol_tree_contains_name(symbols, "Calculator"), "Calculator module not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add function not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "CalculatorClass"), "CalculatorClass not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_get_document_symbols_program(self, language_server: SolidLanguageServer) -> None: + """Test getting document symbols from the main Program.fs file.""" + file_path = os.path.join("Program.fs") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()[0] + + # Look for expected functions and modules + symbol_names = [s.get("name") for s in symbols] + assert "main" in symbol_names, "main function not found in Program.fs symbols" + + if language_has_verified_implementation_support(Language.FSHARP): + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.FSHARP) + pos = find_identifier_position(repo_path / "Formatter.fs", "FormatGreeting") + assert pos is not None, "Could not find IGreeter.FormatGreeting in fixture" + + implementations = language_server.request_implementation("Formatter.fs", *pos) + assert implementations, "Expected at least one implementation of IGreeter.FormatGreeting" + assert any("Formatter.fs" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected ConsoleGreeter.FormatGreeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.FSHARP) + pos = find_identifier_position(repo_path / "Formatter.fs", "FormatGreeting") + assert pos is not None, "Could not find IGreeter.FormatGreeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols("Formatter.fs", *pos) + assert implementing_symbols, "Expected implementing symbols for IGreeter.FormatGreeting" + assert any( + symbol.get("name") == "FormatGreeting" and "Formatter.fs" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected ConsoleGreeter.FormatGreeting symbol, got: {implementing_symbols}" + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_get_document_symbols_calculator(self, language_server: SolidLanguageServer) -> None: + """Test getting document symbols from Calculator.fs file.""" + file_path = os.path.join("Calculator.fs") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()[0] + + # Look for expected functions + symbol_names = [s.get("name") for s in symbols] + expected_symbols = ["add", "subtract", "multiply", "divide", "square", "factorial", "CalculatorClass"] + + for expected in expected_symbols: + assert expected in symbol_names, f"{expected} function not found in Calculator.fs symbols" + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: + """Test finding references using symbol selection range.""" + file_path = os.path.join("Calculator.fs") + symbols = language_server.request_document_symbols(file_path) + + # Find the 'add' function symbol + add_symbol = None + + for sym in symbols.iter_symbols(): + if sym.get("name") == "add": + add_symbol = sym + break + + assert add_symbol is not None, "Could not find 'add' function symbol in Calculator.fs" + + # Try to find references to the add function + sel_start = add_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1) + + # The add function should be referenced in Program.fs + assert any("Program.fs" in ref.get("relativePath", "") for ref in refs), "Program.fs should reference add function" + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_nested_module_symbols(self, language_server: SolidLanguageServer) -> None: + """Test getting symbols from nested Models namespace.""" + file_path = os.path.join("Models", "Person.fs") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()[0] + + # Check for expected types and modules + symbol_names = [s.get("name") for s in symbols] + expected_symbols = ["Person", "PersonModule", "Address", "Employee"] + + for expected in expected_symbols: + assert expected in symbol_names, f"{expected} not found in Person.fs symbols" + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_find_referencing_symbols_across_files(self, language_server: SolidLanguageServer) -> None: + """Test finding references to Calculator functions across files.""" + # Find the subtract function in Calculator.fs + file_path = os.path.join("Calculator.fs") + symbols = language_server.request_document_symbols(file_path) + + subtract_symbol = None + for sym in symbols.iter_symbols(): + if sym.get("name") == "subtract": + subtract_symbol = sym + break + + assert subtract_symbol is not None, "Could not find 'subtract' function symbol" + + # Find references to subtract function + sel_start = subtract_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1) + + # The subtract function should be referenced in Program.fs + assert any("Program.fs" in ref.get("relativePath", "") for ref in refs), "Program.fs should reference subtract function" + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_go_to_definition(self, language_server: SolidLanguageServer) -> None: + """Test go-to-definition functionality.""" + # Test going to definition of 'add' function from Program.fs + program_file = os.path.join("Program.fs") + + # Try to find definition of 'add' function used in Program.fs + # This would typically be at the line where 'add 5 3' is called + definitions = language_server.request_definition(program_file, 10, 20) # Approximate position + + # We should get at least some definitions + assert len(definitions) >= 0, "Should get definitions (even if empty for complex cases)" + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_hover_information(self, language_server: SolidLanguageServer) -> None: + """Test hover information functionality.""" + file_path = os.path.join("Calculator.fs") + + # Try to get hover information for a function + hover_info = language_server.request_hover(file_path, 5, 10) # Approximate position of a function + + # Hover info might be None or contain information + # This is acceptable as it depends on the LSP server's capabilities and timing + assert hover_info is None or isinstance(hover_info, dict), "Hover info should be None or dict" + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_completion(self, language_server: SolidLanguageServer) -> None: + """Test code completion functionality.""" + file_path = os.path.join("Program.fs") + + # Use threading for cross-platform timeout (signal.SIGALRM is Unix-only) + result: dict[str, Any] = dict(value=None) + exception: dict[str, Any] = dict(value=None) + + def run_completion(): + try: + result["value"] = language_server.request_completions(file_path, 15, 10) + except Exception as e: + exception["value"] = e + + thread = threading.Thread(target=run_completion, daemon=True) + thread.start() + thread.join(timeout=5) # 5 second timeout + + if thread.is_alive(): + # Completion timed out, but this is acceptable for F# in some cases + # The important thing is that the language server doesn't crash + return + + if exception["value"]: + raise exception["value"] + + assert isinstance(result["value"], list), "Completions should be a list" + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "DiagnosticsSample.fs", + (), + min_count=1, + ) + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/go/test_go_basic.py b/test/solidlsp/go/test_go_basic.py new file mode 100644 index 0000000..347f602 --- /dev/null +++ b/test/solidlsp/go/test_go_basic.py @@ -0,0 +1,251 @@ +import os +from pathlib import Path + +import pytest + +from serena.symbol import LanguageServerSymbol +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.go +class TestGoLanguageServer: + @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main function not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Helper"), "Helper function not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "DemoStruct"), "DemoStruct not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) + def test_find_symbol_matches_go_method_by_bare_name(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree(within_relative_path="main.go") + + assert SymbolUtils.symbol_tree_contains_name(symbols, "Value"), "Expected Go method name to be normalized to bare name" + assert not SymbolUtils.symbol_tree_contains_name(symbols, "(*DemoStruct).Value"), ( + "Expected receiver-qualified Go method name to be normalized away" + ) + + bare_name_matches = [match for root in symbols for match in LanguageServerSymbol(root).find("Value")] + + assert bare_name_matches, "Expected a Go method to match by bare name" + assert all(match.name == "Value" for match in bare_name_matches) + + @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) + def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("main.go") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + helper_symbol = None + for sym in symbols[0]: + if sym.get("name") == "Helper": + helper_symbol = sym + break + assert helper_symbol is not None, "Could not find 'Helper' function symbol in main.go" + sel_start = helper_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert any("main.go" in ref.get("uri", "") for ref in refs), "Expected at least one reference result to point at main.go" + + if language_has_verified_implementation_support(Language.GO): + + @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.GO) + pos = find_identifier_position(repo_path / "main.go", "FormatGreeting") + assert pos is not None, "Could not find Greeter.FormatGreeting in fixture" + + implementations = language_server.request_implementation("main.go", *pos) + assert implementations, "Expected at least one implementation of Greeter.FormatGreeting" + assert any("main.go" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected ConsoleGreeter.FormatGreeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.GO) + pos = find_identifier_position(repo_path / "main.go", "FormatGreeting") + assert pos is not None, "Could not find Greeter.FormatGreeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols("main.go", *pos) + assert implementing_symbols, "Expected implementing symbols for Greeter.FormatGreeting" + assert any( + symbol.get("name") == "FormatGreeting" and "main.go" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected FormatGreeting symbol, got: {implementing_symbols}" + + +def _filter_symbols_by_name_in_repo(symbols: list | None, target_name: str, repo_name: str = "test_repo") -> list: + """Filter workspace symbols to exact name matches in the test repo.""" + if symbols is None: + return [] + return [s for s in symbols if s.get("name") == target_name and repo_name in s.get("location", {}).get("uri", "")] + + +@pytest.mark.go +class TestGoBuildTags: + """Tests for Go build tag/constraint support.""" + + def _copy_go_fixture(self, tmp_path: Path) -> Path: + """Copy Go fixture repo into tmp_path.""" + import shutil + + from test.conftest import get_repo_path + + fixture_path = get_repo_path(Language.GO) + target_path = tmp_path / "test_repo" + + shutil.copytree(fixture_path, target_path) + return target_path + + def test_default_context_contains_xnotfoo(self, tmp_path: Path) -> None: + """Default build context should contain XNotFoo and not XFoo.""" + from test.conftest import start_ls_context + + repo_path = self._copy_go_fixture(tmp_path) + + with start_ls_context(Language.GO, repo_path=str(repo_path), solidlsp_dir=tmp_path) as ls: + xnotfoo_symbols = ls.request_workspace_symbol("XNotFoo") + xfoo_symbols = ls.request_workspace_symbol("XFoo") + + xnotfoo_matches = _filter_symbols_by_name_in_repo(xnotfoo_symbols, "XNotFoo") + xfoo_matches = _filter_symbols_by_name_in_repo(xfoo_symbols, "XFoo") + + assert len(xnotfoo_matches) > 0, "Default context should contain XNotFoo" + assert len(xfoo_matches) == 0, "Default context should NOT contain XFoo" + + def test_foo_context_contains_xfoo(self, tmp_path: Path) -> None: + """Build context with -tags=foo should contain XFoo and not XNotFoo.""" + from test.conftest import start_ls_context + + repo_path = self._copy_go_fixture(tmp_path) + + ls_settings = { + Language.GO: { + "gopls_settings": { + "buildFlags": ["-tags=foo"], + }, + }, + } + + with start_ls_context(Language.GO, repo_path=str(repo_path), ls_specific_settings=ls_settings, solidlsp_dir=tmp_path) as ls: + xfoo_symbols = ls.request_workspace_symbol("XFoo") + xnotfoo_symbols = ls.request_workspace_symbol("XNotFoo") + + xfoo_matches = _filter_symbols_by_name_in_repo(xfoo_symbols, "XFoo") + xnotfoo_matches = _filter_symbols_by_name_in_repo(xnotfoo_symbols, "XNotFoo") + + assert len(xfoo_matches) > 0, "Foo context should contain XFoo" + assert len(xnotfoo_matches) == 0, "Foo context should NOT contain XNotFoo" + + def test_disk_cache_is_invalidated_on_build_context_switch(self, tmp_path: Path) -> None: + """Go build context switches must not reuse persisted SolidLSP document-symbol caches.""" + import pickle + + from test.conftest import start_ls_context + + repo_path = self._copy_go_fixture(tmp_path) + + ls_settings_foo = { + Language.GO: { + "gopls_settings": { + "buildFlags": ["-tags=foo"], + }, + }, + } + + main_go = os.path.join("main.go") + + def _assert_caches_loaded_and_clean(ls: SolidLanguageServer) -> None: + # White-box assertions: SolidLanguageServer currently has no public API to verify that + # caches were loaded from disk vs created lazily on first request. + assert ls._raw_document_symbols_cache, "Expected raw document-symbol cache to load from disk" + assert ls._document_symbols_cache, "Expected document-symbol cache to load from disk" + assert not ls._raw_document_symbols_cache_is_modified + assert not ls._document_symbols_cache_is_modified + + def _assert_caches_empty(ls: SolidLanguageServer) -> None: + assert ls._raw_document_symbols_cache == {} + assert ls._document_symbols_cache == {} + + def _assert_caches_modified(ls: SolidLanguageServer) -> None: + assert ls._raw_document_symbols_cache_is_modified + assert ls._document_symbols_cache_is_modified + + # Run 1 (default context): populate caches and persist them to disk. + with start_ls_context(Language.GO, repo_path=str(repo_path), solidlsp_dir=tmp_path) as ls_default: + _ = ls_default.request_document_symbols(main_go) + + default_raw_cache_version = ls_default._raw_document_symbols_cache_version() + default_doc_cache_version = ls_default._document_symbols_cache_version() + + ls_default.save_cache() + cache_dir = ls_default.cache_dir + + cache_files = [p for p in cache_dir.rglob("*") if p.is_file()] + assert cache_files, f"Expected SolidLSP to create cache artifacts under {cache_dir}" + + versioned_cache_files: list[tuple[Path, object]] = [] + for p in cache_files: + try: + with p.open("rb") as f: + data = pickle.load(f) + except Exception: + continue + if isinstance(data, dict) and "__cache_version" in data: + versioned_cache_files.append((p, data["__cache_version"])) + + assert versioned_cache_files, f"Expected at least one SolidLSP cache file with a __cache_version under {cache_dir}" + saved_versions = {v for _, v in versioned_cache_files} + assert default_raw_cache_version in saved_versions or default_doc_cache_version in saved_versions, ( + "Expected at least one persisted cache to match the default-context cache version" + ) + + # Run 2 (default context again): prove that persisted caches are actually loaded and used. + with start_ls_context(Language.GO, repo_path=str(repo_path), solidlsp_dir=tmp_path) as ls_default_again: + assert ls_default_again.cache_dir == cache_dir + + _assert_caches_loaded_and_clean(ls_default_again) + + _ = ls_default_again.request_document_symbols(main_go) + + # A cache hit should not mark caches as modified. + assert not ls_default_again._raw_document_symbols_cache_is_modified + assert not ls_default_again._document_symbols_cache_is_modified + + # Run 3 (foo context): the same on-disk cache directory exists, but MUST be treated as stale. + with start_ls_context( + Language.GO, + repo_path=str(repo_path), + ls_specific_settings=ls_settings_foo, + solidlsp_dir=tmp_path, + ) as ls_foo: + assert ls_foo.cache_dir == cache_dir + + foo_raw_cache_version = ls_foo._raw_document_symbols_cache_version() + foo_doc_cache_version = ls_foo._document_symbols_cache_version() + + assert foo_raw_cache_version != default_raw_cache_version + assert foo_doc_cache_version != default_doc_cache_version + + # Different build context => persisted caches must not be loaded. + _assert_caches_empty(ls_foo) + + _ = ls_foo.request_document_symbols(main_go) + + # A cache miss should repopulate and mark caches modified. + _assert_caches_modified(ls_foo) + + @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/go/test_go_diagnostics.py b/test/solidlsp/go/test_go_diagnostics.py new file mode 100644 index 0000000..00b4cc4 --- /dev/null +++ b/test/solidlsp/go/test_go_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.go +class TestGoDiagnostics: + @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.go", + ("missingGreeting", "missingConsumerValue"), + min_count=2, + ) diff --git a/test/solidlsp/groovy/test_groovy_basic.py b/test/solidlsp/groovy/test_groovy_basic.py new file mode 100644 index 0000000..0637e3d --- /dev/null +++ b/test/solidlsp/groovy/test_groovy_basic.py @@ -0,0 +1,129 @@ +import os +from pathlib import Path + +import pytest + +from serena.constants import SERENA_MANAGED_DIR_NAME +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_utils import SymbolUtils +from solidlsp.settings import SolidLSPSettings +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.groovy +class TestGroovyLanguageServer: + language_server: SolidLanguageServer | None = None + test_repo_path: Path = Path(__file__).parent.parent.parent / "resources" / "repos" / "groovy" / "test_repo" + + @classmethod + def setup_class(cls): + """ + Set up test class with Groovy test repository. + """ + if not cls.test_repo_path.exists(): + pytest.skip("Groovy test repository not found") + + # Use JAR path from environment variable + ls_jar_path = os.environ.get("GROOVY_LS_JAR_PATH") + if not ls_jar_path or not os.path.exists(ls_jar_path): + pytest.skip( + "Groovy Language Server JAR not found. Set GROOVY_LS_JAR_PATH environment variable to run tests.", + allow_module_level=True, + ) + + # Get JAR options from environment variable + ls_jar_options = os.environ.get("GROOVY_LS_JAR_OPTIONS", "") + ls_java_home_path = os.environ.get("GROOVY_LS_JAVA_HOME_PATH") + + groovy_settings = {"ls_jar_path": ls_jar_path, "ls_jar_options": ls_jar_options} + if ls_java_home_path: + groovy_settings["ls_java_home_path"] = ls_java_home_path + + # Create language server directly with Groovy-specific settings + repo_path = str(cls.test_repo_path) + config = LanguageServerConfig(code_language=Language.GROOVY, ignored_paths=[], trace_lsp_communication=False) + + project_data_path = os.path.join(repo_path, SERENA_MANAGED_DIR_NAME) + solidlsp_settings = SolidLSPSettings( + solidlsp_dir=str(Path.home() / ".serena"), + project_data_path=project_data_path, + ls_specific_settings={Language.GROOVY: groovy_settings}, + ) + + cls.language_server = SolidLanguageServer.create(config, repo_path, solidlsp_settings=solidlsp_settings) + cls.language_server.start() + + @classmethod + def teardown_class(cls): + """ + Clean up language server. + """ + if cls.language_server is not None: + cls.language_server.stop() + + def test_find_symbol(self) -> None: + assert self.language_server is not None + symbols = self.language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main class not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils class not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model class not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "ModelUser"), "ModelUser class not found in symbol tree" + + def test_find_referencing_class_symbols(self) -> None: + assert self.language_server is not None + file_path = os.path.join("src", "main", "groovy", "com", "example", "Utils.groovy") + refs = self.language_server.request_references(file_path, 3, 6) + assert any("Main.groovy" in ref.get("relativePath", "") for ref in refs), "Utils should be referenced from Main.groovy" + + file_path = os.path.join("src", "main", "groovy", "com", "example", "Model.groovy") + symbols = self.language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + model_symbol = None + for sym in symbols[0]: + if sym.get("name") == "com.example.Model" and sym.get("kind") == 5: + model_symbol = sym + break + assert model_symbol is not None, "Could not find 'Model' class symbol in Model.groovy" + + if "selectionRange" in model_symbol: + sel_start = model_symbol["selectionRange"]["start"] + else: + sel_start = model_symbol["range"]["start"] + refs = self.language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + + main_refs = [ref for ref in refs if "Main.groovy" in ref.get("relativePath", "")] + assert len(main_refs) >= 2, f"Model should be referenced from Main.groovy at least 2 times, found {len(main_refs)}" + + model_user_refs = [ref for ref in refs if "ModelUser.groovy" in ref.get("relativePath", "")] + assert len(model_user_refs) >= 1, f"Model should be referenced from ModelUser.groovy at least 1 time, found {len(model_user_refs)}" + + def test_overview_methods(self) -> None: + assert self.language_server is not None + symbols = self.language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main missing from overview" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils missing from overview" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model missing from overview" + assert SymbolUtils.symbol_tree_contains_name(symbols, "ModelUser"), "ModelUser missing from overview" + + def test_bare_symbol_names(self) -> None: + assert self.language_server is not None + all_symbols = request_all_symbols(self.language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) + + def test_file_diagnostics(self) -> None: + assert self.language_server is not None + assert_file_diagnostics( + self.language_server, + "src/main/groovy/com/example/DiagnosticsSample.groovy", + (), + min_count=1, + ) diff --git a/test/solidlsp/haskell/__init__.py b/test/solidlsp/haskell/__init__.py new file mode 100644 index 0000000..760bf8f --- /dev/null +++ b/test/solidlsp/haskell/__init__.py @@ -0,0 +1 @@ +# Haskell language server tests diff --git a/test/solidlsp/haskell/test_haskell_basic.py b/test/solidlsp/haskell/test_haskell_basic.py new file mode 100644 index 0000000..cf4bba3 --- /dev/null +++ b/test/solidlsp/haskell/test_haskell_basic.py @@ -0,0 +1,252 @@ +""" +Rigorous tests for Haskell Language Server integration with Serena. + +Tests prove that Serena's symbol tools can: +1. Discover all expected symbols with precise matching +2. Track cross-file references accurately +3. Identify data type structures and record fields +4. Navigate between definitions and usages + +Test Repository Structure: +- src/Calculator.hs: Calculator data type, arithmetic functions (add, subtract, multiply, divide, calculate) +- src/Helper.hs: Helper functions (validateNumber, isPositive, isNegative, absolute) +- app/Main.hs: Main entry point using Calculator and Helper modules +""" + +import pytest + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from test.conftest import language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics + +pytestmark = pytest.mark.skipif(not language_tests_enabled(Language.HASKELL), reason="Haskell tests are disabled (HLS not available)") + + +@pytest.mark.haskell +class TestHaskellLanguageServer: + @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) + def test_calculator_module_symbols(self, language_server: SolidLanguageServer): + """ + Test precise symbol discovery in Calculator.hs. + + Verifies that Serena can identify: + - Data type definition (Calculator with record fields) + - All exported functions with correct names + - Module structure + """ + all_symbols, _ = language_server.request_document_symbols("src/Calculator.hs").get_all_symbols_and_roots() + symbol_names = {s["name"] for s in all_symbols} + + # Verify exact set of expected top-level symbols + expected_symbols = { + "Calculator", # Data type + "add", # Function: Int -> Int -> Int + "subtract", # Function: Int -> Int -> Int + "multiply", # Function: Int -> Int -> Int + "divide", # Function: Int -> Int -> Maybe Int + "calculate", # Function: Calculator -> String -> Int -> Int -> Maybe Int + } + + # Verify all expected symbols are present + missing = expected_symbols - symbol_names + assert not missing, f"Missing expected symbols in Calculator.hs: {missing}" + + # Verify Calculator data type exists + calculator_symbol = next((s for s in all_symbols if s["name"] == "Calculator"), None) + assert calculator_symbol is not None, "Calculator data type not found" + + # The Calculator should be identified as a data type + # HLS may use different SymbolKind values (1=File, 5=Class, 23=Struct) + assert calculator_symbol["kind"] in [ + 1, + 5, + 23, + ], f"Calculator should be a data type (kind 1, 5, or 23), got kind {calculator_symbol['kind']}" + + @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) + def test_helper_module_symbols(self, language_server: SolidLanguageServer): + """ + Test precise symbol discovery in Helper.hs. + + Verifies Serena identifies all helper functions that are imported + and used by Calculator module. + """ + all_symbols, _ = language_server.request_document_symbols("src/Helper.hs").get_all_symbols_and_roots() + symbol_names = {s["name"] for s in all_symbols} + + # Verify expected helper functions (module name may also appear) + expected_symbols = { + "validateNumber", # Function used by Calculator.add and Calculator.subtract + "isPositive", # Predicate function + "isNegative", # Predicate function used by absolute + "absolute", # Function that uses isNegative + } + + # All expected symbols should be present (module name is optional) + missing = expected_symbols - symbol_names + assert not missing, f"Missing expected symbols in Helper.hs: {missing}" + + # Verify no unexpected symbols beyond the module name + extra = symbol_names - expected_symbols - {"Helper"} + assert not extra, f"Unexpected symbols in Helper.hs: {extra}" + + @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) + def test_main_module_imports(self, language_server: SolidLanguageServer): + """ + Test that Main.hs properly references both Calculator and Helper modules. + + Verifies Serena can identify cross-module dependencies. + """ + all_symbols, _ = language_server.request_document_symbols("app/Main.hs").get_all_symbols_and_roots() + symbol_names = {s["name"] for s in all_symbols} + + # Main.hs should have the main function + assert "main" in symbol_names, "Main.hs should contain 'main' function" + + @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) + def test_cross_file_references_validateNumber(self, language_server: SolidLanguageServer): + """ + Test cross-file reference tracking for validateNumber function. + + validateNumber is defined in Helper.hs:9 and used in: + - Calculator.hs:21 (in add function) + - Calculator.hs:25 (in subtract function) + + This proves Serena can track function usage across module boundaries. + """ + # Get references to validateNumber (defined at line 9, 0-indexed = line 8) + references = language_server.request_references("src/Helper.hs", line=8, column=0) + + # Should find at least: definition in Helper.hs + 2 usages in Calculator.hs + assert len(references) >= 2, f"Expected at least 2 references to validateNumber (used in add and subtract), got {len(references)}" + + # Verify we have references in Calculator.hs + reference_paths = [ref["relativePath"] for ref in references] + calculator_refs = [path for path in reference_paths if "Calculator.hs" in path] + + assert len(calculator_refs) >= 2, ( + f"Expected at least 2 references in Calculator.hs (add and subtract functions), " + f"got {len(calculator_refs)} references in Calculator.hs" + ) + + @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) + def test_within_file_references_isNegative(self, language_server: SolidLanguageServer): + """ + Test within-file reference tracking for isNegative function. + + isNegative is defined in Helper.hs:17 and used in Helper.hs:22 (absolute function). + This proves Serena can track intra-module function calls. + """ + # isNegative defined at line 17 (0-indexed = line 16) + references = language_server.request_references("src/Helper.hs", line=16, column=0) + + # Should find: definition + usage in absolute function + assert len(references) >= 1, f"Expected at least 1 reference to isNegative (used in absolute), got {len(references)}" + + # All references should be in Helper.hs + reference_paths = [ref["relativePath"] for ref in references] + assert all("Helper.hs" in path for path in reference_paths), ( + f"All isNegative references should be in Helper.hs, got: {reference_paths}" + ) + + @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) + def test_function_references_from_main(self, language_server: SolidLanguageServer): + """ + Test that functions used in Main.hs can be traced back to their definitions. + + Main.hs:12 calls 'add' from Calculator module. + Main.hs:25 calls 'isPositive' from Helper module. + Main.hs:26 calls 'absolute' from Helper module. + + This proves Serena can track cross-module function calls from executable code. + """ + # Test 'add' function references (defined in Calculator.hs:20, 0-indexed = line 19) + add_refs = language_server.request_references("src/Calculator.hs", line=19, column=0) + + # Should find references in Main.hs and possibly Calculator.hs (calculate function uses it) + assert len(add_refs) >= 1, f"Expected at least 1 reference to 'add', got {len(add_refs)}" + + add_ref_paths = [ref["relativePath"] for ref in add_refs] + # Should have at least one reference in Main.hs or Calculator.hs + assert any("Main.hs" in path or "Calculator.hs" in path for path in add_ref_paths), ( + f"Expected 'add' to be referenced in Main.hs or Calculator.hs, got: {add_ref_paths}" + ) + + @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) + def test_multiply_function_usage_in_calculate(self, language_server: SolidLanguageServer): + """ + Test that multiply function usage is tracked within Calculator module. + + multiply is defined in Calculator.hs:28 and used in: + - Calculator.hs:41 (in calculate function via pattern matching) + - Main.hs:20 (via calculate call with "multiply" operator) + + This proves Serena can track function references even when called indirectly. + """ + # multiply defined at line 28 (0-indexed = line 27) + multiply_refs = language_server.request_references("src/Calculator.hs", line=27, column=0) + + # Should find at least the usage in calculate function + assert len(multiply_refs) >= 1, f"Expected at least 1 reference to 'multiply', got {len(multiply_refs)}" + + # Should have reference in Calculator.hs (calculate function) + multiply_ref_paths = [ref["relativePath"] for ref in multiply_refs] + assert any("Calculator.hs" in path for path in multiply_ref_paths), ( + f"Expected 'multiply' to be referenced in Calculator.hs, got: {multiply_ref_paths}" + ) + + @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) + def test_data_type_constructor_references(self, language_server: SolidLanguageServer): + """ + Test that Calculator data type constructor usage is tracked. + + Calculator is defined in Calculator.hs:14 and used in: + - Main.hs:8 (constructor call: Calculator "TestCalc" 1) + - Calculator.hs:37 (type signature for calculate function) + + This proves Serena can track data type constructor references. + """ + # Calculator data type defined at line 14 (0-indexed = line 13) + calculator_refs = language_server.request_references("src/Calculator.hs", line=13, column=5) + + # Should find usage in Main.hs + assert len(calculator_refs) >= 1, f"Expected at least 1 reference to Calculator constructor, got {len(calculator_refs)}" + + # Should have at least one reference in Main.hs or Calculator.hs + calc_ref_paths = [ref["relativePath"] for ref in calculator_refs] + assert any("Main.hs" in path or "Calculator.hs" in path for path in calc_ref_paths), ( + f"Expected Calculator to be referenced in Main.hs or Calculator.hs, got: {calc_ref_paths}" + ) + + @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if s["kind"] == SymbolKind.Module: + continue + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + diagnostics = [ + { + "formatted": format_symbol_for_assert(sym), + "name": sym["name"], + "kind": SymbolKind(sym["kind"]).name, + "detail": sym.get("detail"), + } + for sym in malformed_symbols + ] + pytest.fail(f"Found malformed symbols: {diagnostics}", pytrace=False) + + @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/DiagnosticsSample.hs", + (), + min_count=1, + ) diff --git a/test/solidlsp/haxe/__init__.py b/test/solidlsp/haxe/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/solidlsp/haxe/test_haxe_basic.py b/test/solidlsp/haxe/test_haxe_basic.py new file mode 100644 index 0000000..1662090 --- /dev/null +++ b/test/solidlsp/haxe/test_haxe_basic.py @@ -0,0 +1,277 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from solidlsp.ls_utils import SymbolUtils +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.haxe +class TestHaxeLanguageServer: + @pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True) + def test_ls_is_running(self, language_server: SolidLanguageServer) -> None: + """Test that the Haxe language server starts successfully.""" + assert language_server.is_running() + + @pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main class not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "greet"), "greet method not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "calculateResult"), "calculateResult method not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Helper"), "Helper class not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "addNumbers"), "addNumbers method not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "formatMessage"), "formatMessage method not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True) + def test_bare_symbol_names(self, language_server: SolidLanguageServer) -> None: + """Test that symbol names do not contain unexpected formatting characters.""" + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) + + @pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True) + def test_find_references_within_file(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "Main.hx") + all_symbols, _ = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + greet_symbol = next((s for s in all_symbols if s.get("name") == "greet"), None) + assert greet_symbol is not None, "Could not find 'greet' symbol in Main.hx" + sel_start = greet_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + + assert refs, f"Expected non-empty references for greet but got {refs=}" + + # Convert references to a comparable format + actual_locations = [ + { + "uri_suffix": os.path.basename(ref.get("relativePath", ref.get("uri", ""))), + "line": ref["range"]["start"]["line"], + } + for ref in refs + ] + + # greet is called on line 15 (0-indexed) in Main.hx: `message = greet("World");` + call_site = {"uri_suffix": "Main.hx", "line": 15} + assert call_site in actual_locations, f"Expected reference to greet at line 15 in Main.hx, got {actual_locations}" + + # All references should be within Main.hx (greet is not used in other files) + assert all(loc["uri_suffix"] == "Main.hx" for loc in actual_locations), ( + f"Expected all greet references in Main.hx, got {actual_locations}" + ) + + @pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True) + def test_find_references_across_files(self, language_server: SolidLanguageServer) -> None: + # Test addNumbers which is defined in Helper.hx and used in Main.hx + helper_path = os.path.join("src", "utils", "Helper.hx") + all_symbols, _ = language_server.request_document_symbols(helper_path).get_all_symbols_and_roots() + add_numbers_symbol = next((s for s in all_symbols if s.get("name") == "addNumbers"), None) + assert add_numbers_symbol is not None, "Could not find 'addNumbers' symbol in Helper.hx" + + sel_start = add_numbers_symbol["selectionRange"]["start"] + refs = language_server.request_references(helper_path, sel_start["line"], sel_start["character"]) + + assert refs, f"Expected non-empty references for addNumbers but got {refs=}" + + # Convert references to a comparable format + actual_locations = [ + { + "uri_suffix": os.path.basename(ref.get("relativePath", ref.get("uri", ""))), + "line": ref["range"]["start"]["line"], + } + for ref in refs + ] + + # addNumbers is called on line 16 in Main.hx: `count = Helper.addNumbers(5, 10);` + call_site_1 = {"uri_suffix": "Main.hx", "line": 16} + assert call_site_1 in actual_locations, f"Expected reference to addNumbers at line 16 in Main.hx, got {actual_locations}" + + # addNumbers is called on line 30 in Main.hx: `var sum = Helper.addNumbers(count, 20);` + call_site_2 = {"uri_suffix": "Main.hx", "line": 30} + assert call_site_2 in actual_locations, f"Expected reference to addNumbers at line 30 in Main.hx, got {actual_locations}" + + # Verify cross-file: at least one reference is in Main.hx (different file from definition) + main_refs = [loc for loc in actual_locations if loc["uri_suffix"] == "Main.hx"] + assert len(main_refs) >= 2, f"Expected at least 2 references in Main.hx (lines 16 and 30), got {main_refs}" + + @pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True) + def test_document_symbols_structure(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "Main.hx") + result = language_server.request_document_symbols(file_path) + all_symbols, roots = result.get_all_symbols_and_roots() + + # Main class should be a root symbol + main_symbol = None + for sym in roots: + if sym.get("name") == "Main": + main_symbol = sym + break + assert main_symbol is not None, "Main class not found as root symbol" + assert main_symbol.get("kind") in (SymbolKind.Class, SymbolKind.Struct), f"Expected Main to be Class, got {main_symbol.get('kind')}" + + # Check that methods and fields exist and are children of Main + child_names = {s.get("name") for s in all_symbols if s.get("name") != "Main"} + assert "greet" in child_names, "greet method not found in symbols" + assert "calculateResult" in child_names, "calculateResult method not found in symbols" + assert "message" in child_names, "message field not found in symbols" + assert "count" in child_names, "count field not found in symbols" + + # Verify symbol kinds for specific symbols + for sym in all_symbols: + if sym.get("name") == "greet": + assert sym.get("kind") in ( + SymbolKind.Method, + SymbolKind.Function, + ), f"Expected greet to be Method/Function, got {sym.get('kind')}" + if sym.get("name") == "message": + assert sym.get("kind") in ( + SymbolKind.Field, + SymbolKind.Variable, + SymbolKind.Property, + ), f"Expected message to be Field/Variable, got {sym.get('kind')}" + + @pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True) + def test_workspace_symbol(self, language_server: SolidLanguageServer) -> None: + result = language_server.request_workspace_symbol("Helper") + assert result is not None, "Workspace symbol search returned None" + assert len(result) > 0, "Workspace symbol search returned no results" + assert any("Helper" in str(s.get("name", "")) for s in result), f"Expected at least one result containing 'Helper', got {result}" + + @pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True) + def test_go_to_definition_within_file(self, language_server: SolidLanguageServer) -> None: + """Go to definition of greet from its call site in Main.hx -- should resolve within the same file.""" + main_path = os.path.join("src", "Main.hx") + # Line 16 (0-indexed: 15): `message = greet("World");` + # `greet` starts at character 12 on that line. + definitions = language_server.request_definition(main_path, 15, 12) + assert definitions, "Expected to find definition for greet" + assert any("Main.hx" in d.get("uri", d.get("relativePath", "")) for d in definitions), ( + f"Expected definition in Main.hx, got {definitions}" + ) + # greet is defined on line 23 (0-indexed: 22) in Main.hx + definition_lines = [d["range"]["start"]["line"] for d in definitions if "Main.hx" in d.get("uri", d.get("relativePath", ""))] + assert 22 in definition_lines, f"Expected definition of greet at line 22 in Main.hx, got lines {definition_lines}" + + @pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True) + def test_go_to_definition(self, language_server: SolidLanguageServer) -> None: + """Go to definition of addNumbers from a call site in Main.hx -- should resolve to Helper.hx.""" + main_path = os.path.join("src", "Main.hx") + # Line 17 (0-indexed: 16): `\t\tcount = Helper.addNumbers(5, 10);` + # `addNumbers` starts at character 17 (0-indexed) on that line. + definitions = language_server.request_definition(main_path, 16, 17) + assert definitions, "Expected to find definition for addNumbers" + assert any("Helper.hx" in d.get("uri", d.get("relativePath", "")) for d in definitions), ( + f"Expected definition in Helper.hx, got {definitions}" + ) + # addNumbers is defined on line 18 (0-indexed) in Helper.hx + definition_lines = [d["range"]["start"]["line"] for d in definitions if "Helper.hx" in d.get("uri", d.get("relativePath", ""))] + assert 18 in definition_lines, f"Expected definition of addNumbers at line 18 in Helper.hx, got lines {definition_lines}" + + @pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True) + def test_hover(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "Main.hx") + result = language_server.request_document_symbols(file_path) + all_symbols, _ = result.get_all_symbols_and_roots() + greet_symbol = next((s for s in all_symbols if s.get("name") == "greet"), None) + assert greet_symbol is not None, "Could not find 'greet' symbol" + + sel_start = greet_symbol["selectionRange"]["start"] + hover = language_server.request_hover(file_path, sel_start["line"], sel_start["character"]) + assert hover is not None, "Hover returned None for greet method" + hover_str = str(hover) + assert "String" in hover_str or "greet" in hover_str, f"Expected hover to contain type info, got {hover_str}" + + @pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True) + def test_hover_on_class_declaration(self, language_server: SolidLanguageServer) -> None: + """Hovering on a class name should return hover info.""" + file_path = os.path.join("src", "Main.hx") + result = language_server.request_document_symbols(file_path) + all_symbols, _ = result.get_all_symbols_and_roots() + + main_symbol = next((s for s in all_symbols if s.get("name") == "Main"), None) + assert main_symbol is not None + + sel_start = main_symbol["selectionRange"]["start"] + hover = language_server.request_hover(file_path, sel_start["line"], sel_start["character"]) + assert hover is not None, "Hover on class declaration returned None" + hover_str = str(hover) + assert "Main" in hover_str, f"Expected hover to contain 'Main', got {hover_str}" + + @pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True) + def test_rename_symbol(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "Main.hx") + result = language_server.request_document_symbols(file_path) + all_symbols, _ = result.get_all_symbols_and_roots() + greet_symbol = next((s for s in all_symbols if s.get("name") == "greet"), None) + assert greet_symbol is not None, "Could not find 'greet' symbol" + + sel_start = greet_symbol["selectionRange"]["start"] + edits = language_server.request_rename_symbol_edit(file_path, sel_start["line"], sel_start["character"], "sayHello") + assert edits is not None, "Rename returned None" + # Verify edits contain changes (WorkspaceEdit has 'changes' or 'documentChanges') + edits_str = str(edits) + assert "Main.hx" in edits_str, f"Expected rename edits for Main.hx, got {edits}" + assert "sayHello" in edits_str, f"Expected new name 'sayHello' in rename edits, got {edits}" + + @pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True) + def test_completions(self, language_server: SolidLanguageServer) -> None: + """Request completions after Helper. in calculateResult — should return Helper's static methods.""" + file_path = os.path.join("src", "Main.hx") + # Line 31 (0-indexed: 30): `var sum = Helper.addNumbers(count, 20);` + # Trigger completions after `Helper.` — character 19 is right after the dot. + completions = language_server.request_completions(file_path, 30, 19) + assert completions, "Expected non-empty completions after Helper." + completion_texts = [c.get("completionText", c.get("label", "")) for c in completions] + assert "addNumbers" in completion_texts, f"Expected 'addNumbers' in completions after Helper., got {completion_texts[:10]}" + + @pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True) + def test_document_overview(self, language_server: SolidLanguageServer) -> None: + overview = language_server.request_document_overview(os.path.join("src", "Main.hx")) + assert overview, "Document overview returned empty list" + symbol_names = [s.get("name", "") for s in overview] + assert any("Main" in name for name in symbol_names), f"Expected 'Main' in overview, got {symbol_names}" + main_entry = next((s for s in overview if s.get("name") == "Main"), None) + assert main_entry is not None, f"Expected 'Main' entry in overview, got {symbol_names}" + assert main_entry.get("kind") in (SymbolKind.Class, SymbolKind.Struct), ( + f"Expected Main to be Class/Struct in overview, got kind {main_entry.get('kind')}" + ) + for s in overview: + assert s.get("name"), "Symbol missing 'name' field" + assert s.get("kind"), "Symbol missing 'kind' field" + + @pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True) + def test_rapid_successive_requests(self, language_server: SolidLanguageServer) -> None: + """Verify that rapid successive requests don't return empty results. + + The Haxe LS triggers a recompilation on didOpen. Without suppression, + subsequent requests during recompilation may return empty results. + """ + main_path = os.path.join("src", "Main.hx") + helper_path = os.path.join("src", "utils", "Helper.hx") + + # First request: hover on greet in Main.hx (line 22 is the greet method definition) + hover1 = language_server.request_hover(main_path, 22, 20) + + # Second request: references in a different file (triggers another didOpen) + all_symbols, _ = language_server.request_document_symbols(helper_path).get_all_symbols_and_roots() + add_numbers = next((s for s in all_symbols if s.get("name") == "addNumbers"), None) + assert add_numbers is not None + sel_start = add_numbers["selectionRange"]["start"] + refs = language_server.request_references(helper_path, sel_start["line"], sel_start["character"]) + + # Third request: back to Main.hx hover (would be affected by recompilation) + hover2 = language_server.request_hover(main_path, 22, 20) + + # All three should return non-empty results + assert hover1 is not None, "First hover returned None" + assert refs, "References returned empty after switching files" + assert hover2 is not None, "Second hover returned None after file switching" diff --git a/test/solidlsp/haxe/test_haxe_diagnostics.py b/test/solidlsp/haxe/test_haxe_diagnostics.py new file mode 100644 index 0000000..2f789b6 --- /dev/null +++ b/test/solidlsp/haxe/test_haxe_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.haxe +class TestHaxeDiagnostics: + @pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/DiagnosticsSample.hx", + (), + min_count=1, + ) diff --git a/test/solidlsp/hlsl/__init__.py b/test/solidlsp/hlsl/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/test/solidlsp/hlsl/__init__.py @@ -0,0 +1 @@ + diff --git a/test/solidlsp/hlsl/test_hlsl_basic.py b/test/solidlsp/hlsl/test_hlsl_basic.py new file mode 100644 index 0000000..90d1c86 --- /dev/null +++ b/test/solidlsp/hlsl/test_hlsl_basic.py @@ -0,0 +1,175 @@ +""" +Basic tests for HLSL language server integration (shader-language-server). + +This module tests Language.HLSL using shader-language-server from antaalt/shader-sense. +Tests are skipped if the language server is not available. +""" + +from typing import Any + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.ls_types import SymbolKind +from solidlsp.ls_utils import SymbolUtils +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +def _find_symbol_by_name(language_server: SolidLanguageServer, file_path: str, name: str) -> dict[str, Any] | None: + """Find a top-level symbol by name in a file's document symbols.""" + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + return next((s for s in symbols[0] if s.get("name") == name), None) + + +# ── Symbol Discovery ───────────────────────────────────────────── + + +@pytest.mark.hlsl +class TestHlslSymbols: + """Tests for document symbol extraction.""" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_find_struct(self, language_server: SolidLanguageServer) -> None: + """VertexInput struct should appear in common.hlsl symbols.""" + symbol = _find_symbol_by_name(language_server, "common.hlsl", "VertexInput") + assert symbol is not None, "Expected 'VertexInput' struct in document symbols" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_find_function(self, language_server: SolidLanguageServer) -> None: + """SafeNormalize function should appear in common.hlsl.""" + symbol = _find_symbol_by_name(language_server, "common.hlsl", "SafeNormalize") + assert symbol is not None, "Expected 'SafeNormalize' function in document symbols" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_find_cbuffer_members(self, language_server: SolidLanguageServer) -> None: + """Cbuffer members should appear as variables in compute_test.hlsl. + + Note: shader-language-server reports cbuffer members as individual + variables (kind 13), not the cbuffer name itself as a symbol. + """ + symbol = _find_symbol_by_name(language_server, "compute_test.hlsl", "TextureSize") + assert symbol is not None, "Expected 'TextureSize' cbuffer member in document symbols" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_find_compute_kernel(self, language_server: SolidLanguageServer) -> None: + """CSMain kernel should appear in compute_test.hlsl.""" + symbol = _find_symbol_by_name(language_server, "compute_test.hlsl", "CSMain") + assert symbol is not None, "Expected 'CSMain' compute kernel in document symbols" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_full_symbol_tree(self, language_server: SolidLanguageServer) -> None: + """Full symbol tree should contain symbols from multiple files.""" + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "VertexInput"), "VertexInput not in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "CalculateDiffuse"), "CalculateDiffuse not in symbol tree" + + +# ── Go-to-Definition ───────────────────────────────────────────── + + +@pytest.mark.hlsl +class TestHlslDefinition: + """Tests for go-to-definition capability.""" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_goto_definition_cross_file(self, language_server: SolidLanguageServer) -> None: + """Navigating to SafeNormalize call in lighting.hlsl should resolve to common.hlsl. + + lighting.hlsl line 22 (0-indexed): " float3 halfVec = SafeNormalize(-lightDir + viewDir);" + SafeNormalize starts at column 21. + """ + definitions = language_server.request_definition("lighting.hlsl", 22, 21) + assert len(definitions) >= 1, f"Expected at least 1 definition, got {len(definitions)}" + def_paths = [d.get("relativePath", d.get("uri", "")) for d in definitions] + assert any("common.hlsl" in p for p in def_paths), f"Expected definition in common.hlsl, got: {def_paths}" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_goto_definition_cross_file_remap(self, language_server: SolidLanguageServer) -> None: + """Navigating to Remap call in compute_test.hlsl should resolve to common.hlsl. + + compute_test.hlsl line 20 (0-indexed): " Remap(color.r, 0.0, 1.0, 0.2, 0.8)," + Remap starts at column 8. + """ + definitions = language_server.request_definition("compute_test.hlsl", 20, 8) + assert len(definitions) >= 1, f"Expected at least 1 definition, got {len(definitions)}" + def_paths = [d.get("relativePath", d.get("uri", "")) for d in definitions] + assert any("common.hlsl" in p for p in def_paths), f"Expected definition in common.hlsl, got: {def_paths}" + + +# ── References ──────────────────────────────────────────────────── + + +@pytest.mark.hlsl +class TestHlslReferences: + """Tests for find-references capability. + + shader-language-server does not advertise referencesProvider, so + request_references is expected to return an empty list. + """ + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_references_not_supported(self, language_server: SolidLanguageServer) -> None: + """References request should raise because shader-language-server does not support it. + + common.hlsl line 17 (0-indexed): "float3 SafeNormalize(float3 v)" + SafeNormalize starts at column 7. + """ + with pytest.raises(SolidLSPException, match="Method not found"): + language_server.request_references("common.hlsl", 17, 7) + + +# ── Hover ───────────────────────────────────────────────────────── + + +def _extract_hover_text(hover_info: dict[str, Any]) -> str: + """Extract the text content from an LSP hover response.""" + contents = hover_info["contents"] + if isinstance(contents, dict): + return contents.get("value", "") + elif isinstance(contents, str): + return contents + return str(contents) + + +@pytest.mark.hlsl +class TestHlslHover: + """Tests for hover information.""" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_hover_on_function(self, language_server: SolidLanguageServer) -> None: + """Hovering over SafeNormalize definition should return info. + + common.hlsl line 17 (0-indexed): "float3 SafeNormalize(float3 v)" + SafeNormalize starts at column 7. + """ + hover_info = language_server.request_hover("common.hlsl", 17, 7) + assert hover_info is not None, "Hover should return information for SafeNormalize" + assert "contents" in hover_info, "Hover should have contents" + hover_text = _extract_hover_text(hover_info) + assert len(hover_text) > 0, "Hover text should not be empty" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_hover_on_struct(self, language_server: SolidLanguageServer) -> None: + """Hovering over VertexInput should return struct info. + + common.hlsl line 3 (0-indexed): "struct VertexInput" + VertexInput starts at column 7. + """ + hover_info = language_server.request_hover("common.hlsl", 3, 7) + assert hover_info is not None, "Hover should return information for VertexInput" + assert "contents" in hover_info, "Hover should have contents" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s, period_allowed=s["kind"] == SymbolKind.File): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/hlsl/test_hlsl_diagnostics.py b/test/solidlsp/hlsl/test_hlsl_diagnostics.py new file mode 100644 index 0000000..e356d2d --- /dev/null +++ b/test/solidlsp/hlsl/test_hlsl_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.hlsl +class TestHlslDiagnostics: + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.hlsl", + (), + min_count=1, + ) diff --git a/test/solidlsp/hlsl/test_hlsl_full_index.py b/test/solidlsp/hlsl/test_hlsl_full_index.py new file mode 100644 index 0000000..70df1a6 --- /dev/null +++ b/test/solidlsp/hlsl/test_hlsl_full_index.py @@ -0,0 +1,63 @@ +""" +Regression tests for HLSL full symbol tree indexing. + +These tests verify that request_full_symbol_tree() correctly indexes all files, +including .hlsl includes in subdirectories. This catches bugs where files are +silently dropped during workspace-wide indexing. +""" + +from typing import Any + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from solidlsp.ls_utils import SymbolUtils + + +def _collect_file_names(symbols: list[dict[str, Any]]) -> set[str]: + """Recursively collect the names of all File-kind symbols in the tree.""" + names: set[str] = set() + for sym in symbols: + if sym.get("kind") == SymbolKind.File: + names.add(sym["name"]) + if "children" in sym: + names.update(_collect_file_names(sym["children"])) + return names + + +EXPECTED_FILES = {"common", "lighting", "compute_test", "terrain_sdf"} + +TERRAIN_SDF_UNIQUE_SYMBOLS = {"SampleSDF", "CalculateGradient", "SDFBrickData"} + + +@pytest.mark.hlsl +class TestHlslFullIndex: + """Tests for full symbol tree indexing completeness.""" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_all_files_indexed_in_symbol_tree(self, language_server: SolidLanguageServer) -> None: + """Every .hlsl file in the test repo must appear as a File symbol in the tree.""" + symbols = language_server.request_full_symbol_tree() + file_names = _collect_file_names(symbols) + missing = EXPECTED_FILES - file_names + assert not missing, f"Files missing from full symbol tree: {missing}. Found: {file_names}" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_subdirectory_file_symbols_present(self, language_server: SolidLanguageServer) -> None: + """Symbols unique to terrain/terrain_sdf.hlsl must appear in the full tree.""" + symbols = language_server.request_full_symbol_tree() + for name in TERRAIN_SDF_UNIQUE_SYMBOLS: + assert SymbolUtils.symbol_tree_contains_name(symbols, name), ( + f"Expected '{name}' from terrain/terrain_sdf.hlsl in full symbol tree" + ) + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_include_file_document_symbols_directly(self, language_server: SolidLanguageServer) -> None: + """request_document_symbols on terrain/terrain_sdf.hlsl should return its symbols.""" + doc_symbols = language_server.request_document_symbols("terrain/terrain_sdf.hlsl") + all_symbols = doc_symbols.get_all_symbols_and_roots() + symbol_names = {s.get("name") for s in all_symbols[0]} + for name in TERRAIN_SDF_UNIQUE_SYMBOLS: + assert name in symbol_names, f"Expected '{name}' in document symbols for terrain/terrain_sdf.hlsl, got: {symbol_names}" diff --git a/test/solidlsp/html_ls/__init__.py b/test/solidlsp/html_ls/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/solidlsp/html_ls/test_html_basic.py b/test/solidlsp/html_ls/test_html_basic.py new file mode 100644 index 0000000..1a3444e --- /dev/null +++ b/test/solidlsp/html_ls/test_html_basic.py @@ -0,0 +1,54 @@ +""" +Basic integration tests for the HTML language server. + +The HTML LSP (vscode-html-language-server) provides in-file document symbols +based on the element tree. Cross-file navigation (definition / references) is +not meaningful for HTML and is therefore not tested here. +""" + +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.conftest import request_all_symbols + + +@pytest.mark.html +class TestHtmlLanguageServerBasics: + """Smoke + symbol tests for the HTML language server.""" + + @pytest.mark.parametrize("language_server", [Language.HTML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.HTML], indirect=True) + def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.HTML], indirect=True) + def test_index_document_symbols(self, language_server: SolidLanguageServer) -> None: + """The HTML LSP exposes elements/IDs as document symbols.""" + all_symbols, _ = language_server.request_document_symbols("index.html").get_all_symbols_and_roots() + names = [s["name"] for s in all_symbols] + + # vscode-html-language-server reports elements by tag name and emits an entry + # per element with an id attribute. Names contain the id like "header#page-header" + # or just the tag like "head" for elements without an id; we check both forms. + joined = " | ".join(names) + for expected_id in ("page-header", "site-title", "main-nav", "section-features", "feature-list", "page-footer"): + assert expected_id in joined, f"Expected id '{expected_id}' to appear in HTML symbols: {names}" + + @pytest.mark.parametrize("language_server", [Language.HTML], indirect=True) + def test_about_document_symbols(self, language_server: SolidLanguageServer) -> None: + all_symbols, _ = language_server.request_document_symbols("about.html").get_all_symbols_and_roots() + names = [s["name"] for s in all_symbols] + joined = " | ".join(names) + for expected_id in ("page-header", "about-title", "main-nav", "about-article"): + assert expected_id in joined, f"Expected id '{expected_id}' to appear in HTML symbols: {names}" + + @pytest.mark.parametrize("language_server", [Language.HTML], indirect=True) + def test_full_symbol_tree_includes_both_files(self, language_server: SolidLanguageServer) -> None: + all_symbols = request_all_symbols(language_server) + relative_paths = {s.get("location", {}).get("relativePath") for s in all_symbols} + assert "index.html" in relative_paths + assert "about.html" in relative_paths diff --git a/test/solidlsp/html_ls/test_html_diagnostics.py b/test/solidlsp/html_ls/test_html_diagnostics.py new file mode 100644 index 0000000..7fb7df2 --- /dev/null +++ b/test/solidlsp/html_ls/test_html_diagnostics.py @@ -0,0 +1,27 @@ +""" +Diagnostics smoke test for the HTML language server. + +``vscode-html-language-server`` does not validate HTML structure itself — upstream +``vscode-html-languageservice`` only forwards validation to embedded sub-services +(CSS/JS in ``<style>`` / ``<script>`` blocks), and those aren't wired up in +standalone mode. So a malformed-HTML fixture would yield zero diagnostics regardless. + +Asserting "at least one diagnostic" would be a false positive waiting to happen. +Instead, this test verifies the diagnostics endpoint is reachable end-to-end and +the framework's pull→publish fallback handles the empty response correctly — i.e. +it returns a list, not an error. That alone catches LS-crash regressions on the +diagnostic request, which is the practical purpose for this LS. +""" + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + + +@pytest.mark.html +class TestHtmlDiagnostics: + @pytest.mark.parametrize("language_server", [Language.HTML], indirect=True) + def test_diagnostics_endpoint_returns_list(self, language_server: SolidLanguageServer) -> None: + diagnostics = language_server.request_text_document_diagnostics("diagnostics_sample.html", min_severity=1) + assert isinstance(diagnostics, list), diagnostics diff --git a/test/solidlsp/java/test_java_basic.py b/test/solidlsp/java/test_java_basic.py new file mode 100644 index 0000000..b9b78d8 --- /dev/null +++ b/test/solidlsp/java/test_java_basic.py @@ -0,0 +1,150 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.conftest import ( + find_identifier_position, + get_repo_path, + language_has_verified_implementation_support, + language_tests_enabled, +) +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + +pytestmark = [pytest.mark.java, pytest.mark.skipif(not language_tests_enabled(Language.JAVA), reason="Java tests disabled")] + + +class TestJavaLanguageServer: + @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main class not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils class not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model class not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) + def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: + # Use correct Maven/Java file paths + file_path = os.path.join("src", "main", "java", "test_repo", "Utils.java") + refs = language_server.request_references(file_path, 4, 20) + assert any("Main.java" in ref.get("relativePath", "") for ref in refs), "Main should reference Utils.printHello" + + # Dynamically determine the correct line/column for the 'Model' class name + file_path = os.path.join("src", "main", "java", "test_repo", "Model.java") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + model_symbol = None + for sym in symbols[0]: + if sym.get("name") == "Model" and sym.get("kind") == 5: # 5 = Class + model_symbol = sym + break + assert model_symbol is not None, "Could not find 'Model' class symbol in Model.java" + # Use selectionRange if present, otherwise fall back to range + if "selectionRange" in model_symbol: + sel_start = model_symbol["selectionRange"]["start"] + else: + sel_start = model_symbol["range"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert any("Main.java" in ref.get("relativePath", "") for ref in refs), ( + "Main should reference Model (tried all positions in selectionRange)" + ) + + @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) + def test_overview_methods(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main missing from overview" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils missing from overview" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model missing from overview" + + if language_has_verified_implementation_support(Language.JAVA): + + @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.JAVA) + pos = find_identifier_position(repo_path / "src/main/java/test_repo/Greeter.java", "formatGreeting") + assert pos is not None, "Could not find Greeter.formatGreeting in fixture" + + implementations = language_server.request_implementation("src/main/java/test_repo/Greeter.java", *pos) + assert implementations, "Expected at least one implementation of Greeter.formatGreeting" + assert any("ConsoleGreeter.java" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected ConsoleGreeter.formatGreeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.JAVA) + pos = find_identifier_position(repo_path / "src/main/java/test_repo/Greeter.java", "formatGreeting") + assert pos is not None, "Could not find Greeter.formatGreeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols("src/main/java/test_repo/Greeter.java", *pos) + assert implementing_symbols, "Expected implementing symbols for Greeter.formatGreeting" + assert any( + symbol.get("name") == "formatGreeting" and "ConsoleGreeter.java" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected ConsoleGreeter.formatGreeting symbol, got: {implementing_symbols}" + + @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) + + @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) + def test_lombok_generated_methods_visible_by_default(self, language_server: SolidLanguageServer) -> None: + """Generated Lombok methods must appear in document symbols across the common annotations. + + Default `lombok_show_generated=True` sends `java.symbols.includeGeneratedCode=true` to JDTLS, + which disables the SourceMethod-isGenerated filter in DocumentSymbolHandler. Without it, + find_symbol/get_symbols_overview cannot reach Lombok-synthesised methods at all (#1432). + Covers @Data, @Builder(toBuilder=true), @With, @AllArgsConstructor, @NoArgsConstructor, + @Delegate and @Accessors(fluent=true) — every method-generating annotation listed in the + issue plus fluent prefix-stripped accessors and @Delegate forwarders. + """ + + def _names_by_kind(doc, kind: int) -> set[str]: + return {sym.get("name") for sym in doc.get_all_symbols_and_roots()[0] if sym.get("kind") == kind} + + SYMBOL_KIND_CLASS = 5 + SYMBOL_KIND_METHOD = 6 + SYMBOL_KIND_CONSTRUCTOR = 9 + + # ---- LombokModel: @Data + @Builder(toBuilder=true) + @With + ctors + @Delegate ------- + lombok_path = os.path.join("src", "main", "java", "test_repo", "LombokModel.java") + lombok_doc = language_server.request_document_symbols(lombok_path) + + lombok_methods = _names_by_kind(lombok_doc, SYMBOL_KIND_METHOD) + # @Data getters/setters (prefixed) + canonical Object overrides + for expected in ("getName", "getAge", "setName", "setAge", "equals", "hashCode", "toString"): + assert expected in lombok_methods, f"@Data did not surface {expected!r}; got: {sorted(lombok_methods)}" + # @Builder(toBuilder=true): static factory + instance toBuilder + inner build() + for expected in ("builder", "toBuilder", "build"): + assert expected in lombok_methods, f"@Builder did not surface {expected!r}; got: {sorted(lombok_methods)}" + # @With: copy-with methods + for expected in ("withName", "withAge"): + assert expected in lombok_methods, f"@With did not surface {expected!r}; got: {sorted(lombok_methods)}" + # @Delegate: forwarder methods for every method of the delegate target + for expected in ("greet", "farewell"): + assert expected in lombok_methods, f"@Delegate did not surface forwarder {expected!r}; got: {sorted(lombok_methods)}" + + # @Builder generates an inner builder class + lombok_classes = _names_by_kind(lombok_doc, SYMBOL_KIND_CLASS) + assert "LombokModelBuilder" in lombok_classes, f"@Builder inner class missing; got: {sorted(lombok_classes)}" + + # @AllArgsConstructor + @NoArgsConstructor surface as ctor symbols (kind=9) + lombok_ctors = _names_by_kind(lombok_doc, SYMBOL_KIND_CONSTRUCTOR) + assert "LombokModel" in lombok_ctors, f"Lombok ctors missing; got ctors {sorted(lombok_ctors)}" + + # ---- FluentLombokModel: @Accessors(fluent=true) - prefix-stripped accessors --------- + fluent_path = os.path.join("src", "main", "java", "test_repo", "FluentLombokModel.java") + fluent_doc = language_server.request_document_symbols(fluent_path) + fluent_methods = _names_by_kind(fluent_doc, SYMBOL_KIND_METHOD) + for expected in ("host", "tag"): + assert expected in fluent_methods, f"@Accessors(fluent=true) did not surface {expected!r}; got: {sorted(fluent_methods)}" diff --git a/test/solidlsp/java/test_java_diagnostics.py b/test/solidlsp/java/test_java_diagnostics.py new file mode 100644 index 0000000..ebd5945 --- /dev/null +++ b/test/solidlsp/java/test_java_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.java +class TestJavaDiagnostics: + @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/main/java/test_repo/DiagnosticsSample.java", + (), + min_count=1, + ) diff --git a/test/solidlsp/java/test_jdtls_path_resolution.py b/test/solidlsp/java/test_jdtls_path_resolution.py new file mode 100644 index 0000000..7e7cd71 --- /dev/null +++ b/test/solidlsp/java/test_jdtls_path_resolution.py @@ -0,0 +1,646 @@ +""" +Unit tests for the offline (upstream) JDTLS resolution helpers in +``solidlsp.language_servers.eclipse_jdtls``. + +These tests cover only the path/version/validation logic; they do not start +JDTLS and do not require Java to be installed. Subprocess interactions are +mocked. +""" + +from __future__ import annotations + +import platform +from pathlib import Path +from unittest.mock import patch + +import pytest + +_JAVA_EXE_NAME = "java.exe" if platform.system() == "Windows" else "java" + +from solidlsp.language_servers.eclipse_jdtls import ( + JDTLS_CONFIG_DIR_BY_PLATFORM, + JDTLS_MIN_JDK_VERSION, + EclipseJDTLS, + RuntimeDependencyPaths, +) +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.settings import SolidLSPSettings + + +@pytest.fixture +def custom_settings() -> SolidLSPSettings.CustomLSSettings: + """Empty CustomLSSettings instance for tests that don't need any keys set.""" + return SolidLSPSettings.CustomLSSettings({}) + + +def _make_fake_jdtls_install( + root: Path, *, with_launcher: bool = True, with_native_fragments: bool = True, with_configs: bool = True +) -> Path: + """ + Builds a minimal fake upstream JDTLS layout under ``root``: ``plugins/`` with + a main equinox launcher jar (and optionally native fragments) and + ``config_<platform>/`` directories. Returns ``root``. + """ + plugins = root / "plugins" + plugins.mkdir(parents=True, exist_ok=True) + if with_launcher: + (plugins / "org.eclipse.equinox.launcher_1.7.100.v20251111-0406.jar").touch() + if with_native_fragments: + (plugins / "org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.0.v20240329-1112.jar").touch() + (plugins / "org.eclipse.equinox.launcher.gtk.linux.x86_64_1.2.0.v20240329-1112.jar").touch() + (plugins / "org.eclipse.equinox.launcher.win32.win32.x86_64_1.2.0.v20240329-1112.jar").touch() + if with_configs: + for config_name in set(JDTLS_CONFIG_DIR_BY_PLATFORM.values()): + (root / config_name).mkdir(exist_ok=True) + return root + + +def _make_runtime_dependency_paths(root: Path) -> RuntimeDependencyPaths: + """ + Build minimal runtime dependency paths for initialize-parameter tests. + + :param root: Root directory under which fake runtime files are created. + :return: Runtime paths sufficient for ``EclipseJDTLS._create_base_initialize_params``. + """ + jre_home = root / "jre-home" + jre_home.mkdir(parents=True) + jre_bin = jre_home / "bin" + jre_bin.mkdir() + jre_path = jre_bin / _JAVA_EXE_NAME + jre_path.touch() + + gradle_path = root / "gradle" + gradle_path.mkdir() + + launcher = root / "jdtls-launcher.jar" + launcher.touch() + config = root / "config" + config.mkdir() + lombok = root / "lombok.jar" + lombok.touch() + + return RuntimeDependencyPaths( + jre_path=str(jre_path), + jre_home_path=str(jre_home), + jdtls_launcher_jar_path=str(launcher), + jdtls_readonly_config_path=str(config), + lombok_jar_path=str(lombok), + gradle_path=str(gradle_path), + ) + + +def _make_uninitialized_jdtls( + repository_root: Path, custom_settings: dict[str, object], runtime_dependency_paths: RuntimeDependencyPaths +) -> EclipseJDTLS: + """ + Build an EclipseJDTLS instance without starting dependency setup or JDTLS. + + :param repository_root: Repository root reported to initialization-parameter generation. + :param custom_settings: Java language-server settings to attach to the instance. + :param runtime_dependency_paths: Runtime paths to attach to the instance. + :return: Partially initialized server suitable for pure parameter-generation tests. + """ + server = object.__new__(EclipseJDTLS) + server.repository_root_path = str(repository_root) + server._custom_settings = SolidLSPSettings.CustomLSSettings(custom_settings) + server.runtime_dependency_paths = runtime_dependency_paths + return server + + +def _gradle_java_home(initialize_params: dict) -> str: + """ + Return the Gradle Java home from initialize parameters. + + :param initialize_params: JDTLS initialize-parameter payload. + :return: Configured Gradle Java home. + """ + return initialize_params["initializationOptions"]["settings"]["java"]["import"]["gradle"]["java"]["home"] + + +# ---------------------------------------------------------------------------- +# Gradle Java-home initialize settings +# ---------------------------------------------------------------------------- + + +class TestGradleJavaHomeInitializeSettings: + def test_explicit_gradle_java_home_takes_precedence_over_java_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Verify explicit Gradle Java home wins over system ``JAVA_HOME``.""" + repo = tmp_path / "repo" + repo.mkdir() + runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime") + explicit_gradle_jdk = tmp_path / "explicit-gradle-jdk" + explicit_gradle_jdk.mkdir() + env_jdk = tmp_path / "env-jdk" + env_jdk.mkdir() + monkeypatch.setenv("JAVA_HOME", str(env_jdk)) + + server = _make_uninitialized_jdtls( + repo, + {"gradle_java_home": str(explicit_gradle_jdk), "use_system_java_home": True}, + runtime_paths, + ) + + assert _gradle_java_home(server._create_base_initialize_params()) == str(explicit_gradle_jdk) + + def test_uses_java_home_for_gradle_when_system_java_home_enabled(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Verify Gradle import uses system ``JAVA_HOME`` when the system setting is enabled.""" + repo = tmp_path / "repo" + repo.mkdir() + runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime") + env_jdk = tmp_path / "env-jdk" + env_jdk.mkdir() + monkeypatch.setenv("JAVA_HOME", str(env_jdk)) + + server = _make_uninitialized_jdtls(repo, {"use_system_java_home": True}, runtime_paths) + + assert _gradle_java_home(server._create_base_initialize_params()) == str(env_jdk) + + def test_uses_bundled_runtime_for_gradle_when_system_java_home_disabled(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Verify Gradle import uses the bundled runtime when system Java home is disabled.""" + repo = tmp_path / "repo" + repo.mkdir() + runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime") + env_jdk = tmp_path / "env-jdk" + env_jdk.mkdir() + monkeypatch.setenv("JAVA_HOME", str(env_jdk)) + + server = _make_uninitialized_jdtls(repo, {"use_system_java_home": False}, runtime_paths) + + assert _gradle_java_home(server._create_base_initialize_params()) == runtime_paths.jre_path + + def test_uses_bundled_runtime_for_gradle_when_requested_java_home_is_unset( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Verify Gradle import falls back to the bundled runtime when ``JAVA_HOME`` is unset.""" + repo = tmp_path / "repo" + repo.mkdir() + runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime") + monkeypatch.delenv("JAVA_HOME", raising=False) + + server = _make_uninitialized_jdtls(repo, {"use_system_java_home": True}, runtime_paths) + + assert _gradle_java_home(server._create_base_initialize_params()) == runtime_paths.jre_path + + def test_missing_explicit_gradle_java_home_raises(self, tmp_path: Path) -> None: + """Verify missing explicit Gradle Java home remains a configuration error.""" + repo = tmp_path / "repo" + repo.mkdir() + runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime") + + server = _make_uninitialized_jdtls(repo, {"gradle_java_home": str(tmp_path / "missing-jdk")}, runtime_paths) + + with pytest.raises(FileNotFoundError, match="Gradle Java home not found"): + server._create_base_initialize_params() + + +# ---------------------------------------------------------------------------- +# _resolve_launcher_jar +# ---------------------------------------------------------------------------- + + +class TestResolveLauncherJar: + def test_picks_main_launcher_excluding_native_fragments(self, tmp_path: Path) -> None: + plugins = tmp_path / "plugins" + plugins.mkdir() + main_jar = plugins / "org.eclipse.equinox.launcher_1.7.100.v20251111-0406.jar" + main_jar.touch() + # native fragments should NOT be picked + (plugins / "org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.0.v20240329-1112.jar").touch() + (plugins / "org.eclipse.equinox.launcher.gtk.linux.x86_64_1.2.0.v20240329-1112.jar").touch() + (plugins / "org.eclipse.equinox.launcher.win32.win32.x86_64_1.2.0.v20240329-1112.jar").touch() + + result = EclipseJDTLS.DependencyProvider._resolve_launcher_jar(plugins) + assert result == main_jar + + def test_picks_highest_version_when_multiple_main_launchers(self, tmp_path: Path) -> None: + plugins = tmp_path / "plugins" + plugins.mkdir() + (plugins / "org.eclipse.equinox.launcher_1.6.500.v20240916-1115.jar").touch() + newer = plugins / "org.eclipse.equinox.launcher_1.7.100.v20251111-0406.jar" + newer.touch() + (plugins / "org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar").touch() + + result = EclipseJDTLS.DependencyProvider._resolve_launcher_jar(plugins) + assert result == newer, "Expected the lexicographically highest launcher version to be selected" + + def test_raises_when_no_launcher_present(self, tmp_path: Path) -> None: + plugins = tmp_path / "plugins" + plugins.mkdir() + # only native fragments, no main launcher + (plugins / "org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.0.v20240329-1112.jar").touch() + + with pytest.raises(SolidLSPException, match="No main Equinox launcher jar found"): + EclipseJDTLS.DependencyProvider._resolve_launcher_jar(plugins) + + +# ---------------------------------------------------------------------------- +# _resolve_config_dir +# ---------------------------------------------------------------------------- + + +class TestResolveConfigDir: + @pytest.mark.parametrize( + "platform_id,expected_dir", + [ + ("osx-arm64", "config_mac_arm"), + ("darwin-arm64", "config_mac_arm"), + ("osx-x64", "config_mac"), + ("linux-arm64", "config_linux_arm"), + ("linux-x64", "config_linux"), + ("win-x64", "config_win"), + ], + ) + def test_maps_platform_to_correct_config_dir(self, tmp_path: Path, platform_id: str, expected_dir: str) -> None: + _make_fake_jdtls_install(tmp_path) + with patch("solidlsp.language_servers.eclipse_jdtls.PlatformUtils.get_platform_id") as mock_get_pid: + mock_get_pid.return_value.value = platform_id + result = EclipseJDTLS.DependencyProvider._resolve_config_dir(tmp_path) + assert result.name == expected_dir + assert result.is_dir() + + def test_raises_when_config_dir_missing(self, tmp_path: Path) -> None: + # plugins/ exists but no config_<platform>/ for current OS + (tmp_path / "plugins").mkdir() + with patch("solidlsp.language_servers.eclipse_jdtls.PlatformUtils.get_platform_id") as mock_get_pid: + mock_get_pid.return_value.value = "linux-x64" + with pytest.raises(SolidLSPException, match="Config directory .* not found"): + EclipseJDTLS.DependencyProvider._resolve_config_dir(tmp_path) + + def test_raises_for_unsupported_platform(self, tmp_path: Path) -> None: + _make_fake_jdtls_install(tmp_path) + with patch("solidlsp.language_servers.eclipse_jdtls.PlatformUtils.get_platform_id") as mock_get_pid: + mock_get_pid.return_value.value = "freebsd-riscv64" + with pytest.raises(SolidLSPException, match="Unsupported platform"): + EclipseJDTLS.DependencyProvider._resolve_config_dir(tmp_path) + + +# ---------------------------------------------------------------------------- +# _inspect_java +# ---------------------------------------------------------------------------- + + +class TestInspectJava: + @staticmethod + def _fake_subprocess_result(stderr: str, stdout: str = "", returncode: int = 0): + """Build a minimal CompletedProcess-like object.""" + + class _Result: + def __init__(self) -> None: + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + return _Result() + + def test_parses_temurin_21_output(self) -> None: + # Real Temurin 21.0.10 output (java -XshowSettings:properties -version writes to stderr) + stderr = ( + "Property settings:\n" + " java.home = /Users/me/Library/Java/JavaVirtualMachines/temurin-21.0.10/Contents/Home\n" + " java.version = 21.0.10\n" + 'openjdk version "21.0.10" 2026-01-20 LTS\n' + "OpenJDK Runtime Environment Temurin-21.0.10+7 (build 21.0.10+7-LTS)\n" + ) + with patch("subprocess.run", return_value=self._fake_subprocess_result(stderr)): + home, major = EclipseJDTLS.DependencyProvider._inspect_java("/usr/bin/java") + assert home == "/Users/me/Library/Java/JavaVirtualMachines/temurin-21.0.10/Contents/Home" + assert major == 21 + + def test_parses_openjdk_17_output(self) -> None: + stderr = 'Property settings:\n java.home = /usr/lib/jvm/java-17-openjdk-amd64\nopenjdk version "17.0.5" 2023-10-17\n' + with patch("subprocess.run", return_value=self._fake_subprocess_result(stderr)): + home, major = EclipseJDTLS.DependencyProvider._inspect_java("/usr/bin/java") + assert home == "/usr/lib/jvm/java-17-openjdk-amd64" + assert major == 17 + + def test_raises_when_java_home_property_missing(self) -> None: + stderr = 'java version "21.0.0"\n' + with patch("subprocess.run", return_value=self._fake_subprocess_result(stderr)): + with pytest.raises(SolidLSPException, match="Could not parse java.home"): + EclipseJDTLS.DependencyProvider._inspect_java("/usr/bin/fakejava") + + def test_raises_when_version_string_missing(self) -> None: + stderr = " java.home = /opt/jdk\n" + with patch("subprocess.run", return_value=self._fake_subprocess_result(stderr)): + with pytest.raises(SolidLSPException, match="Could not parse Java version"): + EclipseJDTLS.DependencyProvider._inspect_java("/usr/bin/fakejava") + + def test_raises_on_subprocess_error(self) -> None: + with patch("subprocess.run", side_effect=OSError("permission denied")): + with pytest.raises(SolidLSPException, match="Failed to run"): + EclipseJDTLS.DependencyProvider._inspect_java("/nonexistent/java") + + +# ---------------------------------------------------------------------------- +# _resolve_system_jdk +# ---------------------------------------------------------------------------- + + +class TestResolveSystemJdk: + """Verifies the priority chain: java_home setting > JAVA_HOME env > PATH.""" + + @staticmethod + def _make_jdk_layout(root: Path, java_exe_name: str = _JAVA_EXE_NAME) -> Path: + bin_dir = root / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + (bin_dir / java_exe_name).touch() + return root + + def _patch_inspect_java(self, real_home: str, major: int): + return patch.object(EclipseJDTLS.DependencyProvider, "_inspect_java", return_value=(real_home, major)) + + def test_uses_explicit_java_home_setting(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + explicit_jdk = self._make_jdk_layout(tmp_path / "explicit-jdk") + monkeypatch.delenv("JAVA_HOME", raising=False) + + with self._patch_inspect_java(str(explicit_jdk), 21): + settings = SolidLSPSettings.CustomLSSettings({"java_home": str(explicit_jdk)}) + home, java_path = EclipseJDTLS.DependencyProvider._resolve_system_jdk(settings) + + assert Path(home) == explicit_jdk + assert Path(java_path).name == _JAVA_EXE_NAME + + def test_falls_back_to_java_home_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + env_jdk = self._make_jdk_layout(tmp_path / "env-jdk") + monkeypatch.setenv("JAVA_HOME", str(env_jdk)) + + with self._patch_inspect_java(str(env_jdk), 21): + settings = SolidLSPSettings.CustomLSSettings({}) + home, _ = EclipseJDTLS.DependencyProvider._resolve_system_jdk(settings) + + assert Path(home) == env_jdk + + def test_falls_back_to_which_java( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + path_jdk = self._make_jdk_layout(tmp_path / "path-jdk") + java_path = path_jdk / "bin" / _JAVA_EXE_NAME + monkeypatch.delenv("JAVA_HOME", raising=False) + + with patch("solidlsp.language_servers.eclipse_jdtls.shutil.which", return_value=str(java_path)): + with self._patch_inspect_java(str(path_jdk), 21): + home, _ = EclipseJDTLS.DependencyProvider._resolve_system_jdk(custom_settings) + + assert Path(home) == path_jdk + + def test_raises_when_no_java_anywhere( + self, monkeypatch: pytest.MonkeyPatch, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + monkeypatch.delenv("JAVA_HOME", raising=False) + with patch("solidlsp.language_servers.eclipse_jdtls.shutil.which", return_value=None): + with pytest.raises(SolidLSPException, match="Could not locate a Java installation"): + EclipseJDTLS.DependencyProvider._resolve_system_jdk(custom_settings) + + def test_raises_for_invalid_explicit_java_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # path exists but no bin/java + broken = tmp_path / "broken-jdk" + broken.mkdir() + monkeypatch.delenv("JAVA_HOME", raising=False) + + settings = SolidLSPSettings.CustomLSSettings({"java_home": str(broken)}) + with pytest.raises(SolidLSPException, match=r"java_home=.*invalid"): + EclipseJDTLS.DependencyProvider._resolve_system_jdk(settings) + + def test_raises_for_too_old_jdk(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + old_jdk = self._make_jdk_layout(tmp_path / "jdk-17") + monkeypatch.setenv("JAVA_HOME", str(old_jdk)) + + with self._patch_inspect_java(str(old_jdk), 17): + settings = SolidLSPSettings.CustomLSSettings({}) + with pytest.raises(SolidLSPException, match=f"requires JDK {JDTLS_MIN_JDK_VERSION}"): + EclipseJDTLS.DependencyProvider._resolve_system_jdk(settings) + + def test_uses_real_jdk_home_when_locator_is_macos_stub( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + """ + Simulates the macOS /usr/bin/java stub: the locator points to /usr/bin/java but the JVM's + java.home is the actual JDK. The resolver should report the real JDK home. + """ + real_jdk = self._make_jdk_layout(tmp_path / "real-jdk-21") + macos_stub = tmp_path / "fake-usr" / "bin" / _JAVA_EXE_NAME + macos_stub.parent.mkdir(parents=True) + macos_stub.touch() + monkeypatch.delenv("JAVA_HOME", raising=False) + + with patch("solidlsp.language_servers.eclipse_jdtls.shutil.which", return_value=str(macos_stub)): + with self._patch_inspect_java(str(real_jdk), 21): + home, java_path = EclipseJDTLS.DependencyProvider._resolve_system_jdk(custom_settings) + + # the resolver must trust the JVM's reported java.home, not parent.parent of the stub + assert Path(home) == real_jdk + # and prefer the real-home java executable over the stub + assert Path(java_path) == real_jdk / "bin" / _JAVA_EXE_NAME + + +# ---------------------------------------------------------------------------- +# _setup_from_existing_install +# ---------------------------------------------------------------------------- + + +class TestSetupFromExistingInstall: + @pytest.fixture + def lombok_jar(self, tmp_path: Path) -> Path: + jar = tmp_path / "lombok-1.18.44.jar" + jar.touch() + return jar + + @pytest.fixture + def jdtls_root(self, tmp_path: Path) -> Path: + return _make_fake_jdtls_install(tmp_path / "jdtls") + + def _fake_jdk(self, tmp_path: Path) -> Path: + jdk = tmp_path / "jdk-21" + (jdk / "bin").mkdir(parents=True) + (jdk / "bin" / "java").touch() + return jdk + + def test_happy_path_returns_runtime_paths_with_no_gradle_and_no_intellicode( + self, tmp_path: Path, jdtls_root: Path, lombok_jar: Path, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + jdk = self._fake_jdk(tmp_path) + with patch("solidlsp.language_servers.eclipse_jdtls.PlatformUtils.get_platform_id") as mock_pid: + mock_pid.return_value.value = "linux-x64" + with patch.object(EclipseJDTLS.DependencyProvider, "_resolve_system_jdk", return_value=(str(jdk), str(jdk / "bin" / "java"))): + result = EclipseJDTLS.DependencyProvider._setup_from_existing_install(str(jdtls_root), str(lombok_jar), custom_settings) + + assert result.gradle_path is None + assert result.intellicode_jar_path is None + assert result.intellisense_members_path is None + assert result.lombok_jar_path == str(lombok_jar) + assert result.jre_home_path == str(jdk) + assert Path(result.jdtls_launcher_jar_path).name.startswith("org.eclipse.equinox.launcher_") + assert Path(result.jdtls_readonly_config_path).name == "config_linux" + + def test_raises_for_nonexistent_jdtls_path( + self, tmp_path: Path, lombok_jar: Path, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + with pytest.raises(SolidLSPException, match="not an existing directory"): + EclipseJDTLS.DependencyProvider._setup_from_existing_install(str(tmp_path / "does-not-exist"), str(lombok_jar), custom_settings) + + def test_raises_when_plugins_dir_missing( + self, tmp_path: Path, lombok_jar: Path, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + empty_root = tmp_path / "no-plugins" + empty_root.mkdir() + with pytest.raises(SolidLSPException, match="'plugins/' directory not found"): + EclipseJDTLS.DependencyProvider._setup_from_existing_install(str(empty_root), str(lombok_jar), custom_settings) + + def test_raises_when_lombok_jar_missing( + self, jdtls_root: Path, tmp_path: Path, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + with pytest.raises(SolidLSPException, match="lombok_path .* does not exist"): + EclipseJDTLS.DependencyProvider._setup_from_existing_install( + str(jdtls_root), str(tmp_path / "no-such-lombok.jar"), custom_settings + ) + + +# ---------------------------------------------------------------------------- +# _setup_runtime_dependencies (mode-switch logic) +# ---------------------------------------------------------------------------- + + +class TestSetupRuntimeDependenciesModeSwitch: + """Verifies the activation trigger: both jdtls_path and lombok_path => upstream mode.""" + + def test_both_set_invokes_upstream_mode(self) -> None: + settings = SolidLSPSettings.CustomLSSettings({"jdtls_path": "/x", "lombok_path": "/y"}) + with patch.object(EclipseJDTLS.DependencyProvider, "_setup_from_existing_install", return_value="upstream-result") as mock_upstream: + result = EclipseJDTLS.DependencyProvider._setup_runtime_dependencies("/ignored", settings) + mock_upstream.assert_called_once_with("/x", "/y", settings) + assert result == "upstream-result" + + def test_only_jdtls_path_set_raises(self) -> None: + settings = SolidLSPSettings.CustomLSSettings({"jdtls_path": "/x"}) + with pytest.raises(SolidLSPException, match="must be set together"): + EclipseJDTLS.DependencyProvider._setup_runtime_dependencies("/ignored", settings) + + def test_only_lombok_path_set_raises(self) -> None: + settings = SolidLSPSettings.CustomLSSettings({"lombok_path": "/y"}) + with pytest.raises(SolidLSPException, match="must be set together"): + EclipseJDTLS.DependencyProvider._setup_runtime_dependencies("/ignored", settings) + + +# ---------------------------------------------------------------------------- +# _compute_workspace_hash +# ---------------------------------------------------------------------------- + + +class TestComputeWorkspaceHash: + """ + The launcher jar path is mixed into the hash so that switching JDTLS versions + (default vscode-java VSIX bump or upstream install change) lands in a separate + ws_dir and avoids stale OSGi configs from the previous version blocking startup. + + Workspace-affecting Java settings are also mixed into the hash so stale Maven / + Gradle import caches are not silently reused after config changes. + + Backwards-compatibility carve-out: legacy default-mode users on + INITIAL_VSCODE_JAVA_VERSION keep the original ``md5(repository_root_path)`` format, + as long as they have not opted into tracked workspace-affecting settings. + """ + + REPO = "/home/me/projects/widgets" + DEFAULT_LAUNCHER = "/srv/serena/static/eclipse-jdtls-1.49.0/plugins/org.eclipse.equinox.launcher_1.7.100.jar" + UPSTREAM_LAUNCHER = "/opt/homebrew/Cellar/jdtls/1.50.0/libexec/plugins/org.eclipse.equinox.launcher_1.7.0.jar" + + def _initial_settings(self) -> "SolidLSPSettings.CustomLSSettings": + from solidlsp.language_servers.eclipse_jdtls import INITIAL_VSCODE_JAVA_VERSION + + return SolidLSPSettings.CustomLSSettings({"vscode_java_version": INITIAL_VSCODE_JAVA_VERSION}) + + def test_initial_default_mode_matches_pre_upstream_format(self) -> None: + """Legacy carve-out: INITIAL default-mode hash MUST equal md5(repository_root_path).""" + import hashlib + + expected = hashlib.md5(self.REPO.encode()).hexdigest() + result = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.DEFAULT_LAUNCHER, self._initial_settings()) + assert result == expected + + def test_initial_default_mode_ignores_launcher_path(self) -> None: + """Legacy INITIAL hash must not depend on launcher path (so legacy users keep cache).""" + s = self._initial_settings() + h1 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.DEFAULT_LAUNCHER, s) + h2 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.UPSTREAM_LAUNCHER, s) + assert h1 == h2 + + def test_default_mode_non_initial_includes_launcher_path(self) -> None: + """Default mode on a non-INITIAL version must mix in launcher path so version bumps re-init.""" + empty_settings = SolidLSPSettings.CustomLSSettings({}) + h1 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.DEFAULT_LAUNCHER, empty_settings) + h2 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.UPSTREAM_LAUNCHER, empty_settings) + assert h1 != h2 + + def test_upstream_mode_includes_launcher_path(self) -> None: + """When jdtls_path is set, different launcher paths must produce different hashes.""" + settings = SolidLSPSettings.CustomLSSettings({"jdtls_path": "/opt/homebrew/Cellar/jdtls/1.50.0/libexec"}) + h1 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.DEFAULT_LAUNCHER, settings) + h2 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.UPSTREAM_LAUNCHER, settings) + assert h1 != h2 + + def test_initial_and_upstream_produce_different_hashes(self) -> None: + """Same repo + same launcher path but INITIAL-default vs upstream → different ws_dir.""" + initial_h = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.UPSTREAM_LAUNCHER, self._initial_settings()) + upstream_h = EclipseJDTLS.DependencyProvider._compute_workspace_hash( + self.REPO, + self.UPSTREAM_LAUNCHER, + SolidLSPSettings.CustomLSSettings({"jdtls_path": "/opt/homebrew/Cellar/jdtls/1.50.0/libexec"}), + ) + assert initial_h != upstream_h + + def test_different_repo_paths_produce_different_hashes(self) -> None: + empty_settings = SolidLSPSettings.CustomLSSettings({}) + h1 = EclipseJDTLS.DependencyProvider._compute_workspace_hash("/a/repo", self.DEFAULT_LAUNCHER, empty_settings) + h2 = EclipseJDTLS.DependencyProvider._compute_workspace_hash("/b/repo", self.DEFAULT_LAUNCHER, empty_settings) + assert h1 != h2 + + def test_workspace_affecting_settings_change_hash(self) -> None: + base_settings = SolidLSPSettings.CustomLSSettings({"maven_user_settings": "/tmp/maven-a.xml"}) + changed_settings = SolidLSPSettings.CustomLSSettings({"maven_user_settings": "/tmp/maven-b.xml"}) + + h1 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.DEFAULT_LAUNCHER, base_settings) + h2 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.DEFAULT_LAUNCHER, changed_settings) + + assert h1 != h2 + + def test_legacy_initial_mode_still_invalidates_when_workspace_settings_change(self) -> None: + settings = SolidLSPSettings.CustomLSSettings( + { + "vscode_java_version": self._initial_settings().get("vscode_java_version"), + "gradle_user_home": "/tmp/gradle-home", + } + ) + + legacy_hash = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.DEFAULT_LAUNCHER, self._initial_settings()) + configured_hash = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.DEFAULT_LAUNCHER, settings) + + assert configured_hash != legacy_hash + + +# ---------------------------------------------------------------------------- +# is_ignored_dirname (directory-traversal filter) +# ---------------------------------------------------------------------------- + + +class TestIsIgnoredDirname: + """Regression for #1645: the Java language server must not hard-ignore directories whose names + collide with build-tool output (``lib``, ``dist``, ``classes``, ``out``, ...). Those are all + valid Java package identifiers, so ignoring them by name hid legitimate source from the symbol + tools even when ``git check-ignore`` reported nothing. Real build output is already filtered via + ``.gitignore``, so directory traversal should only skip the language-agnostic + ``_ALWAYS_IGNORED_DIRS`` (VCS/venv/cache/IDE internals) inherited from the base language server. + """ + + @pytest.fixture + def jdtls(self) -> EclipseJDTLS: + # is_ignored_dirname only reads the class-level _ALWAYS_IGNORED_DIRS, so an uninitialized + # instance is sufficient here (no Java or JDTLS process required). + return object.__new__(EclipseJDTLS) + + @pytest.mark.parametrize("dirname", ["lib", "dist", "classes", "out", "target", "build", "bin"]) + def test_valid_package_dirnames_are_not_ignored(self, jdtls: EclipseJDTLS, dirname: str) -> None: + assert jdtls.is_ignored_dirname(dirname) is False + + @pytest.mark.parametrize("dirname", [".git", ".venv", ".idea", ".serena", ".mypy_cache"]) + def test_always_ignored_dirs_are_still_ignored(self, jdtls: EclipseJDTLS, dirname: str) -> None: + assert jdtls.is_ignored_dirname(dirname) is True diff --git a/test/solidlsp/json_ls/__init__.py b/test/solidlsp/json_ls/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/solidlsp/json_ls/test_json_basic.py b/test/solidlsp/json_ls/test_json_basic.py new file mode 100644 index 0000000..c2e8031 --- /dev/null +++ b/test/solidlsp/json_ls/test_json_basic.py @@ -0,0 +1,78 @@ +""" +Basic integration tests for the JSON language server functionality. + +These tests validate the functionality of the language server APIs +like request_document_symbols using the JSON test repository. +""" + +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.json +class TestJsonLanguageServerBasics: + """Test basic functionality of the JSON language server.""" + + @pytest.mark.parametrize("language_server", [Language.JSON], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.JSON], indirect=True) + def test_json_language_server_initialization(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that JSON language server can be initialized successfully.""" + assert language_server is not None + assert language_server.language == Language.JSON + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.JSON], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.JSON], indirect=True) + def test_json_config_file_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test document symbols detection in config.json with specific symbol verification.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.json").get_all_symbols_and_roots() + + assert all_symbols is not None, "Should return symbols for config.json" + assert len(all_symbols) > 0, f"Should find symbols in config.json, found {len(all_symbols)}" + + symbol_names = [sym.get("name") for sym in all_symbols] + assert "app" in symbol_names, "Should detect 'app' key in config.json" + assert "database" in symbol_names, "Should detect 'database' key in config.json" + assert "logging" in symbol_names, "Should detect 'logging' key in config.json" + assert "features" in symbol_names, "Should detect 'features' key in config.json" + + # Verify nested symbols + assert "name" in symbol_names, "Should detect nested 'name' key" + assert "port" in symbol_names, "Should detect nested 'port' key" + assert "debug" in symbol_names, "Should detect nested 'debug' key" + + @pytest.mark.parametrize("language_server", [Language.JSON], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.JSON], indirect=True) + def test_json_data_file_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test symbol detection in data.json with array structures.""" + all_symbols, root_symbols = language_server.request_document_symbols("data.json").get_all_symbols_and_roots() + + assert all_symbols is not None, "Should return symbols for data.json" + assert len(all_symbols) > 0, f"Should find symbols in data.json, found {len(all_symbols)}" + + symbol_names = [sym.get("name") for sym in all_symbols] + assert "users" in symbol_names, "Should detect 'users' array" + assert "projects" in symbol_names, "Should detect 'projects' array" + assert "name" in symbol_names, "Should detect 'name' fields" + assert "email" in symbol_names, "Should detect 'email' fields" + assert "id" in symbol_names, "Should detect 'id' fields" + + @pytest.mark.parametrize("language_server", [Language.JSON], indirect=True) + def test_bare_symbol_names(self, language_server: SolidLanguageServer) -> None: + """Test that symbol names do not contain malformed characters.""" + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s, period_allowed=True): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/json_ls/test_json_diagnostics.py b/test/solidlsp/json_ls/test_json_diagnostics.py new file mode 100644 index 0000000..7850083 --- /dev/null +++ b/test/solidlsp/json_ls/test_json_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.json +class TestJsonDiagnostics: + @pytest.mark.parametrize("language_server", [Language.JSON], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.json", + (), + min_count=1, + ) diff --git a/test/solidlsp/julia/test_julia_basic.py b/test/solidlsp/julia/test_julia_basic.py new file mode 100644 index 0000000..75b81ab --- /dev/null +++ b/test/solidlsp/julia/test_julia_basic.py @@ -0,0 +1,76 @@ +import pytest + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.skipif(not language_tests_enabled(Language.JULIA), reason="Julia tests are disabled (julia not available)") +@pytest.mark.julia +class TestJuliaLanguageServer: + @pytest.mark.parametrize("language_server", [Language.JULIA], indirect=True) + def test_julia_symbols(self, language_server: SolidLanguageServer): + """ + Test if we can find the top-level symbols in the main.jl file. + """ + all_symbols, _ = language_server.request_document_symbols("main.jl").get_all_symbols_and_roots() + symbol_names = {s["name"] for s in all_symbols} + assert "calculate_sum" in symbol_names + assert "main" in symbol_names + + @pytest.mark.parametrize("language_server", [Language.JULIA], indirect=True) + def test_julia_within_file_references(self, language_server: SolidLanguageServer): + """ + Test finding references to a function within the same file. + """ + # Find references to 'calculate_sum' - the function name starts at line 2, column 9 + # LSP uses 0-based indexing + references = language_server.request_references("main.jl", line=2, column=9) + + # Should find at least the definition and the call site + assert len(references) >= 1, f"Expected at least 1 reference, got {len(references)}" + + # Verify at least one reference is in main.jl + reference_paths = [ref["relativePath"] for ref in references] + assert "main.jl" in reference_paths + + @pytest.mark.parametrize("language_server", [Language.JULIA], indirect=True) + def test_julia_cross_file_references(self, language_server: SolidLanguageServer): + """ + Test finding references to a function defined in another file. + """ + # The 'say_hello' function name starts at line 1, column 13 in lib/helper.jl + # LSP uses 0-based indexing + references = language_server.request_references("lib/helper.jl", line=1, column=13) + + # Should find at least the call site in main.jl + assert len(references) >= 1, f"Expected at least 1 reference, got {len(references)}" + + # Verify at least one reference points to the usage + reference_paths = [ref["relativePath"] for ref in references] + # The reference might be in either file (definition or usage) + assert "main.jl" in reference_paths or "lib/helper.jl" in reference_paths + + @pytest.mark.parametrize("language_server", [Language.JULIA], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) + + @pytest.mark.parametrize("language_server", [Language.JULIA], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.jl", + (), + min_count=1, + ) diff --git a/test/solidlsp/kotlin/test_kotlin_basic.py b/test/solidlsp/kotlin/test_kotlin_basic.py new file mode 100644 index 0000000..0ce7a61 --- /dev/null +++ b/test/solidlsp/kotlin/test_kotlin_basic.py @@ -0,0 +1,73 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from solidlsp.ls_utils import SymbolUtils +from test.conftest import language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +# Kotlin LSP (IntelliJ-based, pre-alpha v261) crashes on JVM restart under CI resource constraints +# (2 CPUs, 7GB RAM). First start succeeds but subsequent starts fail with cancelled (-32800). +# Tests pass reliably on developer machines. See PR #1061 for investigation details. +# (The CI quarantine lives centrally in test/conftest.py::_determine_disabled_languages.) +@pytest.mark.skipif(not language_tests_enabled(Language.KOTLIN), reason="Kotlin tests are disabled") +@pytest.mark.kotlin +class TestKotlinLanguageServer: + @pytest.mark.parametrize("language_server", [Language.KOTLIN], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main class not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils class not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model class not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.KOTLIN], indirect=True) + def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: + # Use correct Kotlin file paths + file_path = os.path.join("src", "main", "kotlin", "test_repo", "Utils.kt") + refs = language_server.request_references(file_path, 3, 12) + assert any("Main.kt" in ref.get("relativePath", "") for ref in refs), "Main should reference Utils.printHello" + + # Dynamically determine the correct line/column for the 'Model' class name + file_path = os.path.join("src", "main", "kotlin", "test_repo", "Model.kt") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + model_symbol = None + for sym in symbols[0]: + print(sym) + print("\n") + if sym.get("name") == "Model" and sym.get("kind") == 23: # 23 = Class + model_symbol = sym + break + assert model_symbol is not None, "Could not find 'Model' class symbol in Model.kt" + # Use selectionRange if present, otherwise fall back to range + if "selectionRange" in model_symbol: + sel_start = model_symbol["selectionRange"]["start"] + else: + sel_start = model_symbol["range"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert any("Main.kt" in ref.get("relativePath", "") for ref in refs), ( + "Main should reference Model (tried all positions in selectionRange)" + ) + + @pytest.mark.parametrize("language_server", [Language.KOTLIN], indirect=True) + def test_overview_methods(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main missing from overview" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils missing from overview" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model missing from overview" + + @pytest.mark.parametrize("language_server", [Language.KOTLIN], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s, period_allowed=s["kind"] == SymbolKind.File): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/kotlin/test_kotlin_diagnostics.py b/test/solidlsp/kotlin/test_kotlin_diagnostics.py new file mode 100644 index 0000000..c189825 --- /dev/null +++ b/test/solidlsp/kotlin/test_kotlin_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.kotlin +class TestKotlinDiagnostics: + @pytest.mark.parametrize("language_server", [Language.KOTLIN], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/main/kotlin/test_repo/DiagnosticsSample.kt", + (), + min_count=1, + ) diff --git a/test/solidlsp/latex/test_latex_basic.py b/test/solidlsp/latex/test_latex_basic.py new file mode 100644 index 0000000..36cc67d --- /dev/null +++ b/test/solidlsp/latex/test_latex_basic.py @@ -0,0 +1,37 @@ +"""Basic tests for the texlab-based LaTeX language server.""" + +import pytest + +from serena.symbol import LanguageServerSymbol +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + + +@pytest.mark.latex +class TestLatexLanguageServerBasics: + """Basic functionality of the LaTeX (texlab) language server.""" + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_document_symbols_are_sections(self, language_server: SolidLanguageServer) -> None: + """Sectioning commands should surface as document symbols.""" + symbols, _roots = language_server.request_document_symbols("main.tex").get_all_symbols_and_roots() + assert len(symbols) > 0, "Should find at least some symbols in main.tex" + + names = {s.get("name", "") for s in symbols} + for expected in ("Introduction", "Methods", "Conclusion"): + assert expected in names, f"Expected section '{expected}' among document symbols, got: {sorted(names)}" + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_subsection_symbol_present(self, language_server: SolidLanguageServer) -> None: + """A nested subsection should also be exposed as a symbol.""" + symbols, _roots = language_server.request_document_symbols("main.tex").get_all_symbols_and_roots() + names = {s.get("name", "") for s in symbols} + assert "Implementation Details" in names, f"Expected the subsection symbol, got: {sorted(names)}" + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_subsection_name_path_nests_under_section(self, language_server: SolidLanguageServer) -> None: + """A subsection's name path nests under its parent section ("section/subsection").""" + _symbols, roots = language_server.request_document_symbols("main.tex").get_all_symbols_and_roots() + matches = [m for root in roots for m in LanguageServerSymbol(root).find("Methods/Implementation Details")] + assert len(matches) == 1, matches + assert matches[0].get_name_path() == "Methods/Implementation Details" diff --git a/test/solidlsp/latex/test_latex_beamer.py b/test/solidlsp/latex/test_latex_beamer.py new file mode 100644 index 0000000..adc6dae --- /dev/null +++ b/test/solidlsp/latex/test_latex_beamer.py @@ -0,0 +1,59 @@ +r"""Beamer presentation support for the LaTeX (texlab) language server. + +Beamer frames surface as ``Frame: <title>`` document symbols nested under their +section, and ``\ref`` resolution works inside frames. +""" + +import pytest + +from serena.util.text_utils import find_text_coordinates +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import Location +from test.solidlsp.conftest import read_repo_file + +SLIDES = "slides.tex" + + +def _rel(location: Location) -> str: + relative_path = location["relativePath"] + assert relative_path is not None, location + return relative_path.replace("\\", "/") + + +@pytest.mark.latex +class TestLatexBeamer: + """texlab handling of a beamer presentation (sections and frames).""" + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_beamer_sections_and_frames_are_symbols(self, language_server: SolidLanguageServer) -> None: + r"""Beamer sections and ``\frametitle`` frames both surface as document symbols.""" + symbols, _roots = language_server.request_document_symbols(SLIDES).get_all_symbols_and_roots() + names = {s.get("name", "") for s in symbols} + for expected in ("Overview", "Results", "Frame: Introduction", "Frame: Methodology", "Frame: Findings"): + assert expected in names, f"Expected beamer symbol {expected!r}, got: {sorted(names)}" + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_beamer_frames_nest_under_sections(self, language_server: SolidLanguageServer) -> None: + r"""Frames are children of the section they appear in.""" + _symbols, roots = language_server.request_document_symbols(SLIDES).get_all_symbols_and_roots() + roots_by_name = {root.get("name"): root for root in roots} + + overview = roots_by_name.get("Overview") + assert overview is not None, [root.get("name") for root in roots] + overview_children = {child.get("name") for child in overview.get("children", [])} + assert overview_children == {"Frame: Introduction", "Frame: Methodology"}, overview_children + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_beamer_frame_ref_resolves_to_section(self, language_server: SolidLanguageServer) -> None: + r"""A ``\ref`` inside a beamer frame resolves to the section it targets.""" + content = read_repo_file(language_server, SLIDES) + ref = find_text_coordinates(content, r"see Section~\\ref\{(sec:results)\}", require_unique=True) + section = find_text_coordinates(content, r"\\section\{(Results)\}", require_unique=True) + assert ref is not None and section is not None + + definitions = language_server.request_definition(SLIDES, ref.line, ref.col) + + assert len(definitions) == 1, definitions + assert _rel(definitions[0]) == "slides.tex" + assert definitions[0]["range"]["start"]["line"] == section.line diff --git a/test/solidlsp/latex/test_latex_references.py b/test/solidlsp/latex/test_latex_references.py new file mode 100644 index 0000000..0cfd186 --- /dev/null +++ b/test/solidlsp/latex/test_latex_references.py @@ -0,0 +1,92 @@ +r"""Reference and definition resolution for the LaTeX (texlab) language server. + +Exercises within-file and cross-file ``\ref`` -> ``\label`` resolution and +``\cite`` -> BibTeX entry resolution over the latex test repository. +""" + +import os + +import pytest + +from serena.util.text_utils import find_text_coordinates +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import Location +from test.solidlsp.conftest import read_repo_file + +MAIN = "main.tex" +BACKGROUND = os.path.join("sections", "background.tex") +BIB = "references.bib" + + +def _coords(language_server: SolidLanguageServer, relative_path: str, regex: str): + coords = find_text_coordinates(read_repo_file(language_server, relative_path), regex, require_unique=True) + assert coords is not None, f"pattern {regex!r} not found in {relative_path}" + return coords + + +def _rel(location: Location) -> str: + relative_path = location["relativePath"] + assert relative_path is not None, location + return relative_path.replace("\\", "/") + + +@pytest.mark.latex +class TestLatexReferences: + """texlab reference/definition resolution within and across files.""" + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_within_file_ref_resolves_to_section(self, language_server: SolidLanguageServer) -> None: + r"""A ``\ref`` resolves to the sectioning command labelled in the same file.""" + ref = _coords(language_server, MAIN, r"forward to Section~\\ref\{(sec:methods)\}") + section = _coords(language_server, MAIN, r"\\section\{(Methods)\}") + + definitions = language_server.request_definition(MAIN, ref.line, ref.col) + + assert len(definitions) == 1, definitions + assert _rel(definitions[0]) == "main.tex" + assert definitions[0]["range"]["start"]["line"] == section.line + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_within_file_references_list_all_uses(self, language_server: SolidLanguageServer) -> None: + r"""Requesting references on a within-file label returns both ``\ref`` uses.""" + label = _coords(language_server, MAIN, r"\\label\{(sec:methods)\}") + + references = language_server.request_references(MAIN, label.line, label.col) + + assert {_rel(ref) for ref in references} == {"main.tex"} + assert len(references) == 2, references + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_cross_file_ref_resolves_across_files(self, language_server: SolidLanguageServer) -> None: + r"""A ``\ref`` in main.tex resolves to a ``\label`` defined in another file.""" + ref = _coords(language_server, MAIN, r"see Section~\\ref\{(sec:background)\}") + section = _coords(language_server, BACKGROUND, r"\\section\{(Background)\}") + + definitions = language_server.request_definition(MAIN, ref.line, ref.col) + + assert len(definitions) == 1, definitions + assert _rel(definitions[0]) == "sections/background.tex" + assert definitions[0]["range"]["start"]["line"] == section.line + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_cross_file_references_point_back_to_main(self, language_server: SolidLanguageServer) -> None: + r"""References on a cross-file label include the ``\ref`` site in main.tex.""" + label = _coords(language_server, BACKGROUND, r"\\label\{(sec:background)\}") + ref_in_main = _coords(language_server, MAIN, r"see Section~\\ref\{(sec:background)\}") + + references = language_server.request_references(BACKGROUND, label.line, label.col) + + assert any(_rel(ref) == "main.tex" and ref["range"]["start"]["line"] == ref_in_main.line for ref in references), references + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_citation_resolves_to_bib_entry(self, language_server: SolidLanguageServer) -> None: + r"""A ``\cite`` resolves to its entry in the BibTeX file.""" + cite = _coords(language_server, MAIN, r"Knuth~\\cite\{(knuth1984)\}") + entry = _coords(language_server, BIB, r"@book\{(knuth1984)") + + definitions = language_server.request_definition(MAIN, cite.line, cite.col) + + assert len(definitions) == 1, definitions + assert _rel(definitions[0]) == "references.bib" + assert definitions[0]["range"]["start"]["line"] == entry.line diff --git a/test/solidlsp/lean4/test_lean4_basic.py b/test/solidlsp/lean4/test_lean4_basic.py new file mode 100644 index 0000000..bb4883c --- /dev/null +++ b/test/solidlsp/lean4/test_lean4_basic.py @@ -0,0 +1,167 @@ +""" +Tests for Lean 4 Language Server integration with Serena. + +Tests prove that Serena's symbol tools can: +1. Start the Lean 4 language server +2. Discover all expected symbols with precise matching +3. Track within-file references +4. Track cross-file references + +Test Repository Structure: +- Helper.lean: Calculator structure, arithmetic functions (add, subtract), predicates (isPositive, absolute) +- Main.lean: Main entry point using Helper, plus multiply and calculate functions +""" + +import pytest + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled + +pytestmark = pytest.mark.skipif(not language_tests_enabled(Language.LEAN4), reason="Lean4 tests are disabled (lean not available)") + + +@pytest.mark.lean4 +class TestLean4LanguageServer: + @pytest.mark.parametrize("language_server", [Language.LEAN4], indirect=True) + def test_ls_is_running(self, language_server: SolidLanguageServer) -> None: + """Test that the Lean 4 language server starts successfully.""" + assert language_server.is_running() + + @pytest.mark.parametrize("language_server", [Language.LEAN4], indirect=True) + def test_helper_symbols(self, language_server: SolidLanguageServer) -> None: + """ + Test symbol discovery in Helper.lean. + + Verifies that Serena can identify: + - Structure definition (Calculator) + - All functions (add, subtract, isPositive, absolute) + """ + all_symbols, _ = language_server.request_document_symbols("Helper.lean").get_all_symbols_and_roots() + symbol_names = {s["name"] for s in all_symbols} + + expected_symbols = { + "Calculator", + "add", + "subtract", + "isPositive", + "absolute", + } + + missing = expected_symbols - symbol_names + assert not missing, f"Missing expected symbols in Helper.lean: {missing}" + + @pytest.mark.parametrize("language_server", [Language.LEAN4], indirect=True) + def test_main_symbols(self, language_server: SolidLanguageServer) -> None: + """ + Test symbol discovery in Main.lean. + + Verifies that Serena can identify locally defined functions. + """ + all_symbols, _ = language_server.request_document_symbols("Main.lean").get_all_symbols_and_roots() + symbol_names = {s["name"] for s in all_symbols} + + expected_symbols = { + "multiply", + "calculate", + "main", + } + + missing = expected_symbols - symbol_names + assert not missing, f"Missing expected symbols in Main.lean: {missing}" + + @pytest.mark.parametrize("language_server", [Language.LEAN4], indirect=True) + def test_within_file_references(self, language_server: SolidLanguageServer) -> None: + """ + Test within-file reference tracking for isPositive. + + isPositive is defined in Helper.lean line 11 (0-indexed) and used by absolute on line 15. + """ + # isPositive defined at line 11, column 4 + references = language_server.request_references("Helper.lean", line=11, column=4) + + assert len(references) >= 1, f"Expected at least 1 reference to isPositive (used in absolute), got {len(references)}" + + # Check that isPositive is referenced within Helper.lean at line 15 (absolute calls isPositive) + ref_locations = [(ref["relativePath"], ref["range"]["start"]["line"]) for ref in references] + helper_refs = [(path, line) for path, line in ref_locations if "Helper.lean" in path] + assert any(line == 15 for _, line in helper_refs), ( + f"Expected isPositive reference at Helper.lean:15 (in absolute), got: {ref_locations}" + ) + + @pytest.mark.parametrize("language_server", [Language.LEAN4], indirect=True) + def test_cross_file_references_add(self, language_server: SolidLanguageServer) -> None: + """ + Test cross-file reference tracking for add function. + + add is defined in Helper.lean line 5 (0-indexed) and used in Main.lean on lines 7 and 15. + """ + # add defined at line 5, column 4 + references = language_server.request_references("Helper.lean", line=5, column=4) + + assert len(references) >= 1, f"Expected at least 1 reference to add in Main.lean, got {len(references)}" + + # Check for references in Main.lean with specific lines + ref_locations = [(ref["relativePath"], ref["range"]["start"]["line"]) for ref in references] + main_refs = [(path, line) for path, line in ref_locations if "Main.lean" in path] + assert len(main_refs) >= 1, f"Expected at least 1 reference to add in Main.lean, got: {ref_locations}" + main_ref_lines = {line for _, line in main_refs} + # add is used in Main.lean line 7 (in calculate) and line 15 (in main) + assert 7 in main_ref_lines or 15 in main_ref_lines, ( + f"Expected add references at Main.lean lines 7 or 15, got lines: {main_ref_lines}" + ) + + @pytest.mark.parametrize("language_server", [Language.LEAN4], indirect=True) + def test_cross_file_references_calculator(self, language_server: SolidLanguageServer) -> None: + """ + Test cross-file reference tracking for Calculator structure. + + Calculator is defined in Helper.lean line 0 (0-indexed) and used in Main.lean lines 5 and 13. + """ + # Calculator defined at line 0, column 10 + references = language_server.request_references("Helper.lean", line=0, column=10) + + assert len(references) >= 1, f"Expected at least 1 reference to Calculator in Main.lean, got {len(references)}" + + ref_locations = [(ref["relativePath"], ref["range"]["start"]["line"]) for ref in references] + main_refs = [(path, line) for path, line in ref_locations if "Main.lean" in path] + assert len(main_refs) >= 1, f"Expected at least 1 reference to Calculator in Main.lean, got: {ref_locations}" + main_ref_lines = {line for _, line in main_refs} + # Calculator is used in Main.lean line 5 (calculate signature) and line 13 (let c : Calculator) + assert 5 in main_ref_lines or 13 in main_ref_lines, ( + f"Expected Calculator references at Main.lean lines 5 or 13, got lines: {main_ref_lines}" + ) + + @pytest.mark.parametrize("language_server", [Language.LEAN4], indirect=True) + def test_go_to_definition_within_file(self, language_server: SolidLanguageServer) -> None: + """ + Test go-to-definition within a file. + + In Main.lean line 19, calculate is called: 'match calculate c "multiply" 6 7 with'. + calculate is defined at Main.lean line 5. + """ + # calculate usage in Main.lean line 19, 'calculate' starts at col 8 + definitions = language_server.request_definition("Main.lean", line=19, column=8) + + assert len(definitions) >= 1, f"Expected at least 1 definition for calculate, got {len(definitions)}" + + def_location = definitions[0] + assert def_location["uri"].endswith("Main.lean"), f"Expected definition in Main.lean, got: {def_location['uri']}" + assert def_location["range"]["start"]["line"] == 5, f"Expected definition at line 5, got: {def_location['range']['start']['line']}" + + @pytest.mark.parametrize("language_server", [Language.LEAN4], indirect=True) + def test_go_to_definition_across_files(self, language_server: SolidLanguageServer) -> None: + """ + Test go-to-definition across files. + + In Main.lean line 15, add is called: 'add 5 3'. + add is defined in Helper.lean line 5. + """ + # add usage in Main.lean line 15, 'add' starts at col 19 + definitions = language_server.request_definition("Main.lean", line=15, column=19) + + assert len(definitions) >= 1, f"Expected at least 1 definition for add, got {len(definitions)}" + + def_location = definitions[0] + assert def_location["uri"].endswith("Helper.lean"), f"Expected definition in Helper.lean, got: {def_location['uri']}" + assert def_location["range"]["start"]["line"] == 5, f"Expected definition at line 5, got: {def_location['range']['start']['line']}" diff --git a/test/solidlsp/lean4/test_lean4_diagnostics.py b/test/solidlsp/lean4/test_lean4_diagnostics.py new file mode 100644 index 0000000..11eac57 --- /dev/null +++ b/test/solidlsp/lean4/test_lean4_diagnostics.py @@ -0,0 +1,20 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled +from test.solidlsp.util.diagnostics import assert_file_diagnostics + +pytestmark = pytest.mark.skipif(not language_tests_enabled(Language.LEAN4), reason="Lean4 tests are disabled (lean not available)") + + +@pytest.mark.lean4 +class TestLean4Diagnostics: + @pytest.mark.parametrize("language_server", [Language.LEAN4], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "DiagnosticsSample.lean", + (), + min_count=1, + ) diff --git a/test/solidlsp/lua/test_lua_basic.py b/test/solidlsp/lua/test_lua_basic.py new file mode 100644 index 0000000..5465fda --- /dev/null +++ b/test/solidlsp/lua/test_lua_basic.py @@ -0,0 +1,302 @@ +""" +Tests for the Lua language server implementation. + +These tests validate symbol finding and cross-file reference capabilities +for Lua modules and functions. +""" + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.lua +class TestLuaLanguageServer: + """Test Lua language server symbol finding and cross-file references.""" + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_find_symbols_in_calculator(self, language_server: SolidLanguageServer) -> None: + """Test finding specific functions in calculator.lua.""" + symbols = language_server.request_document_symbols("src/calculator.lua").get_all_symbols_and_roots() + + assert symbols is not None + assert len(symbols) > 0 + + # Extract function names from the returned structure + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + function_names = set() + for symbol in symbol_list: + if isinstance(symbol, dict): + name = symbol.get("name", "") + # Handle both plain names and module-prefixed names + if "." in name: + name = name.split(".")[-1] + if symbol.get("kind") == SymbolKind.Function: + function_names.add(name) + + # Verify exact calculator functions exist + expected_functions = {"add", "subtract", "multiply", "divide", "factorial"} + found_functions = function_names & expected_functions + assert found_functions == expected_functions, f"Expected exactly {expected_functions}, found {found_functions}" + + # Verify specific functions + assert "add" in function_names, "add function not found" + assert "multiply" in function_names, "multiply function not found" + assert "factorial" in function_names, "factorial function not found" + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_find_symbols_in_utils(self, language_server: SolidLanguageServer) -> None: + """Test finding specific functions in utils.lua.""" + symbols = language_server.request_document_symbols("src/utils.lua").get_all_symbols_and_roots() + + assert symbols is not None + assert len(symbols) > 0 + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + function_names = set() + all_symbols = set() + + for symbol in symbol_list: + if isinstance(symbol, dict): + name = symbol.get("name", "") + all_symbols.add(name) + # Handle both plain names and module-prefixed names + if "." in name: + name = name.split(".")[-1] + if symbol.get("kind") == SymbolKind.Function: + function_names.add(name) + + # Verify exact string utility functions + expected_utils = {"trim", "split", "starts_with", "ends_with"} + found_utils = function_names & expected_utils + assert found_utils == expected_utils, f"Expected exactly {expected_utils}, found {found_utils}" + + # Verify exact table utility functions + table_utils = {"deep_copy", "table_contains", "table_merge"} + found_table_utils = function_names & table_utils + assert found_table_utils == table_utils, f"Expected exactly {table_utils}, found {found_table_utils}" + + # Check for Logger class/table + assert "Logger" in all_symbols or any("Logger" in s for s in all_symbols), "Logger not found in symbols" + + if language_has_verified_implementation_support(Language.LUA): + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.LUA) + pos = find_identifier_position(repo_path / "src" / "animals.lua", "speak") + assert pos is not None, "Could not find Animal:speak in fixture" + + implementations = language_server.request_implementation("src/animals.lua", *pos) + assert implementations, "Expected at least one implementation of Animal:speak" + assert any("animals.lua" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected Dog:speak in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.LUA) + pos = find_identifier_position(repo_path / "src" / "animals.lua", "speak") + assert pos is not None, "Could not find Animal:speak in fixture" + + implementing_symbols = language_server.request_implementing_symbols("src/animals.lua", *pos) + assert implementing_symbols, "Expected implementing symbols for Animal:speak" + assert any( + symbol.get("name") == "speak" and "animals.lua" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected Dog:speak symbol, got: {implementing_symbols}" + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_find_symbols_in_main(self, language_server: SolidLanguageServer) -> None: + """Test finding functions in main.lua.""" + symbols = language_server.request_document_symbols("main.lua").get_all_symbols_and_roots() + + assert symbols is not None + assert len(symbols) > 0 + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + function_names = set() + + for symbol in symbol_list: + if isinstance(symbol, dict) and symbol.get("kind") == SymbolKind.Function: + function_names.add(symbol.get("name", "")) + + # Verify exact main functions exist + expected_funcs = {"print_banner", "test_calculator", "test_utils"} + found_funcs = function_names & expected_funcs + assert found_funcs == expected_funcs, f"Expected exactly {expected_funcs}, found {found_funcs}" + + assert "test_calculator" in function_names, "test_calculator function not found" + assert "test_utils" in function_names, "test_utils function not found" + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_cross_file_references_calculator_add(self, language_server: SolidLanguageServer) -> None: + """Test finding cross-file references to calculator.add function.""" + symbols = language_server.request_document_symbols("src/calculator.lua").get_all_symbols_and_roots() + + assert symbols is not None + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + # Find the add function + add_symbol = None + for sym in symbol_list: + if isinstance(sym, dict): + name = sym.get("name", "") + if "add" in name or name == "add": + add_symbol = sym + break + + assert add_symbol is not None, "add function not found in calculator.lua" + + # Get references to the add function + range_info = add_symbol.get("selectionRange", add_symbol.get("range")) + assert range_info is not None, "add function has no range information" + + range_start = range_info["start"] + refs = language_server.request_references("src/calculator.lua", range_start["line"], range_start["character"]) + + assert refs is not None + assert isinstance(refs, list) + # add function appears in: main.lua (lines 17, 78), test_calculator.lua (lines 22, 23, 24) + # Note: The declaration itself may or may not be included as a reference + assert len(refs) >= 5, f"Should find at least 5 references to calculator.add, found {len(refs)}" + + # Verify exact reference locations + ref_files: dict[str, list[int]] = {} + for ref in refs: + filename = ref.get("uri", "").split("/")[-1] + if filename not in ref_files: + ref_files[filename] = [] + ref_files[filename].append(ref["range"]["start"]["line"]) + + # The declaration may or may not be included + if "calculator.lua" in ref_files: + assert 5 in ref_files["calculator.lua"], ( + f"If declaration is included, it should be at line 6 (0-indexed: 5), found at {ref_files['calculator.lua']}" + ) + + # Check main.lua has usages + assert "main.lua" in ref_files, "Should find add usages in main.lua" + assert 16 in ref_files["main.lua"] or 77 in ref_files["main.lua"], ( + f"Should find add usage in main.lua, found at lines {ref_files.get('main.lua', [])}" + ) + + # Check for cross-file references from main.lua + main_refs = [ref for ref in refs if "main.lua" in ref.get("uri", "")] + assert len(main_refs) > 0, "calculator.add should be called in main.lua" + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_cross_file_references_utils_trim(self, language_server: SolidLanguageServer) -> None: + """Test finding cross-file references to utils.trim function.""" + symbols = language_server.request_document_symbols("src/utils.lua").get_all_symbols_and_roots() + + assert symbols is not None + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + # Find the trim function + trim_symbol = None + for sym in symbol_list: + if isinstance(sym, dict): + name = sym.get("name", "") + if "trim" in name or name == "trim": + trim_symbol = sym + break + + assert trim_symbol is not None, "trim function not found in utils.lua" + + # Get references to the trim function + range_info = trim_symbol.get("selectionRange", trim_symbol.get("range")) + assert range_info is not None, "trim function has no range information" + + range_start = range_info["start"] + refs = language_server.request_references("src/utils.lua", range_start["line"], range_start["character"]) + + assert refs is not None + assert isinstance(refs, list) + # trim function appears in: usage (line 33 in main.lua) + # Note: The declaration itself may or may not be included as a reference + assert len(refs) >= 1, f"Should find at least 1 reference to utils.trim, found {len(refs)}" + + # Verify exact reference locations + ref_files: dict[str, list[int]] = {} + for ref in refs: + filename = ref.get("uri", "").split("/")[-1] + if filename not in ref_files: + ref_files[filename] = [] + ref_files[filename].append(ref["range"]["start"]["line"]) + + # The declaration may or may not be included + if "utils.lua" in ref_files: + assert 5 in ref_files["utils.lua"], ( + f"If declaration is included, it should be at line 6 (0-indexed: 5), found at {ref_files['utils.lua']}" + ) + + # Check main.lua has usage + assert "main.lua" in ref_files, "Should find trim usage in main.lua" + assert 32 in ref_files["main.lua"], ( + f"Should find trim usage at line 33 (0-indexed: 32) in main.lua, found at lines {ref_files.get('main.lua', [])}" + ) + + # Check for cross-file references from main.lua + main_refs = [ref for ref in refs if "main.lua" in ref.get("uri", "")] + assert len(main_refs) > 0, "utils.trim should be called in main.lua" + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_hover_information(self, language_server: SolidLanguageServer) -> None: + """Test hover information for symbols.""" + # Get hover info for a function + hover_info = language_server.request_hover("src/calculator.lua", 5, 10) # Position near add function + + assert hover_info is not None, "Should provide hover information" + + # Hover info could be a dict with 'contents' or a string + if isinstance(hover_info, dict): + assert "contents" in hover_info or "value" in hover_info, "Hover should have contents" + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_full_symbol_tree(self, language_server: SolidLanguageServer) -> None: + """Test that full symbol tree is not empty.""" + symbols = language_server.request_full_symbol_tree() + + assert symbols is not None + assert len(symbols) > 0, "Symbol tree should not be empty" + + # The tree should have at least one root node + root = symbols[0] + assert isinstance(root, dict), "Root should be a dict" + assert "name" in root, "Root should have a name" + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_references_between_test_and_source(self, language_server: SolidLanguageServer) -> None: + """Test finding references from test files to source files.""" + # Check if test_calculator.lua references calculator module + test_symbols = language_server.request_document_symbols("tests/test_calculator.lua").get_all_symbols_and_roots() + + assert test_symbols is not None + assert len(test_symbols) > 0 + + # The test file should have some content that references calculator + symbol_list = test_symbols[0] if isinstance(test_symbols, tuple) else test_symbols + assert len(symbol_list) > 0, "test_calculator.lua should have symbols" + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name( + s, + whitespace_allowed=s["name"] == " ", + period_allowed="." in s["name"], + colon_allowed=":" in s["name"], + ): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/luau/__init__.py b/test/solidlsp/luau/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/solidlsp/luau/test_luau_basic.py b/test/solidlsp/luau/test_luau_basic.py new file mode 100644 index 0000000..4564e25 --- /dev/null +++ b/test/solidlsp/luau/test_luau_basic.py @@ -0,0 +1,167 @@ +""" +Tests for the Luau language server implementation. + +These tests validate symbol finding, within-file references, +and cross-file reference capabilities for Luau modules and functions. +""" + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.luau +class TestLuauLanguageServer: + """Test Luau language server symbol finding and cross-file references.""" + + @pytest.mark.parametrize("language_server", [Language.LUAU], indirect=True) + def test_find_symbols_in_init(self, language_server: SolidLanguageServer) -> None: + """Test finding specific functions in init.luau.""" + symbols = language_server.request_document_symbols("src/init.luau").get_all_symbols_and_roots() + + assert symbols is not None + assert len(symbols) > 0 + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + symbol_names = set() + for symbol in symbol_list: + if isinstance(symbol, dict): + name = symbol.get("name", "") + symbol_names.add(name) + + assert "createConfig" in symbol_names, f"createConfig not found in symbols: {symbol_names}" + assert "main" in symbol_names, f"main not found in symbols: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.LUAU], indirect=True) + def test_find_symbols_in_module(self, language_server: SolidLanguageServer) -> None: + """Test finding specific functions in module.luau.""" + symbols = language_server.request_document_symbols("src/module.luau").get_all_symbols_and_roots() + + assert symbols is not None + assert len(symbols) > 0 + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + symbol_names = set() + for symbol in symbol_list: + if isinstance(symbol, dict): + name = symbol.get("name", "") + symbol_names.add(name) + + assert "process" in symbol_names, f"process not found in symbols: {symbol_names}" + assert "helper" in symbol_names, f"helper not found in symbols: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.LUAU], indirect=True) + def test_find_references_within_file(self, language_server: SolidLanguageServer) -> None: + """Test finding within-file references to createConfig in init.luau. + + createConfig is defined at line 8 (0-indexed) and referenced at lines 17 and 23. + """ + symbols = language_server.request_document_symbols("src/init.luau").get_all_symbols_and_roots() + + assert symbols is not None + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + # Find the createConfig function symbol + create_config_symbol = None + for sym in symbol_list: + if isinstance(sym, dict) and sym.get("name") == "createConfig": + create_config_symbol = sym + break + + assert create_config_symbol is not None, "createConfig function not found in init.luau" + + range_info = create_config_symbol.get("selectionRange", create_config_symbol.get("range")) + assert range_info is not None, "createConfig has no range information" + + range_start = range_info["start"] + refs = language_server.request_references("src/init.luau", range_start["line"], range_start["character"]) + + assert refs is not None + assert isinstance(refs, list) + # createConfig appears multiple times within init.luau: + # definition (line 8), usage in main (line 17), and return table (line 23) + assert len(refs) >= 2, f"Should find at least 2 references to createConfig, found {len(refs)}" + + # Verify that references are in init.luau + ref_files = set() + for ref in refs: + filename = ref.get("uri", "").split("/")[-1] + ref_files.add(filename) + + assert "init.luau" in ref_files, f"Expected references in init.luau, found in: {ref_files}" + + @pytest.mark.parametrize("language_server", [Language.LUAU], indirect=True) + def test_find_references_across_files(self, language_server: SolidLanguageServer) -> None: + """Test finding cross-file references to process function. + + process is defined in module.luau and used in init.luau via module.process(). + """ + symbols = language_server.request_document_symbols("src/module.luau").get_all_symbols_and_roots() + + assert symbols is not None + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + # Find the process function symbol + process_symbol = None + for sym in symbol_list: + if isinstance(sym, dict) and sym.get("name") == "process": + process_symbol = sym + break + + assert process_symbol is not None, "process function not found in module.luau" + + range_info = process_symbol.get("selectionRange", process_symbol.get("range")) + assert range_info is not None, "process function has no range information" + + range_start = range_info["start"] + refs = language_server.request_references("src/module.luau", range_start["line"], range_start["character"]) + + assert refs is not None + assert isinstance(refs, list) + assert len(refs) >= 1, f"Should find at least 1 reference to process, found {len(refs)}" + + # Collect reference files and lines + ref_info: dict[str, list[int]] = {} + for ref in refs: + filename = ref.get("uri", "").split("/")[-1] + if filename not in ref_info: + ref_info[filename] = [] + ref_info[filename].append(ref["range"]["start"]["line"]) + + # The definition in module.luau may or may not be included + # We expect at least the reference in module.luau return table (line 9) + assert "module.luau" in ref_info, f"Expected references in module.luau, found in: {set(ref_info.keys())}" + + @pytest.mark.parametrize("language_server", [Language.LUAU], indirect=True) + def test_find_definition(self, language_server: SolidLanguageServer) -> None: + """Test finding definition of createConfig from its usage in main(). + + createConfig is used at line 17, column 20 (0-indexed) in init.luau. + Its definition should be at line 8 in init.luau. + """ + # Line 17 (0-indexed): ` local config = createConfig("test", 42)` + # createConfig starts at column 20 + definition_locations = language_server.request_definition("src/init.luau", 17, 20) + + assert definition_locations, f"Expected non-empty definition list but got {definition_locations}" + assert len(definition_locations) >= 1 + + definition = definition_locations[0] + assert definition["uri"].endswith("init.luau"), f"Definition should be in init.luau, got: {definition['uri']}" + # createConfig is defined at line 8 (0-indexed): `local function createConfig(...)` + assert definition["range"]["start"]["line"] == 8, f"Definition should be at line 8, got line {definition['range']['start']['line']}" + + @pytest.mark.parametrize("language_server", [Language.LUAU], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/luau/test_luau_dependency_provider.py b/test/solidlsp/luau/test_luau_dependency_provider.py new file mode 100644 index 0000000..d274364 --- /dev/null +++ b/test/solidlsp/luau/test_luau_dependency_provider.py @@ -0,0 +1,165 @@ +"""Tests for the Luau language server dependency provider.""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from solidlsp.language_servers.luau_lsp import LuauLanguageServer +from solidlsp.settings import SolidLSPSettings + + +def _make_provider( + tmp_path: Path, + custom_settings: dict[str, str] | None = None, +) -> LuauLanguageServer.DependencyProvider: + return LuauLanguageServer.DependencyProvider( + custom_settings=SolidLSPSettings.CustomLSSettings(custom_settings or {}), + ls_resources_dir=str(tmp_path), + ) + + +@pytest.mark.luau +class TestLuauDependencyProvider: + def test_create_launch_command_uses_ls_path_override_and_adds_assets(self, tmp_path: Path) -> None: + provider = _make_provider(tmp_path, {"ls_path": "/custom/luau-lsp"}) + + with patch.object( + provider, + "_get_or_install_core_dependency", + side_effect=AssertionError("_get_or_install_core_dependency should not be called when ls_path is set"), + ): + with patch.object( + provider, + "_resolve_support_files", + return_value=("/tmp/globalTypes.d.luau", "/tmp/en-us.json"), + ): + assert provider.create_launch_command() == [ + "/custom/luau-lsp", + "lsp", + "--definitions:@roblox=/tmp/globalTypes.d.luau", + "--docs=/tmp/en-us.json", + ] + + def test_resolve_support_files_defaults_to_roblox_mode(self, tmp_path: Path) -> None: + provider = _make_provider(tmp_path) + + with patch.object( + provider, + "_download_roblox_support_files", + return_value=("/tmp/globalTypes.PluginSecurity.d.luau", "/tmp/en-us.json"), + ) as download_roblox_support_files: + with patch.object( + provider, + "_download_standard_docs", + side_effect=AssertionError("_download_standard_docs should not be called in roblox mode"), + ): + assert provider._resolve_support_files() == ( + "/tmp/globalTypes.PluginSecurity.d.luau", + "/tmp/en-us.json", + ) + + download_roblox_support_files.assert_called_once_with("PluginSecurity") + + def test_resolve_support_files_uses_standard_mode_docs_only(self, tmp_path: Path) -> None: + provider = _make_provider(tmp_path, {"platform": "standard"}) + + with patch.object(provider, "_download_standard_docs", return_value="/tmp/luau-en-us.json") as download_standard_docs: + with patch.object( + provider, + "_download_roblox_support_files", + side_effect=AssertionError("_download_roblox_support_files should not be called in standard mode"), + ): + assert provider._resolve_support_files() == ( + None, + "/tmp/luau-en-us.json", + ) + + download_standard_docs.assert_called_once_with() + + def test_get_or_install_core_dependency_uses_system_binary(self, tmp_path: Path) -> None: + provider = _make_provider(tmp_path) + + with patch("solidlsp.language_servers.luau_lsp.shutil.which", return_value="/usr/bin/luau-lsp"): + with patch.object( + provider, + "_download_luau_lsp", + side_effect=AssertionError("_download_luau_lsp should not be called when luau-lsp is on PATH"), + ): + assert provider._get_or_install_core_dependency() == "/usr/bin/luau-lsp" + + def test_download_luau_lsp_extracts_binary_into_ls_resources_dir(self, tmp_path: Path) -> None: + provider = _make_provider(tmp_path) + + def fake_extract( + url: str, + target_path: str, + archive_type: str, + expected_sha256: str | None = None, + allowed_hosts: tuple[str, ...] | list[str] | None = None, + ) -> None: + del url, archive_type, expected_sha256, allowed_hosts + nested_dir = Path(target_path) / "nested" + nested_dir.mkdir(parents=True, exist_ok=True) + (nested_dir / "luau-lsp").write_text("#!/bin/sh\n", encoding="utf-8") + + with patch("solidlsp.language_servers.luau_lsp.platform.system", return_value="Linux"): + with patch("solidlsp.language_servers.luau_lsp.platform.machine", return_value="aarch64"): + with patch( + "solidlsp.language_servers.luau_lsp.FileUtils.download_and_extract_archive_verified", + side_effect=fake_extract, + ): + binary_path = provider._download_luau_lsp() + + resolved_binary = Path(binary_path) + assert resolved_binary.exists() + assert resolved_binary.name == "luau-lsp" + assert str(resolved_binary).startswith(str(tmp_path)) + + def test_download_roblox_support_files_writes_into_ls_resources_dir(self, tmp_path: Path) -> None: + provider = _make_provider(tmp_path) + + def fake_download( + url: str, target_path: str, expected_sha256: str | None = None, allowed_hosts: tuple[str, ...] | list[str] | None = None + ) -> None: + del expected_sha256, allowed_hosts + if "type-definitions" in url: + Path(target_path).write_bytes(b"types") + else: + Path(target_path).write_bytes(b"docs") + + with patch("solidlsp.language_servers.luau_lsp.FileUtils.download_file_verified", side_effect=fake_download): + definitions_path, docs_path = provider._download_roblox_support_files("LocalUserSecurity") + + assert definitions_path == str(tmp_path / "globalTypes.LocalUserSecurity.d.luau") + assert docs_path == str(tmp_path / "en-us.json") + assert (tmp_path / "globalTypes.LocalUserSecurity.d.luau").read_bytes() == b"types" + assert (tmp_path / "en-us.json").read_bytes() == b"docs" + + def test_download_standard_docs_writes_into_ls_resources_dir(self, tmp_path: Path) -> None: + provider = _make_provider(tmp_path, {"platform": "standard"}) + + with patch( + "solidlsp.language_servers.luau_lsp.FileUtils.download_file_verified", + side_effect=lambda url, target_path, expected_sha256=None, allowed_hosts=None: Path(target_path).write_bytes(b"docs"), + ): + docs_path = provider._download_standard_docs() + + assert docs_path == str(tmp_path / "luau-en-us.json") + assert (tmp_path / "luau-en-us.json").read_bytes() == b"docs" + + def test_workspace_configuration_uses_configured_platform(self) -> None: + config = LuauLanguageServer._get_workspace_configuration(SolidLSPSettings.CustomLSSettings({"platform": "standard"})) + assert config == {"platform": {"type": "standard"}} + + def test_invalid_platform_raises(self, tmp_path: Path) -> None: + provider = _make_provider(tmp_path, {"platform": "invalid"}) + + with pytest.raises(ValueError, match="Unsupported Luau platform"): + provider._resolve_support_files() + + def test_invalid_roblox_security_level_raises(self, tmp_path: Path) -> None: + provider = _make_provider(tmp_path, {"roblox_security_level": "invalid"}) + + with pytest.raises(ValueError, match="Unsupported Luau Roblox security level"): + provider._resolve_support_files() diff --git a/test/solidlsp/markdown/__init__.py b/test/solidlsp/markdown/__init__.py new file mode 100644 index 0000000..9f2adc1 --- /dev/null +++ b/test/solidlsp/markdown/__init__.py @@ -0,0 +1 @@ +"""Tests for markdown language server functionality.""" diff --git a/test/solidlsp/markdown/test_markdown_basic.py b/test/solidlsp/markdown/test_markdown_basic.py new file mode 100644 index 0000000..34f5753 --- /dev/null +++ b/test/solidlsp/markdown/test_markdown_basic.py @@ -0,0 +1,109 @@ +""" +Basic integration tests for the markdown language server functionality. + +These tests validate the functionality of the language server APIs +like request_document_symbols using the markdown test repository. +""" + +import pytest + +from serena.symbol import LanguageServerSymbol +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.markdown +class TestMarkdownLanguageServerBasics: + """Test basic functionality of the markdown language server.""" + + @pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True) + def test_markdown_language_server_initialization(self, language_server: SolidLanguageServer) -> None: + """Test that markdown language server can be initialized successfully.""" + assert language_server is not None + assert language_server.language == Language.MARKDOWN + + @pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True) + def test_markdown_request_document_symbols(self, language_server: SolidLanguageServer) -> None: + """Test request_document_symbols for markdown files.""" + all_symbols, _root_symbols = language_server.request_document_symbols("README.md").get_all_symbols_and_roots() + + heading_names = [symbol["name"] for symbol in all_symbols] + + # Should detect headings from README.md + assert "Test Repository" in heading_names or len(all_symbols) > 0, "Should find at least one heading" + + # Verify that markdown headings are remapped from String to Namespace + for symbol in all_symbols: + assert symbol["kind"] == SymbolKind.Namespace, ( + f"Heading '{symbol['name']}' should have kind Namespace, got {SymbolKind(symbol['kind']).name}" + ) + + @pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True) + def test_markdown_request_symbols_from_guide(self, language_server: SolidLanguageServer) -> None: + """Test symbol detection in guide.md file.""" + all_symbols, _root_symbols = language_server.request_document_symbols("guide.md").get_all_symbols_and_roots() + + # At least some headings should be found + assert len(all_symbols) > 0, f"Should find headings in guide.md, found {len(all_symbols)}" + + @pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True) + def test_markdown_request_symbols_from_api(self, language_server: SolidLanguageServer) -> None: + """Test symbol detection in api.md file.""" + all_symbols, _root_symbols = language_server.request_document_symbols("api.md").get_all_symbols_and_roots() + + # Should detect headings from api.md + assert len(all_symbols) > 0, f"Should find headings in api.md, found {len(all_symbols)}" + + @pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True) + def test_markdown_request_document_symbols_with_body(self, language_server: SolidLanguageServer) -> None: + """Test request_document_symbols with body extraction.""" + all_symbols, _root_symbols = language_server.request_document_symbols("README.md").get_all_symbols_and_roots() + + # Should have found some symbols + assert len(all_symbols) > 0, "Should find symbols in README.md" + + # Note: Not all markdown LSPs provide body information for symbols + # This test is more lenient and just verifies the API works + assert all_symbols is not None, "Should return symbols even if body extraction is limited" + + @pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True) + def test_markdown_headings_not_low_level(self, language_server: SolidLanguageServer) -> None: + """Test that markdown headings are not classified as low-level symbols. + + Verifies the fix for the issue where Marksman's SymbolKind.String (15) + caused all headings to be filtered out of get_symbols_overview. + """ + all_symbols, _root_symbols = language_server.request_document_symbols("README.md").get_all_symbols_and_roots() + assert len(all_symbols) > 0, "Should find headings in README.md" + + for symbol in all_symbols: + ls_symbol = LanguageServerSymbol(symbol) + assert not ls_symbol.is_low_level(), ( + f"Heading '{symbol['name']}' should not be low-level (kind={SymbolKind(symbol['kind']).name})" + ) + + @pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True) + def test_markdown_nested_headings_remapped(self, language_server: SolidLanguageServer) -> None: + """Test that nested headings (h1-h5) are all remapped from String to Namespace.""" + all_symbols, _root_symbols = language_server.request_document_symbols("api.md").get_all_symbols_and_roots() + + # api.md has deeply nested headings (h1 through h5) + assert len(all_symbols) > 5, "api.md should have many headings" + + for symbol in all_symbols: + assert symbol["kind"] == SymbolKind.Namespace, f"Nested heading '{symbol['name']}' should be remapped to Namespace" + + @pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s, whitespace_allowed=True, parenthesis_allowed=True): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/matlab/__init__.py b/test/solidlsp/matlab/__init__.py new file mode 100644 index 0000000..cf1fe4a --- /dev/null +++ b/test/solidlsp/matlab/__init__.py @@ -0,0 +1 @@ +# MATLAB language server tests diff --git a/test/solidlsp/matlab/test_matlab_basic.py b/test/solidlsp/matlab/test_matlab_basic.py new file mode 100644 index 0000000..4857a44 --- /dev/null +++ b/test/solidlsp/matlab/test_matlab_basic.py @@ -0,0 +1,129 @@ +""" +Basic integration tests for the MATLAB language server functionality. + +These tests validate the functionality of the language server APIs +like request_document_symbols using the MATLAB test repository. + +Requirements: + - MATLAB R2021b or later must be installed + - MATLAB_PATH environment variable should be set to MATLAB installation directory + - Node.js and npm must be installed +""" + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics + +# Skip all tests if MATLAB is not available +pytestmark = pytest.mark.matlab + + +@pytest.mark.skipif(not language_tests_enabled(Language.MATLAB), reason="MATLAB tests are disabled (MATLAB installation not found)") +class TestMatlabLanguageServerBasics: + """Test basic functionality of the MATLAB language server.""" + + @pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True) + def test_matlab_language_server_initialization(self, language_server: SolidLanguageServer) -> None: + """Test that MATLAB language server can be initialized successfully.""" + assert language_server is not None + assert language_server.language == Language.MATLAB + + @pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True) + def test_matlab_request_document_symbols_class(self, language_server: SolidLanguageServer) -> None: + """Test request_document_symbols for MATLAB class file.""" + # Test getting symbols from Calculator.m (class file) + all_symbols, _root_symbols = language_server.request_document_symbols("Calculator.m").get_all_symbols_and_roots() + + # Extract class symbols (LSP Symbol Kind 5 for class) + class_symbols = [symbol for symbol in all_symbols if symbol.get("kind") == 5] + class_names = [symbol["name"] for symbol in class_symbols] + + # Should find the Calculator class + assert "Calculator" in class_names, "Should find Calculator class" + + # Extract method symbols (LSP Symbol Kind 6 for method or 12 for function) + method_symbols = [symbol for symbol in all_symbols if symbol.get("kind") in [6, 12]] + method_names = [symbol["name"] for symbol in method_symbols] + + # Should find key methods + expected_methods = ["add", "subtract", "multiply", "divide"] + for method in expected_methods: + assert method in method_names, f"Should find {method} method in Calculator class" + + @pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True) + def test_matlab_request_document_symbols_function(self, language_server: SolidLanguageServer) -> None: + """Test request_document_symbols for MATLAB function file.""" + # Test getting symbols from lib/mathUtils.m (function file) + all_symbols, _root_symbols = language_server.request_document_symbols("lib/mathUtils.m").get_all_symbols_and_roots() + + # Extract function symbols (LSP Symbol Kind 12 for function) + function_symbols = [symbol for symbol in all_symbols if symbol.get("kind") == 12] + function_names = [symbol["name"] for symbol in function_symbols] + + # Should find the main mathUtils function + assert "mathUtils" in function_names, "Should find mathUtils function" + + # Should also find nested/local functions + expected_local_functions = ["computeFactorial", "computeFibonacci", "checkPrime", "computeStats"] + for func in expected_local_functions: + assert func in function_names, f"Should find {func} local function" + + @pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True) + def test_matlab_request_document_symbols_script(self, language_server: SolidLanguageServer) -> None: + """Test request_document_symbols for MATLAB script file.""" + # Test getting symbols from main.m (script file) + all_symbols, _root_symbols = language_server.request_document_symbols("main.m").get_all_symbols_and_roots() + + # Scripts may have variables and sections, but less structured symbols + # Just verify we can get symbols without errors + assert all_symbols is not None + + +@pytest.mark.skipif(not language_tests_enabled(Language.MATLAB), reason="MATLAB tests are disabled (MATLAB installation not found)") +class TestMatlabLanguageServerReferences: + """Test find references functionality of the MATLAB language server.""" + + @pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True) + def test_matlab_find_references_within_file(self, language_server: SolidLanguageServer) -> None: + """Test finding references within a single MATLAB file.""" + # Find references to 'result' variable in Calculator.m + # This is a basic test to verify references work + references = language_server.request_references("Calculator.m", 25, 12) # 'result' in add method + + # Should find at least the definition + assert references is not None + + @pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True) + def test_matlab_find_references_cross_file(self, language_server: SolidLanguageServer) -> None: + """Test finding references across MATLAB files.""" + # Find references to Calculator class used in main.m + references = language_server.request_references("main.m", 11, 8) # 'Calculator' reference + + # Should find references in both main.m and Calculator.m + assert references is not None + + @pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) + + @pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.m", + (), + min_count=1, + ) diff --git a/test/solidlsp/msl/__init__.py b/test/solidlsp/msl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/solidlsp/msl/test_msl_basic.py b/test/solidlsp/msl/test_msl_basic.py new file mode 100644 index 0000000..92a4cd4 --- /dev/null +++ b/test/solidlsp/msl/test_msl_basic.py @@ -0,0 +1,161 @@ +""" +Basic integration tests for the mSL (mIRC Scripting Language) language server. + +Tests validate document symbols, references, and definitions for aliases, events, +raw events, menus, dialogs, and CTCP handlers using the mSL test repository. +""" + +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + +pytestmark = [pytest.mark.msl] + + +class TestMslDocumentSymbols: + """Test document symbol retrieval for mSL constructs.""" + + @pytest.mark.parametrize("language_server", [Language.MSL], indirect=True) + def test_ls_is_running(self, language_server: SolidLanguageServer) -> None: + """Test that the language server starts successfully.""" + assert language_server.is_running() + + @pytest.mark.parametrize("language_server", [Language.MSL], indirect=True) + def test_document_symbols_main(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols are returned for the main file.""" + doc_symbols = language_server.request_document_symbols("main.mrc") + all_symbols, root_symbols = doc_symbols.get_all_symbols_and_roots() + + symbol_names = [s.get("name") for s in all_symbols if s.get("name")] + assert "greet" in symbol_names, f"greet alias not found. Found: {symbol_names}" + assert "calculate.doubloons" in symbol_names, f"calculate.doubloons alias not found. Found: {symbol_names}" + assert "show.player.info" in symbol_names, f"show.player.info alias not found. Found: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.MSL], indirect=True) + def test_document_symbols_events(self, language_server: SolidLanguageServer) -> None: + """Test that event handlers, raw events, and menus are detected in the main file.""" + doc_symbols = language_server.request_document_symbols("main.mrc") + all_symbols, root_symbols = doc_symbols.get_all_symbols_and_roots() + + symbol_names = [s.get("name") for s in all_symbols if s.get("name")] + # Check for on *:TEXT and on *:JOIN events + on_events = [n for n in symbol_names if n.startswith("on ")] + assert len(on_events) >= 2, f"Expected at least 2 event handlers. Found: {on_events}" + # Check for raw event + raw_events = [n for n in symbol_names if n.startswith("raw ")] + assert len(raw_events) >= 1, f"Expected at least 1 raw event handler. Found: {raw_events}" + # Check for menu + menus = [n for n in symbol_names if n.startswith("menu ")] + assert len(menus) >= 1, f"Expected at least 1 menu. Found: {menus}" + + @pytest.mark.parametrize("language_server", [Language.MSL], indirect=True) + def test_document_symbols_utils(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols are returned for the utils file.""" + doc_symbols = language_server.request_document_symbols("utils.mrc") + all_symbols, root_symbols = doc_symbols.get_all_symbols_and_roots() + + symbol_names = [s.get("name") for s in all_symbols if s.get("name")] + assert "format.coins" in symbol_names, f"format.coins alias not found. Found: {symbol_names}" + assert "is.admin" in symbol_names, f"is.admin alias not found. Found: {symbol_names}" + assert "welcome.message" in symbol_names, f"welcome.message alias not found. Found: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.MSL], indirect=True) + def test_document_symbols_dialog_and_ctcp(self, language_server: SolidLanguageServer) -> None: + """Test that dialog and CTCP handler definitions are detected.""" + doc_symbols = language_server.request_document_symbols("utils.mrc") + all_symbols, root_symbols = doc_symbols.get_all_symbols_and_roots() + + symbol_names = [s.get("name") for s in all_symbols if s.get("name")] + assert "dialog settings" in symbol_names, f"dialog settings not found. Found: {symbol_names}" + # Check for CTCP handler + ctcp_events = [n for n in symbol_names if n.startswith("ctcp ")] + assert len(ctcp_events) >= 1, f"Expected at least 1 ctcp handler. Found: {ctcp_events}" + + @pytest.mark.parametrize("language_server", [Language.MSL], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + """Test that the full symbol tree contains expected symbols from both files.""" + from solidlsp.ls_utils import SymbolUtils + + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "greet"), "greet not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "format.coins"), "format.coins not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "show.player.info"), "show.player.info not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.MSL], indirect=True) + def test_bare_symbol_names(self, language_server: SolidLanguageServer) -> None: + """Test that symbol names do not contain unexpected formatting characters.""" + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + # mSL symbols can contain periods (e.g., calculate.doubloons) and + # colons/spaces in event names (e.g., "on *:TEXT"), so allow those + if has_malformed_name(s, period_allowed=True, colon_allowed=True, whitespace_allowed=True): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) + + @pytest.mark.parametrize("language_server", [Language.MSL], indirect=True) + def test_find_references_within_file(self, language_server: SolidLanguageServer) -> None: + """Test that references to 'greet' are found within main.mrc.""" + file_path = "main.mrc" + all_symbols, _ = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + greet_symbol = next((s for s in all_symbols if s.get("name") == "greet"), None) + assert greet_symbol is not None, "Could not find 'greet' symbol in main.mrc" + + sel_start = greet_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + + assert refs, f"Expected non-empty references for greet but got {refs=}" + + actual_locations = [ + { + "uri_suffix": os.path.basename(ref.get("relativePath", ref.get("uri", ""))), + "line": ref["range"]["start"]["line"], + } + for ref in refs + ] + + # greet is called on line 13 (0-indexed) in main.mrc: `greet $nick` + call_site = {"uri_suffix": "main.mrc", "line": 13} + assert call_site in actual_locations, f"Expected reference to greet at line 13 in main.mrc, got {actual_locations}" + + @pytest.mark.parametrize("language_server", [Language.MSL], indirect=True) + def test_find_references_across_files(self, language_server: SolidLanguageServer) -> None: + """Test that references to 'format.coins' are found across main.mrc and utils.mrc.""" + # format.coins is defined in utils.mrc but called in both main.mrc and utils.mrc + file_path = "utils.mrc" + all_symbols, _ = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + fc_symbol = next((s for s in all_symbols if s.get("name") == "format.coins"), None) + assert fc_symbol is not None, "Could not find 'format.coins' symbol in utils.mrc" + + sel_start = fc_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + + assert refs, f"Expected non-empty references for format.coins but got {refs=}" + + actual_locations = [ + { + "uri_suffix": os.path.basename(ref.get("relativePath", ref.get("uri", ""))), + "line": ref["range"]["start"]["line"], + } + for ref in refs + ] + + # Verify cross-file: at least one reference is in main.mrc (different file from definition) + main_refs = [loc for loc in actual_locations if loc["uri_suffix"] == "main.mrc"] + assert len(main_refs) >= 1, f"Expected at least 1 reference in main.mrc, got {main_refs}" + + @pytest.mark.parametrize("language_server", [Language.MSL], indirect=True) + def test_workspace_symbol(self, language_server: SolidLanguageServer) -> None: + """Test that workspace symbol search returns results.""" + result = language_server.request_workspace_symbol("greet") + assert result is not None, "Workspace symbol search returned None" + assert len(result) > 0, "Workspace symbol search returned no results" + assert any("greet" in str(s.get("name", "")) for s in result), f"Expected at least one result containing 'greet', got {result}" diff --git a/test/solidlsp/nix/test_nix_basic.py b/test/solidlsp/nix/test_nix_basic.py new file mode 100644 index 0000000..789d047 --- /dev/null +++ b/test/solidlsp/nix/test_nix_basic.py @@ -0,0 +1,259 @@ +""" +Tests for the Nix language server implementation using nixd. + +These tests validate symbol finding and cross-file reference capabilities for Nix expressions. +""" + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from test.conftest import is_ci, language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics + +pytestmark = pytest.mark.skipif(not language_tests_enabled(Language.NIX), reason="Nix tests are disabled (nixd not available)") + + +@pytest.mark.nix +class TestNixLanguageServer: + """Test Nix language server symbol finding capabilities.""" + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_find_symbols_in_default_nix(self, language_server: SolidLanguageServer) -> None: + """Test finding specific symbols in default.nix.""" + symbols = language_server.request_document_symbols("default.nix").get_all_symbols_and_roots() + + assert symbols is not None + assert len(symbols) > 0 + + # Extract symbol names from the returned structure + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} + + # Verify specific function exists + assert "makeGreeting" in symbol_names, "makeGreeting function not found" + + # Verify exact attribute sets are found + expected_attrs = {"listUtils", "stringUtils"} + found_attrs = symbol_names & expected_attrs + assert found_attrs == expected_attrs, f"Expected exactly {expected_attrs}, found {found_attrs}" + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_find_symbols_in_utils(self, language_server: SolidLanguageServer) -> None: + """Test finding symbols in lib/utils.nix.""" + symbols = language_server.request_document_symbols("lib/utils.nix").get_all_symbols_and_roots() + + assert symbols is not None + assert len(symbols) > 0 + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} + + # Verify exact utility modules are found + expected_modules = {"math", "strings", "lists", "attrs"} + found_modules = symbol_names & expected_modules + assert found_modules == expected_modules, f"Expected exactly {expected_modules}, found {found_modules}" + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_find_symbols_in_flake(self, language_server: SolidLanguageServer) -> None: + """Test finding symbols in flake.nix.""" + symbols = language_server.request_document_symbols("flake.nix").get_all_symbols_and_roots() + + assert symbols is not None + assert len(symbols) > 0 + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} + + # Flakes must have either inputs or outputs + assert "inputs" in symbol_names or "outputs" in symbol_names, "Flake must have inputs or outputs" + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_find_symbols_in_module(self, language_server: SolidLanguageServer) -> None: + """Test finding symbols in a NixOS module.""" + symbols = language_server.request_document_symbols("modules/example.nix").get_all_symbols_and_roots() + + assert symbols is not None + assert len(symbols) > 0 + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} + + # NixOS modules must have either options or config + assert "options" in symbol_names or "config" in symbol_names, "Module must have options or config" + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_find_references_within_file(self, language_server: SolidLanguageServer) -> None: + """Test finding references within the same file.""" + symbols = language_server.request_document_symbols("default.nix").get_all_symbols_and_roots() + + assert symbols is not None + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + # Find makeGreeting function + greeting_symbol = None + for sym in symbol_list: + if sym.get("name") == "makeGreeting": + greeting_symbol = sym + break + + assert greeting_symbol is not None, "makeGreeting function not found" + assert "range" in greeting_symbol, "Symbol must have range information" + + range_start = greeting_symbol["range"]["start"] + refs = language_server.request_references("default.nix", range_start["line"], range_start["character"]) + + assert refs is not None + assert isinstance(refs, list) + # nixd finds at least the inherit statement (line 67) + assert len(refs) >= 1, f"Should find at least 1 reference to makeGreeting, found {len(refs)}" + + # Verify makeGreeting is referenced at expected locations + if refs: + ref_lines = sorted([ref["range"]["start"]["line"] for ref in refs]) + # Check if we found the inherit (line 67, 0-indexed: 66) + assert 66 in ref_lines, f"Should find makeGreeting inherit at line 67, found at lines {[l + 1 for l in ref_lines]}" + + @pytest.mark.xfail(is_ci, reason="Test is flaky") # TODO: Re-enable if the hover test becomes more stable (#1040) + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_hover_information(self, language_server: SolidLanguageServer) -> None: + """Test hover information for symbols.""" + # Get hover info for makeGreeting function + hover_info = language_server.request_hover("default.nix", 12, 5) # Position at makeGreeting + + assert hover_info is not None, "Should provide hover information" + + if isinstance(hover_info, dict) and len(hover_info) > 0: + # If hover info is provided, it should have proper structure + assert "contents" in hover_info or "value" in hover_info, "Hover should have contents or value" + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_cross_file_references_utils_import(self, language_server: SolidLanguageServer) -> None: + """Test finding cross-file references for imported utils.""" + # Find references to 'utils' which is imported in default.nix from lib/utils.nix + # Line 10 in default.nix: utils = import ./lib/utils.nix { inherit lib; }; + refs = language_server.request_references("default.nix", 9, 2) # Position of 'utils' + + assert refs is not None + assert isinstance(refs, list) + + # Should find references within default.nix where utils is used + default_refs = [ref for ref in refs if "default.nix" in ref.get("uri", "")] + # utils is: imported (line 10), used in listUtils.unique (line 24), inherited in exports (line 69) + assert len(default_refs) >= 2, f"Should find at least 2 references to utils in default.nix, found {len(default_refs)}" + + # Verify utils is referenced at expected locations (0-indexed) + if default_refs: + ref_lines = sorted([ref["range"]["start"]["line"] for ref in default_refs]) + # Check for key references - at least the import (line 10) or usage (line 24) + assert 9 in ref_lines or 23 in ref_lines, ( + f"Should find utils import or usage, found references at lines {[l + 1 for l in ref_lines]}" + ) + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_verify_imports_exist(self, language_server: SolidLanguageServer) -> None: + """Verify that our test files have proper imports set up.""" + # Verify that default.nix imports utils from lib/utils.nix + symbols = language_server.request_document_symbols("default.nix").get_all_symbols_and_roots() + + assert symbols is not None + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + # Check that makeGreeting exists (defined in default.nix) + symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} + assert "makeGreeting" in symbol_names, "makeGreeting should be found in default.nix" + + # Verify lib/utils.nix has the expected structure + utils_symbols = language_server.request_document_symbols("lib/utils.nix").get_all_symbols_and_roots() + assert utils_symbols is not None + utils_list = utils_symbols[0] if isinstance(utils_symbols, tuple) else utils_symbols + utils_names = {sym.get("name") for sym in utils_list if isinstance(sym, dict)} + + # Verify key functions exist in utils + assert "math" in utils_names, "math should be found in lib/utils.nix" + assert "strings" in utils_names, "strings should be found in lib/utils.nix" + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_go_to_definition_cross_file(self, language_server: SolidLanguageServer) -> None: + """Test go-to-definition from default.nix to lib/utils.nix.""" + # Line 24 in default.nix: unique = utils.lists.unique; + # Test go-to-definition for 'utils' + definitions = language_server.request_definition("default.nix", 23, 14) # Position of 'utils' + + assert definitions is not None + assert isinstance(definitions, list) + + if len(definitions) > 0: + # Should point to the import statement or utils.nix + assert any("utils" in def_item.get("uri", "") or "default.nix" in def_item.get("uri", "") for def_item in definitions), ( + "Definition should relate to utils import or utils.nix file" + ) + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_definition_navigation_in_flake(self, language_server: SolidLanguageServer) -> None: + """Test definition navigation in flake.nix.""" + # Test that we can navigate to definitions within flake.nix + # Line 69: default = hello-custom; + definitions = language_server.request_definition("flake.nix", 68, 20) # Position of 'hello-custom' + + assert definitions is not None + assert isinstance(definitions, list) + # nixd should find the definition of hello-custom in the same file + if len(definitions) > 0: + assert any("flake.nix" in def_item.get("uri", "") for def_item in definitions), ( + "Should find hello-custom definition in flake.nix" + ) + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_full_symbol_tree(self, language_server: SolidLanguageServer) -> None: + """Test that full symbol tree is not empty.""" + symbols = language_server.request_full_symbol_tree() + + assert symbols is not None + assert len(symbols) > 0, "Symbol tree should not be empty" + + # The tree should have at least one root node + root = symbols[0] + assert isinstance(root, dict), "Root should be a dict" + assert "name" in root, "Root should have a name" + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + # nixd can surface anonymous expression nodes using synthetic display labels + # like "(anonymous lambda)", "(dynamic string)", "{anonymous}", and + # "(dynamic attribute name)". It also emits literal value nodes such as + # strings and arrays. These do not correspond to named declarations, so + # there is no bare identifier we could or should normalize them to in the + # Nix wrapper. This test is only meant to enforce bare names for symbols + # that actually have declaration names. + if s["kind"] in {SymbolKind.String, SymbolKind.Array}: + continue + if s["name"] in {"(anonymous lambda)", "(dynamic string)", "{anonymous}", "(dynamic attribute name)"}: + continue + if s["kind"] in {SymbolKind.Field, SymbolKind.Property} and "." in s["name"]: + continue + if has_malformed_name( + s, + whitespace_allowed=s["name"] == "(anonymous lambda)", + parenthesis_allowed=s["name"] == "(anonymous lambda)", + ): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.nix", + (), + min_count=1, + ) diff --git a/test/solidlsp/ocaml/test_cross_file_refs.py b/test/solidlsp/ocaml/test_cross_file_refs.py new file mode 100644 index 0000000..44c72e4 --- /dev/null +++ b/test/solidlsp/ocaml/test_cross_file_refs.py @@ -0,0 +1,77 @@ +""" +Test cross-file references for OCaml. + +Cross-file references require OCaml >= 5.2 and ocaml-lsp-server >= 1.23.0. +On environments without these (e.g. Windows CI with OCaml 4.14), only +same-file references are asserted. +""" + +import logging +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.language_servers.ocaml_lsp_server import OcamlLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled + +log = logging.getLogger(__name__) + + +@pytest.mark.skipif(not language_tests_enabled(Language.OCAML), reason="OCaml tests are disabled (opam not available)") +@pytest.mark.ocaml +class TestCrossFileReferences: + @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) + def test_fib_has_cross_file_references(self, language_server: SolidLanguageServer) -> None: + """Test that fib function references are found across multiple files. + + The `fib` function is defined in lib/test_repo.ml and used in: + - lib/test_repo.ml (definition + 2 recursive calls) + - bin/main.ml (1 call) + - test/test_test_repo.ml (5 references) + + Total: 9 references across 3 files. + """ + file_path = os.path.join("lib", "test_repo.ml") + + fib_line = 7 + fib_char = 8 + + refs = language_server.request_references(file_path, fib_line, fib_char) + + lib_refs = [ref for ref in refs if "lib/test_repo.ml" in ref.get("uri", "")] + bin_refs = [ref for ref in refs if "bin/main.ml" in ref.get("uri", "")] + test_refs = [ref for ref in refs if "test/test_test_repo.ml" in ref.get("uri", "")] + + log.info("Cross-file references result:") + log.info(f"Total references found: {len(refs)}") + log.info(f" lib/test_repo.ml: {len(lib_refs)}") + log.info(f" bin/main.ml: {len(bin_refs)}") + log.info(f" test/test_test_repo.ml: {len(test_refs)}") + + for ref in refs: + uri = ref.get("uri", "") + filename = uri.split("/")[-1] + line = ref.get("range", {}).get("start", {}).get("line", -1) + log.info(f" {filename}:{line}") + + # Same-file references always work + assert len(lib_refs) >= 3, f"Expected at least 3 references in lib/test_repo.ml (definition + 2 recursive), but got {len(lib_refs)}" + + # Cross-file references require OCaml >= 5.2 and ocaml-lsp-server >= 1.23.0 + if isinstance(language_server, OcamlLanguageServer) and language_server.supports_cross_file_references: + assert len(refs) >= 9, ( + f"Expected at least 9 total references (3 in lib + 1 in bin + 5 in test), " + f"but got {len(refs)}. Cross-file references are NOT working!" + ) + + assert len(bin_refs) >= 1, ( + f"Expected at least 1 reference in bin/main.ml, but got {len(bin_refs)}. " + "Cross-file references are NOT working - bin/main.ml not found!" + ) + + assert len(test_refs) >= 1, ( + f"Expected at least 1 reference in test/test_test_repo.ml, but got {len(test_refs)}. " + "Cross-file references are NOT working - test file not found!" + ) diff --git a/test/solidlsp/ocaml/test_ocaml_basic.py b/test/solidlsp/ocaml/test_ocaml_basic.py new file mode 100644 index 0000000..86d3a87 --- /dev/null +++ b/test/solidlsp/ocaml/test_ocaml_basic.py @@ -0,0 +1,169 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.conftest import language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.skipif(not language_tests_enabled(Language.OCAML), reason="OCaml tests are disabled (opam not available)") +@pytest.mark.ocaml +class TestOCamlLanguageServer: + @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "DemoModule"), "DemoModule not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "fib"), "fib not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "someFunction"), "someFunction function not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) + def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("lib", "test_repo.ml") + + # Use the correct character position for 'fib' function name + # Line 8: "let rec fib n =" - 'fib' starts at character 8 (0-indexed) + fib_line = 7 # 0-indexed line number + fib_char = 8 # 0-indexed character position + + refs = language_server.request_references(file_path, fib_line, fib_char) + + # Should find at least 3 references: definition + 2 recursive calls in same file + assert len(refs) >= 3, f"Expected at least 3 references to fib (definition + 2 recursive), found {len(refs)}" + + # All references should be in lib/test_repo.ml (same file as definition) + # Use forward slashes for URI matching (URIs always use /) + lib_refs = [ref for ref in refs if "lib/test_repo.ml" in ref.get("uri", "")] + assert len(lib_refs) >= 3, f"Expected at least 3 references in lib/test_repo.ml, found {len(lib_refs)}" + + @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) + def test_mixed_ocaml_modules(self, language_server: SolidLanguageServer) -> None: + """Test that the language server can find symbols from OCaml modules""" + # Test that full symbol tree includes symbols from various file types + all_symbols = language_server.request_full_symbol_tree() + + # Should find symbols from main OCaml files + assert SymbolUtils.symbol_tree_contains_name(all_symbols, "fib"), "Should find fib from .ml file" + assert SymbolUtils.symbol_tree_contains_name(all_symbols, "DemoModule"), "Should find DemoModule from .ml file" + assert SymbolUtils.symbol_tree_contains_name(all_symbols, "someFunction"), "Should find someFunction from DemoModule" + assert SymbolUtils.symbol_tree_contains_name(all_symbols, "num_domains"), "Should find num_domains constant" + + def test_reason_file_patterns(self) -> None: + """Test that OCaml language configuration recognizes Reason file extensions""" + ocaml_lang = Language.OCAML + file_matcher = ocaml_lang.get_source_fn_matcher() + + # Test OCaml extensions + assert file_matcher.is_relevant_filename("test.ml"), "Should match .ml files" + assert file_matcher.is_relevant_filename("test.mli"), "Should match .mli files" + + # Test Reason extensions + assert file_matcher.is_relevant_filename("test.re"), "Should match .re files" + assert file_matcher.is_relevant_filename("test.rei"), "Should match .rei files" + + # Test non-matching extensions + assert not file_matcher.is_relevant_filename("test.py"), "Should not match .py files" + assert not file_matcher.is_relevant_filename("test.js"), "Should not match .js files" + + @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) + def test_module_hierarchy_navigation(self, language_server: SolidLanguageServer) -> None: + """Test navigation within module hierarchy including DemoModule.""" + file_path = os.path.join("lib", "test_repo.ml") + + # Use correct position for 'DemoModule' (line 1, char 7) + # Line 1: "module DemoModule = struct" - 'DemoModule' starts around char 7 + module_line = 0 # 0-indexed + module_char = 7 # 0-indexed + + refs = language_server.request_references(file_path, module_line, module_char) + + # Should find at least 1 reference (the definition) + assert len(refs) >= 1, f"Expected at least 1 reference to DemoModule, found {len(refs)}" + + # Check that references are found - use forward slashes for URI matching + lib_refs = [ref for ref in refs if "lib/test_repo.ml" in ref.get("uri", "")] + assert len(lib_refs) >= 1, f"Expected at least 1 reference in lib/test_repo.ml, found {len(lib_refs)}" + + @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) + def test_let_binding_references(self, language_server: SolidLanguageServer) -> None: + """Test finding references to let-bound values across files.""" + file_path = os.path.join("lib", "test_repo.ml") + + # Use correct position for 'num_domains' (line 12, char 4) + # Line 12: "let num_domains = 2" - 'num_domains' starts around char 4 + num_domains_line = 11 # 0-indexed + num_domains_char = 4 # 0-indexed + + refs = language_server.request_references(file_path, num_domains_line, num_domains_char) + + # Should find at least 1 reference (the definition) + assert len(refs) >= 1, f"Expected at least 1 reference to num_domains, found {len(refs)}" + + # Check that reference is found in the definition file - use forward slashes + ml_refs = [ref for ref in refs if "lib/test_repo.ml" in ref.get("uri", "")] + assert len(ml_refs) >= 1, f"Expected at least 1 reference in lib/test_repo.ml, found {len(ml_refs)}" + + @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) + def test_recursive_function_analysis(self, language_server: SolidLanguageServer) -> None: + """Test that recursive function calls are properly identified within the definition file.""" + file_path = os.path.join("lib", "test_repo.ml") + + # Use correct position for 'fib' function name (line 8, char 8) + fib_line = 7 # 0-indexed + fib_char = 8 # 0-indexed + + refs = language_server.request_references(file_path, fib_line, fib_char) + + # Filter to references within the definition file only - use forward slashes + same_file_refs = [ref for ref in refs if "lib/test_repo.ml" in ref.get("uri", "")] + + # Should find at least 3 references in test_repo.ml: definition + 2 recursive calls + # On OCaml 5.2+ with cross-file refs, there may be more total refs but same-file count stays the same + assert len(same_file_refs) >= 3, ( + f"Expected at least 3 references in test_repo.ml (definition + 2 recursive), found {len(same_file_refs)}" + ) + + # Verify references are on different lines (definition + recursive calls) + ref_lines = [ref.get("range", {}).get("start", {}).get("line", -1) for ref in same_file_refs] + unique_lines = len(set(ref_lines)) + assert unique_lines >= 2, f"Recursive calls should appear on multiple lines, found {unique_lines} unique lines" + + @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) + def test_open_statement_resolution(self, language_server: SolidLanguageServer) -> None: + """Test that open statements allow unqualified access to module contents.""" + # In bin/main.ml, fib is called without Test_repo prefix due to 'open Test_repo' + all_symbols = language_server.request_full_symbol_tree() + + # Should be able to find fib through symbol tree + fib_accessible = SymbolUtils.symbol_tree_contains_name(all_symbols, "fib") + assert fib_accessible, "fib should be accessible through open statement" + + # DemoModule should also be accessible + demo_module_accessible = SymbolUtils.symbol_tree_contains_name(all_symbols, "DemoModule") + assert demo_module_accessible, "DemoModule should be accessible" + + # Verify we have access to both qualified and unqualified symbols + assert len(all_symbols) > 0, "Should find symbols from OCaml files" + + # Test that the language server recognizes the open statement context + file_path = os.path.join("bin", "main.ml") + symbols, _roots = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + assert len(symbols) > 0, "Should find symbols in main.ml that use opened modules" + + @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + # happens for lambdas a la let () = ... + if s["name"] == "()": + continue + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/ocaml/test_ocaml_diagnostics.py b/test/solidlsp/ocaml/test_ocaml_diagnostics.py new file mode 100644 index 0000000..3817f22 --- /dev/null +++ b/test/solidlsp/ocaml/test_ocaml_diagnostics.py @@ -0,0 +1,19 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.skipif(not language_tests_enabled(Language.OCAML), reason="OCaml tests are disabled (opam not available)") +@pytest.mark.ocaml +class TestOcamlDiagnostics: + @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "lib/diagnostics_sample.ml", + (), + min_count=1, + ) diff --git a/test/solidlsp/pascal/__init__.py b/test/solidlsp/pascal/__init__.py new file mode 100644 index 0000000..b9d1f3d --- /dev/null +++ b/test/solidlsp/pascal/__init__.py @@ -0,0 +1,15 @@ +def _check_pascal_available() -> bool: + """Check if Pascal language server (pasls) is available. + + Note: pasls will be auto-downloaded if not present, so Pascal + support is always available. + """ + return True + + +PASCAL_AVAILABLE = _check_pascal_available() + + +def is_pascal_available() -> bool: + """Return True if Pascal language server can be used.""" + return PASCAL_AVAILABLE diff --git a/test/solidlsp/pascal/test_pascal_auto_update.py b/test/solidlsp/pascal/test_pascal_auto_update.py new file mode 100644 index 0000000..2b6e223 --- /dev/null +++ b/test/solidlsp/pascal/test_pascal_auto_update.py @@ -0,0 +1,255 @@ +""" +Unit tests for the Pascal language server auto-update functionality. + +These tests validate the version comparison, checksum verification, +and other helper methods without requiring network access or the +actual Pascal language server. +""" + +from __future__ import annotations + +import hashlib +import os +import tarfile +import tempfile +import time + +import pytest + +from solidlsp.language_servers.pascal_server import PascalLanguageServer + +pytestmark = [pytest.mark.pascal] + + +class TestVersionNormalization: + """Test version string normalization.""" + + def test_normalize_version_with_v_prefix(self) -> None: + """Test that 'v' prefix is stripped.""" + assert PascalLanguageServer._normalize_version("v1.0.0") == "1.0.0" + + def test_normalize_version_with_capital_v_prefix(self) -> None: + """Test that 'V' prefix is stripped.""" + assert PascalLanguageServer._normalize_version("V1.0.0") == "1.0.0" + + def test_normalize_version_without_prefix(self) -> None: + """Test version without prefix is unchanged.""" + assert PascalLanguageServer._normalize_version("1.0.0") == "1.0.0" + + def test_normalize_version_with_whitespace(self) -> None: + """Test that whitespace is stripped.""" + assert PascalLanguageServer._normalize_version(" v1.0.0 ") == "1.0.0" + + def test_normalize_version_empty(self) -> None: + """Test empty version returns empty string.""" + assert PascalLanguageServer._normalize_version("") == "" + + def test_normalize_version_none(self) -> None: + """Test None returns empty string.""" + assert PascalLanguageServer._normalize_version(None) == "" + + +class TestVersionComparison: + """Test version comparison logic.""" + + def test_newer_version_major(self) -> None: + """Test detection of newer major version.""" + assert PascalLanguageServer._is_newer_version("v2.0.0", "v1.0.0") is True + + def test_newer_version_minor(self) -> None: + """Test detection of newer minor version.""" + assert PascalLanguageServer._is_newer_version("v1.1.0", "v1.0.0") is True + + def test_newer_version_patch(self) -> None: + """Test detection of newer patch version.""" + assert PascalLanguageServer._is_newer_version("v1.0.1", "v1.0.0") is True + + def test_same_version(self) -> None: + """Test same version returns False.""" + assert PascalLanguageServer._is_newer_version("v1.0.0", "v1.0.0") is False + + def test_older_version(self) -> None: + """Test older version returns False.""" + assert PascalLanguageServer._is_newer_version("v1.0.0", "v2.0.0") is False + + def test_latest_none_returns_false(self) -> None: + """Test None latest version returns False.""" + assert PascalLanguageServer._is_newer_version(None, "v1.0.0") is False + + def test_local_none_returns_true(self) -> None: + """Test None local version returns True (first install).""" + assert PascalLanguageServer._is_newer_version("v1.0.0", None) is True + + def test_both_none_returns_false(self) -> None: + """Test both None returns False.""" + assert PascalLanguageServer._is_newer_version(None, None) is False + + def test_version_with_different_lengths(self) -> None: + """Test versions with different number of parts.""" + assert PascalLanguageServer._is_newer_version("v1.0.1", "v1.0") is True + assert PascalLanguageServer._is_newer_version("v1.0", "v1.0.1") is False + + def test_version_with_prerelease(self) -> None: + """Test versions with prerelease suffixes.""" + # Prerelease suffix is ignored, only numeric parts are compared + assert PascalLanguageServer._is_newer_version("v1.1.0-beta", "v1.0.0") is True + + +class TestSHA256Checksum: + """Test SHA256 checksum calculation and verification.""" + + def test_calculate_sha256(self) -> None: + """Test SHA256 calculation for a known content.""" + with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f: + f.write(b"test content") + temp_path = f.name + + try: + result = PascalLanguageServer._calculate_sha256(temp_path) + expected = hashlib.sha256(b"test content").hexdigest() + assert result == expected + finally: + os.unlink(temp_path) + + def test_verify_checksum_correct(self) -> None: + """Test checksum verification with correct checksum.""" + with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f: + f.write(b"test content") + temp_path = f.name + + try: + expected = hashlib.sha256(b"test content").hexdigest() + assert PascalLanguageServer._verify_checksum(temp_path, expected) is True + finally: + os.unlink(temp_path) + + def test_verify_checksum_incorrect(self) -> None: + """Test checksum verification with incorrect checksum.""" + with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f: + f.write(b"test content") + temp_path = f.name + + try: + wrong_checksum = "0" * 64 + assert PascalLanguageServer._verify_checksum(temp_path, wrong_checksum) is False + finally: + os.unlink(temp_path) + + def test_verify_checksum_case_insensitive(self) -> None: + """Test checksum verification is case insensitive.""" + with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f: + f.write(b"test content") + temp_path = f.name + + try: + expected = hashlib.sha256(b"test content").hexdigest().upper() + assert PascalLanguageServer._verify_checksum(temp_path, expected) is True + finally: + os.unlink(temp_path) + + +class TestTarfileSafety: + """Test tarfile path traversal protection.""" + + def test_safe_tar_member_normal_path(self) -> None: + """Test normal path is considered safe.""" + member = tarfile.TarInfo(name="pasls") + assert PascalLanguageServer._is_safe_tar_member(member, "/tmp/target") is True + + def test_safe_tar_member_nested_path(self) -> None: + """Test nested path is considered safe.""" + member = tarfile.TarInfo(name="subdir/pasls") + assert PascalLanguageServer._is_safe_tar_member(member, "/tmp/target") is True + + def test_unsafe_tar_member_path_traversal(self) -> None: + """Test path traversal is detected.""" + member = tarfile.TarInfo(name="../etc/passwd") + assert PascalLanguageServer._is_safe_tar_member(member, "/tmp/target") is False + + def test_unsafe_tar_member_hidden_traversal(self) -> None: + """Test hidden path traversal in nested path.""" + member = tarfile.TarInfo(name="subdir/../../etc/passwd") + assert PascalLanguageServer._is_safe_tar_member(member, "/tmp/target") is False + + def test_safe_tar_member_similar_name(self) -> None: + """Test path containing '..' in filename (not directory) is safe.""" + member = tarfile.TarInfo(name="file..name") + assert PascalLanguageServer._is_safe_tar_member(member, "/tmp/target") is True + + +class TestMetadataManagement: + """Test metadata directory and file management.""" + + def test_meta_dir_creates_directory(self) -> None: + """Test _meta_dir creates directory if not exists.""" + with tempfile.TemporaryDirectory() as temp_dir: + meta_path = PascalLanguageServer._meta_dir(temp_dir) + assert os.path.exists(meta_path) + assert meta_path == os.path.join(temp_dir, PascalLanguageServer.META_DIR) + + def test_meta_file_returns_correct_path(self) -> None: + """Test _meta_file returns correct path.""" + with tempfile.TemporaryDirectory() as temp_dir: + meta_file = PascalLanguageServer._meta_file(temp_dir, "version") + expected = os.path.join(temp_dir, PascalLanguageServer.META_DIR, "version") + assert meta_file == expected + + +class TestUpdateCheckTiming: + """Test update check timing logic.""" + + def test_should_check_update_no_last_check(self) -> None: + """Test should check when no last_check file exists.""" + with tempfile.TemporaryDirectory() as temp_dir: + assert PascalLanguageServer._should_check_update(temp_dir) is True + + def test_should_check_update_recent_check(self) -> None: + """Test should not check when recently checked.""" + with tempfile.TemporaryDirectory() as temp_dir: + # Create meta dir and last_check file with current time + meta_dir = PascalLanguageServer._meta_dir(temp_dir) + last_check_file = os.path.join(meta_dir, "last_check") + with open(last_check_file, "w") as f: + f.write(str(time.time())) + + assert PascalLanguageServer._should_check_update(temp_dir) is False + + def test_should_check_update_old_check(self) -> None: + """Test should check when last check was > 24 hours ago.""" + with tempfile.TemporaryDirectory() as temp_dir: + # Create meta dir and last_check file with old time + meta_dir = PascalLanguageServer._meta_dir(temp_dir) + last_check_file = os.path.join(meta_dir, "last_check") + old_time = time.time() - (PascalLanguageServer.UPDATE_CHECK_INTERVAL + 3600) + with open(last_check_file, "w") as f: + f.write(str(old_time)) + + assert PascalLanguageServer._should_check_update(temp_dir) is True + + def test_update_last_check_creates_file(self) -> None: + """Test _update_last_check creates timestamp file.""" + with tempfile.TemporaryDirectory() as temp_dir: + PascalLanguageServer._update_last_check(temp_dir) + last_check_file = PascalLanguageServer._meta_file(temp_dir, "last_check") + assert os.path.exists(last_check_file) + + with open(last_check_file) as f: + timestamp = float(f.read().strip()) + assert abs(timestamp - time.time()) < 5 # within 5 seconds + + +class TestVersionPersistence: + """Test local version persistence.""" + + def test_save_and_get_local_version(self) -> None: + """Test saving and retrieving local version.""" + with tempfile.TemporaryDirectory() as temp_dir: + PascalLanguageServer._save_local_version(temp_dir, "v1.0.0") + version = PascalLanguageServer._get_local_version(temp_dir) + assert version == "v1.0.0" + + def test_get_local_version_not_exists(self) -> None: + """Test getting version when file doesn't exist.""" + with tempfile.TemporaryDirectory() as temp_dir: + version = PascalLanguageServer._get_local_version(temp_dir) + assert version is None diff --git a/test/solidlsp/pascal/test_pascal_basic.py b/test/solidlsp/pascal/test_pascal_basic.py new file mode 100644 index 0000000..e9636e9 --- /dev/null +++ b/test/solidlsp/pascal/test_pascal_basic.py @@ -0,0 +1,214 @@ +""" +Basic integration tests for the Pascal language server functionality. + +These tests validate the functionality of the language server APIs +like request_document_symbols using the Pascal test repository. + +Uses genericptr/pascal-language-server which returns SymbolInformation[] format: +- Returns classes, structs, enums, typedefs, functions/procedures +- Uses correct SymbolKind values: Class=5, Function=12, Method=6, Struct=23 +- Method names don't include parent class prefix; uses containerName instead +""" + +import shutil + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from test.conftest import is_ci, language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + +pytestmark = [ + pytest.mark.pascal, + pytest.mark.skipif(not language_tests_enabled(Language.PASCAL), reason="Pascal tests are disabled (pasls/fpc not available)"), +] + + +@pytest.mark.pascal +@pytest.mark.skipif(shutil.which("fpc") is None and not is_ci, reason="Free Pascal compiler is not available") +class TestPascalLanguageServerBasics: + """Test basic functionality of the Pascal language server.""" + + @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) + def test_pascal_language_server_initialization(self, language_server: SolidLanguageServer) -> None: + """Test that Pascal language server can be initialized successfully.""" + assert language_server is not None + assert language_server.language == Language.PASCAL + + @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) + def test_pascal_request_document_symbols(self, language_server: SolidLanguageServer) -> None: + """Test request_document_symbols for Pascal files. + + genericptr pasls returns proper SymbolKind values: + - Standalone functions: kind=12 (Function) + - Classes: kind=5 (Class) + """ + # Test getting symbols from main.pas + all_symbols, _root_symbols = language_server.request_document_symbols("main.pas").get_all_symbols_and_roots() + + # Should have symbols + assert len(all_symbols) > 0, "Should have symbols in main.pas" + + # Should detect standalone functions (SymbolKind.Function = 12) + function_symbols = [s for s in all_symbols if s.get("kind") == SymbolKind.Function] + function_names = [s["name"] for s in function_symbols] + + assert "CalculateSum" in function_names, "Should find CalculateSum function" + assert "PrintMessage" in function_names, "Should find PrintMessage procedure" + + # Should detect classes (SymbolKind.Class = 5) + class_symbols = [s for s in all_symbols if s.get("kind") == SymbolKind.Class] + class_names = [s["name"] for s in class_symbols] + + assert "TUser" in class_names, "Should find TUser class" + assert "TUserManager" in class_names, "Should find TUserManager class" + + @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) + def test_pascal_class_methods(self, language_server: SolidLanguageServer) -> None: + """Test detection of class methods in Pascal files. + + pasls returns class methods with SymbolKind.Method (kind 6), not Function (kind 12). + """ + all_symbols, _root_symbols = language_server.request_document_symbols("main.pas").get_all_symbols_and_roots() + + # Get all method symbols (pasls returns class methods as SymbolKind.Method = 6) + method_symbols = [s for s in all_symbols if s.get("kind") == SymbolKind.Method] + method_names = [s["name"] for s in method_symbols] + + # Should detect TUser methods + expected_tuser_methods = ["Create", "Destroy", "GetInfo", "UpdateAge"] + for method in expected_tuser_methods: + found = method in method_names + assert found, f"Should find method '{method}'" + + # Should detect TUserManager methods + expected_manager_methods = ["Create", "Destroy", "AddUser", "GetUserCount", "FindUserByName"] + for method in expected_manager_methods: + found = method in method_names + assert found, f"Should find method '{method}'" + + @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) + def test_pascal_helper_unit_symbols(self, language_server: SolidLanguageServer) -> None: + """Test function detection in Helper unit.""" + # Test with lib/helper.pas + helper_all_symbols, _helper_root_symbols = language_server.request_document_symbols("lib/helper.pas").get_all_symbols_and_roots() + + # Should have symbols + assert len(helper_all_symbols) > 0, "Helper unit should have symbols" + + # Extract function symbols + function_symbols = [s for s in helper_all_symbols if s.get("kind") == SymbolKind.Function] + function_names = [s["name"] for s in function_symbols] + + # Should detect standalone functions + expected_functions = ["GetHelperMessage", "MultiplyNumbers", "LogMessage"] + for func_name in expected_functions: + assert func_name in function_names, f"Should find {func_name} function in Helper unit" + + # Should also detect THelper class methods (returned as SymbolKind.Method = 6) + method_symbols = [s for s in helper_all_symbols if s.get("kind") == SymbolKind.Method] + method_names = [s["name"] for s in method_symbols] + assert "FormatString" in method_names, "Should find FormatString method" + assert "IsEven" in method_names, "Should find IsEven method" + + @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) + def test_pascal_cross_file_references(self, language_server: SolidLanguageServer) -> None: + """Test that Pascal LSP can handle cross-file references.""" + # main.pas uses Helper unit + main_symbols, _main_roots = language_server.request_document_symbols("main.pas").get_all_symbols_and_roots() + helper_symbols, _helper_roots = language_server.request_document_symbols("lib/helper.pas").get_all_symbols_and_roots() + + # Verify both files have symbols + assert len(main_symbols) > 0, "main.pas should have symbols" + assert len(helper_symbols) > 0, "helper.pas should have symbols" + + # Verify GetHelperMessage is in Helper unit + helper_function_names = [s["name"] for s in helper_symbols if s.get("kind") == SymbolKind.Function] + assert "GetHelperMessage" in helper_function_names, "Helper unit should export GetHelperMessage" + + @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) + def test_pascal_symbol_locations(self, language_server: SolidLanguageServer) -> None: + """Test that symbols have correct location information. + + Note: genericptr pasls returns the interface declaration location (line ~41), + not the implementation location (line ~115). + """ + all_symbols, _root_symbols = language_server.request_document_symbols("main.pas").get_all_symbols_and_roots() + + # Find CalculateSum function + calc_symbols = [s for s in all_symbols if s.get("name") == "CalculateSum"] + assert len(calc_symbols) > 0, "Should find CalculateSum" + + calc_symbol = calc_symbols[0] + + # Verify it has location information (SymbolInformation format uses location.range) + if "location" in calc_symbol: + location = calc_symbol["location"] + assert "range" in location, "Location should have range" + assert "start" in location["range"], "Range should have start" + assert "line" in location["range"]["start"], "Start should have line" + line = location["range"]["start"]["line"] + else: + # DocumentSymbol format uses range directly + assert "range" in calc_symbol, "Symbol should have range" + assert "start" in calc_symbol["range"], "Range should have start" + line = calc_symbol["range"]["start"]["line"] + + # CalculateSum is declared at line 41 in main.pas (0-indexed would be 40) + # genericptr pasls returns interface declaration location + assert 35 <= line <= 45, f"CalculateSum should be around line 41 (interface), got {line}" + + @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) + def test_pascal_namespace_symbol(self, language_server: SolidLanguageServer) -> None: + """Test that genericptr pasls returns Interface namespace symbol.""" + all_symbols, _root_symbols = language_server.request_document_symbols("main.pas").get_all_symbols_and_roots() + + # genericptr pasls adds an "Interface" namespace symbol + symbol_names = [s["name"] for s in all_symbols] + + # The Interface section should be represented + # Note: This depends on pasls configuration + assert len(all_symbols) > 0, "Should have symbols" + # Interface namespace may or may not be present depending on pasls configuration + _ = symbol_names # used for potential future assertions + + @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) + def test_pascal_hover_with_doc_comments(self, language_server: SolidLanguageServer) -> None: + """Test that hover returns documentation comments. + + CalculateSum has /// style doc comments that should appear in hover. + """ + # CalculateSum is declared at line 46 (1-indexed), so line 45 (0-indexed) + hover = language_server.request_hover("main.pas", 45, 12) + + assert hover is not None, "Hover should return a result" + + # Extract hover content - handle both dict and object formats + if isinstance(hover, dict): + contents = hover.get("contents", {}) + value = contents.get("value", "") if isinstance(contents, dict) else str(contents) + else: + value = hover.contents.value if hasattr(hover.contents, "value") else str(hover.contents) + + # Should contain the function signature + assert "CalculateSum" in value, f"Hover should show function name. Got: {value[:500]}" + + # Should contain the doc comment + assert "Calculates the sum" in value, f"Hover should include doc comment. Got: {value[:500]}" + + @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if s["kind"] == SymbolKind.Package: + continue + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/pascal/test_pascal_diagnostics.py b/test/solidlsp/pascal/test_pascal_diagnostics.py new file mode 100644 index 0000000..ad96a1e --- /dev/null +++ b/test/solidlsp/pascal/test_pascal_diagnostics.py @@ -0,0 +1,25 @@ +import shutil + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled +from test.solidlsp.util.diagnostics import assert_file_diagnostics + +pytestmark = [ + pytest.mark.pascal, + pytest.mark.skipif(not language_tests_enabled(Language.PASCAL), reason="Pascal tests are disabled"), + pytest.mark.skipif(shutil.which("fpc") is None, reason="Pascal diagnostics require the Free Pascal compiler"), +] + + +class TestPascalDiagnostics: + @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.pas", + (), + min_count=1, + ) diff --git a/test/solidlsp/perl/__init__.py b/test/solidlsp/perl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/solidlsp/perl/test_perl_basic.py b/test/solidlsp/perl/test_perl_basic.py new file mode 100644 index 0000000..f6e1bf6 --- /dev/null +++ b/test/solidlsp/perl/test_perl_basic.py @@ -0,0 +1,111 @@ +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics + +pytestmark = pytest.mark.skipif( + not language_tests_enabled(Language.PERL), reason="Perl tests are disabled (Perl::LanguageServer not available)" +) + + +@pytest.mark.perl +class TestPerlLanguageServer: + """ + Tests for Perl::LanguageServer integration. + + Perl::LanguageServer provides comprehensive LSP support for Perl including: + - Document symbols (functions, variables) + - Go to definition (including cross-file) + - Find references (including cross-file) - this was not available in PLS + """ + + @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PERL], indirect=True) + def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that the language server starts and stops successfully.""" + # The fixture already handles start and stop + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) + def test_document_symbols(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols are correctly identified.""" + # Request document symbols + all_symbols, _ = language_server.request_document_symbols("main.pl").get_all_symbols_and_roots() + + assert all_symbols, "Expected to find symbols in main.pl" + assert len(all_symbols) > 0, "Expected at least one symbol" + + # DEBUG: Print all symbols + print("\n=== All symbols in main.pl ===") + for s in all_symbols: + line = s.get("range", {}).get("start", {}).get("line", "?") + print(f"Line {line}: {s.get('name')} (kind={s.get('kind')})") + + # Check that we can find function symbols + function_symbols = [s for s in all_symbols if s.get("kind") == 12] # 12 = Function/Method + assert len(function_symbols) >= 2, f"Expected at least 2 functions (greet, use_helper_function), found {len(function_symbols)}" + + function_names = [s.get("name") for s in function_symbols] + assert "greet" in function_names, f"Expected 'greet' function in symbols, found: {function_names}" + assert "use_helper_function" in function_names, f"Expected 'use_helper_function' in symbols, found: {function_names}" + + # @pytest.mark.skip(reason="Perl::LanguageServer cross-file definition tracking needs configuration") + @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) + def test_find_definition_across_files(self, language_server: SolidLanguageServer) -> None: + definition_location_list = language_server.request_definition("main.pl", 17, 0) + + assert len(definition_location_list) == 1 + definition_location = definition_location_list[0] + print(f"Found definition: {definition_location}") + assert definition_location["uri"].endswith("helper.pl") + assert definition_location["range"]["start"]["line"] == 4 # add method on line 2 (0-indexed 1) + + @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) + def test_find_references_across_files(self, language_server: SolidLanguageServer) -> None: + """Test finding references to a function across multiple files.""" + reference_locations = language_server.request_references("helper.pl", 4, 5) + + assert len(reference_locations) >= 2, f"Expected at least 2 references to helper_function, found {len(reference_locations)}" + + main_pl_refs = [ref for ref in reference_locations if ref["uri"].endswith("main.pl")] + assert len(main_pl_refs) >= 2, f"Expected at least 2 references in main.pl, found {len(main_pl_refs)}" + + main_pl_lines = sorted([ref["range"]["start"]["line"] for ref in main_pl_refs]) + assert 17 in main_pl_lines, f"Expected reference at line 18 (0-indexed 17), found: {main_pl_lines}" + assert 20 in main_pl_lines, f"Expected reference at line 21 (0-indexed 20), found: {main_pl_lines}" + + @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) + def test_find_references_includes_t_files(self, language_server: SolidLanguageServer) -> None: + """References to a .pm/.pl sub must surface callers in .t test files (fileFilter includes .t).""" + reference_locations = language_server.request_references("helper.pl", 4, 5) + + t_refs = [ref for ref in reference_locations if ref["uri"].endswith(".t")] + assert t_refs, f"Expected at least one reference in a .t file, got: {[r['uri'] for r in reference_locations]}" + + @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) + + @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.pl", + (), + min_count=1, + ) diff --git a/test/solidlsp/perl/test_perl_config.py b/test/solidlsp/perl/test_perl_config.py new file mode 100644 index 0000000..3b954d9 --- /dev/null +++ b/test/solidlsp/perl/test_perl_config.py @@ -0,0 +1,220 @@ +"""Configuration plumbing for ``PerlLanguageServer``'s fileFilter / ignoreDirs. + +``Perl::LanguageServer`` only indexes files whose extension is in ``perl.fileFilter`` and skips +directories listed in ``perl.ignoreDirs``; both are pushed to the LS at startup (see +``_start_server``). The defaults must stay backward-compatible, but projects with non-standard +layouts — e.g. ``.cgi`` / ``.psgi`` Perl web handlers that can dominate a mature codebase — need +a way to extend visibility (#1449). + +These tests pin the configuration plumbing without starting the language server (which would +require a Perl runtime), so they run in every environment. They also cover the source-file matcher +sync that keeps ``find_symbol`` consistent with the LS (#1449). +""" + +from pathlib import Path + +from solidlsp.language_servers.perl_language_server import ( + _DEFAULT_FILE_FILTER, + _DEFAULT_IGNORE_DIRS, + PerlLanguageServer, +) +from solidlsp.ls_config import FilenameMatcher, Language +from solidlsp.settings import SolidLSPSettings + + +def _settings(tmp_path: Path, ls_specific_settings: dict | None = None) -> SolidLSPSettings: + """A SolidLSPSettings rooted under ``tmp_path`` with optional perl overrides.""" + return SolidLSPSettings( + solidlsp_dir=str(tmp_path / ".solidlsp"), + ls_specific_settings=ls_specific_settings or {}, + ) + + +class TestResolveFilterSettings: + def test_defaults_when_no_ls_specific_settings(self, tmp_path: Path) -> None: + # Behaviour unchanged for projects that don't set ls_specific_settings at all. + file_filter, ignore_dirs = PerlLanguageServer._resolve_filter_settings(_settings(tmp_path)) + + assert file_filter == _DEFAULT_FILE_FILTER + assert ignore_dirs == _DEFAULT_IGNORE_DIRS + + def test_defaults_when_perl_key_absent(self, tmp_path: Path) -> None: + # ls_specific_settings configured for another language must not leak into Perl. + settings = _settings(tmp_path, {Language.PYTHON: {"something": "unrelated"}}) + + file_filter, ignore_dirs = PerlLanguageServer._resolve_filter_settings(settings) + + assert file_filter == _DEFAULT_FILE_FILTER + assert ignore_dirs == _DEFAULT_IGNORE_DIRS + + def test_custom_file_filter_makes_extra_extensions_visible(self, tmp_path: Path) -> None: + # #1449: a Perl web backend must be able to surface .cgi / .psgi handlers. + custom = [".pm", ".pl", ".t", ".cgi", ".psgi"] + settings = _settings(tmp_path, {Language.PERL: {"file_filter": custom}}) + + file_filter, _ = PerlLanguageServer._resolve_filter_settings(settings) + + assert file_filter == custom + assert ".cgi" in file_filter + + def test_custom_ignore_dirs(self, tmp_path: Path) -> None: + custom = [".git", "blib", "local", "cover_db", "t"] + settings = _settings(tmp_path, {Language.PERL: {"ignore_dirs": custom}}) + + _, ignore_dirs = PerlLanguageServer._resolve_filter_settings(settings) + + assert ignore_dirs == custom + + def test_both_overrides_apply_independently(self, tmp_path: Path) -> None: + file_filter = [".pm", ".pl", ".cgi"] + ignore_dirs = [".git", "vendor"] + settings = _settings( + tmp_path, + {Language.PERL: {"file_filter": file_filter, "ignore_dirs": ignore_dirs}}, + ) + + resolved_filter, resolved_dirs = PerlLanguageServer._resolve_filter_settings(settings) + + assert resolved_filter == file_filter + assert resolved_dirs == ignore_dirs + + def test_default_lists_are_not_mutated_across_instances(self, tmp_path: Path) -> None: + # The resolver must copy the module-level defaults, otherwise one instance mutating its + # returned list (or the LS handlers appending to self._file_filter) would corrupt every + # subsequently created default-configured instance. + file_filter, _ = PerlLanguageServer._resolve_filter_settings(_settings(tmp_path)) + file_filter.append(".cgi") + + next_filter, _ = PerlLanguageServer._resolve_filter_settings(_settings(tmp_path)) + + assert next_filter == _DEFAULT_FILE_FILTER + assert ".cgi" not in _DEFAULT_FILE_FILTER + + +class TestFilenameMatcherAddExtensions: + def test_adds_new_extension(self) -> None: + matcher = FilenameMatcher(".pm", ".pl") + + matcher.add_extensions(".cgi") + + assert matcher.is_relevant_filename("lib/Foo.pm") + assert matcher.is_relevant_filename("web/handler.cgi") + + def test_is_idempotent(self) -> None: + # Re-adding an existing extension must not duplicate it; calling sync twice (e.g. across + # two PerlLanguageServer instances) must be a no-op. + matcher = FilenameMatcher(".pm", ".pl") + baseline = len(matcher._file_extensions) + + matcher.add_extensions(".pm", ".pl") + matcher.add_extensions(".cgi") + matcher.add_extensions(".cgi") + + assert len(matcher._file_extensions) == baseline + 1 + assert matcher.is_relevant_filename("foo.cgi") + + def test_respects_case_insensitivity(self) -> None: + matcher = FilenameMatcher(".pm", case_sensitive=False) + + matcher.add_extensions(".CGI") + + # case-insensitive matcher normalises to lower case + assert matcher.is_relevant_filename("Handler.Cgi") + assert ".cgi" in matcher._file_extensions + assert ".CGI" not in matcher._file_extensions + + +class TestFilenameMatcherReset: + def test_reset_undoes_added_extensions(self) -> None: + matcher = FilenameMatcher(".pm", ".pl") + + matcher.add_extensions(".cgi", ".psgi") + assert matcher.is_relevant_filename("h.cgi") + + matcher.reset() + + assert not matcher.is_relevant_filename("h.cgi") + assert not matcher.is_relevant_filename("h.psgi") + assert matcher.is_relevant_filename("h.pm") + + def test_reset_restores_original_after_case_insensitive_add(self) -> None: + matcher = FilenameMatcher(".PM", case_sensitive=False) + + matcher.add_extensions(".cgi") + matcher.reset() + + # original extension survives the round-trip (normalised to lower case at construction) + assert matcher._file_extensions == [".pm"] + assert not matcher.is_relevant_filename("h.cgi") + + def test_reset_is_idempotent(self) -> None: + # SolidLanguageServer.__init__ calls reset on every activation; repeated resets must be safe + # and must not shrink below the original configuration. + matcher = FilenameMatcher(".pm", ".pl") + baseline = len(matcher._file_extensions) + + matcher.add_extensions(".cgi") + matcher.reset() + matcher.reset() + + assert len(matcher._file_extensions) == baseline + + +class TestSourceFnMatcherSync: + def test_custom_file_filter_extends_perl_matcher(self, tmp_path: Path) -> None: + # #1449: find_symbol relies on Language.PERL.get_source_fn_matcher(); unless the configured + # extensions are synced into it, symbols in .cgi/.psgi files stay invisible even though the + # LS indexes them. get_source_fn_matcher() is a @cache singleton, so reset() afterwards. + matcher = Language.PERL.get_source_fn_matcher() + try: + assert not matcher.is_relevant_filename("handler.cgi") # guard: not matched by default + + file_filter, _ = PerlLanguageServer._resolve_filter_settings( + _settings(tmp_path, {Language.PERL: {"file_filter": [".pm", ".pl", ".t", ".cgi", ".psgi"]}}) + ) + PerlLanguageServer._sync_source_fn_matcher(file_filter) + + assert matcher.is_relevant_filename("lib/Foo.pm") + assert matcher.is_relevant_filename("web/handler.cgi") + assert matcher.is_relevant_filename("app.psgi") + finally: + matcher.reset() + + def test_default_file_filter_leaves_matcher_unchanged(self, tmp_path: Path) -> None: + # The default file_filter matches the existing Perl matcher extensions, so syncing it must + # be a no-op (no duplicate entries, no new matches). + matcher = Language.PERL.get_source_fn_matcher() + try: + initial = list(matcher._file_extensions) + file_filter, _ = PerlLanguageServer._resolve_filter_settings(_settings(tmp_path)) + PerlLanguageServer._sync_source_fn_matcher(file_filter) + + assert sorted(matcher._file_extensions) == sorted(initial) + finally: + matcher.reset() + + def test_reset_prevents_cross_project_leak(self, tmp_path: Path) -> None: + # The matcher is a per-language singleton shared across projects. Project A reconfigures + # file_filter (adds .cgi); when project B is activated, SolidLanguageServer.__init__ resets + # the matcher first, so project B must NOT see .cgi even though project A added it. + # Mirrors the reset-then-sync ordering of PerlLanguageServer.__init__. + matcher = Language.PERL.get_source_fn_matcher() + try: + # project A: custom file_filter with .cgi + filter_a, _ = PerlLanguageServer._resolve_filter_settings( + _settings(tmp_path, {Language.PERL: {"file_filter": [".pm", ".pl", ".t", ".cgi"]}}) + ) + PerlLanguageServer._sync_source_fn_matcher(filter_a) + assert matcher.is_relevant_filename("handler.cgi") + + # project B activates: base __init__ resets, then default config is applied (no .cgi) + matcher.reset() + filter_b, _ = PerlLanguageServer._resolve_filter_settings(_settings(tmp_path / "proj_b")) + PerlLanguageServer._sync_source_fn_matcher(filter_b) + + assert not matcher.is_relevant_filename("handler.cgi"), ( + "project B inherited project A's .cgi — reset did not undo the reconfiguration" + ) + assert matcher.is_relevant_filename("lib/Foo.pm") + finally: + matcher.reset() diff --git a/test/solidlsp/php/test_php_basic.py b/test/solidlsp/php/test_php_basic.py new file mode 100644 index 0000000..947495b --- /dev/null +++ b/test/solidlsp/php/test_php_basic.py @@ -0,0 +1,294 @@ +import subprocess +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import is_ci, is_windows, language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +def _php_supports_phpactor() -> bool: + completed_process = subprocess.run(["php", "-m"], capture_output=True, text=True, check=False) + installed_extensions = {line.strip().lower() for line in completed_process.stdout.splitlines()} + return "openssl" in installed_extensions + + +_php_servers: list[Language] = [Language.PHP] +if language_tests_enabled(Language.PHP_PHPACTOR): + if not (is_windows and is_ci) and _php_supports_phpactor(): + _php_servers.append(Language.PHP_PHPACTOR) +if language_tests_enabled(Language.PHP_PHPANTOM): + _php_servers.append(Language.PHP_PHPANTOM) + + +def _is_phpactor(language_server: SolidLanguageServer) -> bool: + return language_server.language_server.language == Language.PHP_PHPACTOR + + +def _is_phpantom(language_server: SolidLanguageServer) -> bool: + return language_server.language_server.language == Language.PHP_PHPANTOM + + +def _is_non_default_php_backend(language_server: SolidLanguageServer) -> bool: + return _is_phpactor(language_server) or _is_phpantom(language_server) + + +@pytest.mark.php +class TestPhpLanguageServers: + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) + def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that the language server starts and stops successfully.""" + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) + def test_find_definition_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + # In index.php: + # Line 9 (1-indexed): $greeting = greet($userName); + # Line 11 (1-indexed): echo $greeting; + # We want to find the definition of $greeting (defined on line 9) + # from its usage in echo $greeting; on line 11. + # LSP is 0-indexed: definition on line 8, usage on line 10. + # $greeting in echo $greeting; (e c h o $ g r e e t i n g) + # ^ char 5 + # Intelephense uses line 10 (0-indexed), Phpactor uses line 11 (0-indexed) + if _is_non_default_php_backend(language_server): + definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 11, 6) + else: + definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 10, 6) + + assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" + if _is_non_default_php_backend(language_server): + assert len(definition_location_list) >= 1 + else: + assert len(definition_location_list) == 1 + definition_location = definition_location_list[0] + assert definition_location["uri"].endswith("index.php") + # Definition of $greeting is on line 10 (1-indexed) / line 9 (0-indexed), char 0 + assert definition_location["range"]["start"]["line"] == 9 + if not _is_non_default_php_backend(language_server): + assert definition_location["range"]["start"]["character"] == 0 + + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) + def test_find_definition_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + # Intelephense uses line 12 (0-indexed), Phpactor uses line 13 (0-indexed) + if _is_non_default_php_backend(language_server): + definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 13, 5) + else: + definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 12, 5) + + assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" + if _is_non_default_php_backend(language_server): + assert len(definition_location_list) >= 1 + else: + assert len(definition_location_list) == 1 + definition_location = definition_location_list[0] + assert definition_location["uri"].endswith("helper.php") + assert definition_location["range"]["start"]["line"] == 2 + if not _is_non_default_php_backend(language_server): + assert definition_location["range"]["start"]["character"] == 0 + + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) + def test_find_definition_simple_variable(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + file_path = str(repo_path / "simple_var.php") + + # In simple_var.php: + # Line 2 (1-indexed): $localVar = "test"; + # Line 3 (1-indexed): echo $localVar; + # LSP is 0-indexed: definition on line 1, usage on line 2 + # Find definition of $localVar (char 5 on line 3 / 0-indexed: line 2, char 5) + # $localVar in echo $localVar; (e c h o $ l o c a l V a r) + # ^ char 5 + definition_location_list = language_server.request_definition(file_path, 2, 6) # cursor on 'l' in $localVar + + assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" + if _is_non_default_php_backend(language_server): + assert len(definition_location_list) >= 1 + else: + assert len(definition_location_list) == 1 + definition_location = definition_location_list[0] + assert definition_location["uri"].endswith("simple_var.php") + assert definition_location["range"]["start"]["line"] == 1 # Definition of $localVar (0-indexed) + if not _is_non_default_php_backend(language_server): + assert definition_location["range"]["start"]["character"] == 0 # $localVar (0-indexed) + + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) + def test_find_references_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + index_php_path = str(repo_path / "index.php") + + # In index.php (0-indexed lines): + # Line 9: $greeting = greet($userName); // Definition of $greeting + # Line 11: echo $greeting; // Usage of $greeting + # Find references for $greeting from its usage in "echo $greeting;" (line 11, char 6 for 'g') + references = language_server.request_references(index_php_path, 11, 6) + + assert references, f"Expected non-empty references for $greeting but got {references=}" + + if _is_non_default_php_backend(language_server): + actual_locations = [ + { + "uri_suffix": loc["uri"].split("/")[-1], + "line": loc["range"]["start"]["line"], + } + for loc in references + ] + + # Check that at least one reference points to $greeting usage in index.php + matching = [loc for loc in actual_locations if loc["uri_suffix"] == "index.php" and loc["line"] == 11] + assert matching, f"Expected reference to $greeting on line 11 of index.php, got {actual_locations}" + else: + # Intelephense, when asked for references from usage, seems to only return the usage itself. + assert len(references) == 1, "Expected to find 1 reference for $greeting (the usage itself)" + + expected_locations = [{"uri_suffix": "index.php", "line": 11, "character": 5}] # Usage: echo $greeting (points to $) + + # Convert actual references to a comparable format and sort + actual_locations = sorted( + [ + { + "uri_suffix": loc["uri"].split("/")[-1], + "line": loc["range"]["start"]["line"], + "character": loc["range"]["start"]["character"], + } + for loc in references + ], + key=lambda x: (x["uri_suffix"], x["line"], x["character"]), + ) + + expected_locations = sorted(expected_locations, key=lambda x: (x["uri_suffix"], x["line"], x["character"])) + + assert actual_locations == expected_locations + + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) + def test_find_references_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + helper_php_path = str(repo_path / "helper.php") + # In index.php (0-indexed lines): + # Line 13: helperFunction(); // Usage of helperFunction + # Find references for helperFunction from its definition + references = language_server.request_references(helper_php_path, 2, len("function ")) + + assert references, f"Expected non-empty references for helperFunction but got {references=}" + + if _is_non_default_php_backend(language_server): + actual_locations_comparable = [] + for loc in references: + actual_locations_comparable.append( + { + "uri_suffix": loc["uri"].split("/")[-1], + "line": loc["range"]["start"]["line"], + } + ) + + # Check that helperFunction usage in index.php line 13 is found + matching = [loc for loc in actual_locations_comparable if loc["uri_suffix"] == "index.php" and loc["line"] == 13] + assert matching, f"Usage of helperFunction in index.php (line 13) not found in {actual_locations_comparable}" + else: + # Intelephense might return 1 (usage) or 2 (usage + definition) references. + # Let's check for at least the usage in index.php + # Definition is in helper.php, line 2, char 0 (based on previous findings) + # Usage is in index.php, line 13, char 0 + actual_locations_comparable = [] + for loc in references: + actual_locations_comparable.append( + { + "uri_suffix": loc["uri"].split("/")[-1], + "line": loc["range"]["start"]["line"], + "character": loc["range"]["start"]["character"], + } + ) + + usage_in_index_php = {"uri_suffix": "index.php", "line": 13, "character": 0} + assert usage_in_index_php in actual_locations_comparable, "Usage of helperFunction in index.php not found" + + @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPANTOM], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols are properly retrieved after Intelephense capability fix.""" + from solidlsp.ls_utils import SymbolUtils + + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "helperFunction"), "helperFunction not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "greet"), "greet function not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPANTOM], indirect=True) + def test_document_symbols(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols are properly retrieved for a specific file.""" + doc_symbols = language_server.request_document_symbols("helper.php") + all_symbols = doc_symbols.get_all_symbols_and_roots() + symbol_names = [sym.get("name") for sym in all_symbols[0] if sym.get("name")] + assert "helperFunction" in symbol_names, f"helperFunction not found in document symbols. Found: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPANTOM], indirect=True) + def test_document_symbols_hierarchical_structure(self, language_server: SolidLanguageServer) -> None: + """Verify Intelephense returns hierarchical DocumentSymbol format. + + When hierarchicalDocumentSymbolSupport is declared in client capabilities, + Intelephense returns DocumentSymbol[] where class methods appear as children + of their parent class. Without this declaration, it falls back to a flat + SymbolInformation[] list where all symbols appear at root level with no + parent-child relationships. + """ + all_symbols, root_symbols = language_server.request_document_symbols("sample.php").get_all_symbols_and_roots() + + root_names = [s.get("name") for s in root_symbols] + assert "Animal" in root_names, f"Animal class not found at root level. Roots: {root_names}" + assert "Dog" in root_names, f"Dog class not found at root level. Roots: {root_names}" + assert "Cat" in root_names, f"Cat class not found at root level. Roots: {root_names}" + + # Verify Dog has method children — this is the key assertion for hierarchical support. + # With a flat response, Dog would have no children and all methods would be at root level. + dog_symbol = next((s for s in root_symbols if s.get("name") == "Dog"), None) + assert dog_symbol is not None, "Dog class not found in root symbols" + dog_children = dog_symbol.get("children", []) + dog_child_names = [c.get("name") for c in dog_children] + assert len(dog_child_names) > 0, ( + f"Dog class has no children — hierarchicalDocumentSymbolSupport is not working. All root symbols: {root_names}" + ) + expected_methods = {"greet", "fetch", "getBreed", "describe"} + missing = expected_methods - set(dog_child_names) + assert not missing, f"Dog class missing expected methods: {missing}. Children found: {dog_child_names}" + + # Methods must NOT appear at root level (that would indicate the flat fallback format). + assert "greet" not in root_names, f"greet should be a child of Dog, not at root level. Roots: {root_names}" + assert "fetch" not in root_names, f"fetch should be a child of Dog, not at root level. Roots: {root_names}" + + @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPANTOM], indirect=True) + def test_full_symbol_tree_within_file(self, language_server: SolidLanguageServer) -> None: + """Verify request_full_symbol_tree scoped to a PHP file returns correct symbols. + + This validates that Intelephense responds correctly when symbols are requested + for a single file, including class/method hierarchy in sample.php. + """ + from solidlsp.ls_utils import SymbolUtils + + symbols = language_server.request_full_symbol_tree(within_relative_path="sample.php") + + assert SymbolUtils.symbol_tree_contains_name(symbols, "Dog"), "Dog not found in sample.php symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Animal"), "Animal not found in sample.php symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "greet"), "greet method not found in sample.php symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "fetch"), "fetch method not found in sample.php symbol tree" + + # Methods must appear as children of Dog, not as root-level symbols + dog_root = next((s for s in symbols if s.get("name") == "Dog"), None) + if dog_root is not None: + assert SymbolUtils.symbol_tree_contains_name([dog_root], "greet"), "greet should be nested under Dog in symbol tree" + + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/php/test_php_diagnostics.py b/test/solidlsp/php/test_php_diagnostics.py new file mode 100644 index 0000000..2bfead1 --- /dev/null +++ b/test/solidlsp/php/test_php_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.php +class TestPhpDiagnostics: + @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPANTOM], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.php", + (), + min_count=1, + ) diff --git a/test/solidlsp/php/test_phpantom.py b/test/solidlsp/php/test_phpantom.py new file mode 100644 index 0000000..4ca4a0b --- /dev/null +++ b/test/solidlsp/php/test_phpantom.py @@ -0,0 +1,231 @@ +import shutil +from pathlib import Path + +import pytest + +from serena.code_editor import LanguageServerCodeEditor +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from src.serena.symbol import LanguageServerSymbolRetriever +from test.conftest import project_with_ls_context, start_ls_context + + +def _extract_changes(workspace_edit: dict) -> dict[str, list[dict]]: + changes = workspace_edit.get("changes", {}) + if changes: + return changes + + document_changes = workspace_edit.get("documentChanges", []) + return {change["textDocument"]["uri"]: change["edits"] for change in document_changes if "textDocument" in change and "edits" in change} + + +def _copy_php_fixture(tmp_path: Path) -> Path: + from test.conftest import get_repo_path + + fixture_path = get_repo_path(Language.PHP) + target_path = tmp_path / "test_repo" + shutil.copytree(fixture_path, target_path) + return target_path + + +def _write_psr4_fixture(repo_path: Path) -> None: + serena_dir = repo_path / ".serena" + serena_dir.mkdir(exist_ok=True) + (serena_dir / "project.yml").write_text( + """project_name: php-phase1 +languages: + - php_phpantom +""", + encoding="utf-8", + ) + + (repo_path / "composer.json").write_text('{"autoload": {"psr-4": {"Demo\\\\": "src/"}}}\n', encoding="utf-8") + + src_dir = repo_path / "src" + src_dir.mkdir(exist_ok=True) + + (src_dir / "Greeter.php").write_text( + """<?php +namespace Demo; + +interface Greeter +{ + /** Returns a greeting for the given name. */ + public function greet(string $name): string; +} +""", + encoding="utf-8", + ) + + (src_dir / "Helper.php").write_text( + """<?php +namespace Demo; + +/** Utility helper docs. */ +function format_name(string $name): string +{ + return strtoupper($name); +} +""", + encoding="utf-8", + ) + + (src_dir / "Welcome.php").write_text( + """<?php +namespace Demo; + +use const Demo\\MAX_GREETING_LENGTH; +use function Demo\\format_name; + +const MAX_GREETING_LENGTH = 80; + +/** Welcome service docs. */ +class Welcome implements Greeter +{ + /** Friendly greeting docs. */ + public function greet(string $name): string + { + return substr("Hello ".format_name($name), 0, MAX_GREETING_LENGTH); + } +} +""", + encoding="utf-8", + ) + + (src_dir / "UseWelcome.php").write_text( + """<?php +namespace Demo; + +function run_demo(): string +{ + $service = new Welcome(); + return $service->greet('world'); +} +""", + encoding="utf-8", + ) + + +def _find_root_symbol(language_server: SolidLanguageServer, relative_path: str, symbol_name: str) -> dict: + all_symbols, root_symbols = language_server.request_document_symbols(relative_path).get_all_symbols_and_roots() + for symbol in root_symbols: + if symbol.get("name") == symbol_name: + return symbol + for symbol in all_symbols: + if symbol.get("name") == symbol_name: + return symbol + raise AssertionError(f"Symbol {symbol_name!r} not found in {relative_path}") + + +def _find_child_symbol(parent_symbol: dict, child_name: str) -> dict: + for child in parent_symbol.get("children", []): + if child.get("name") == child_name: + return child + raise AssertionError(f"Child symbol {child_name!r} not found in {parent_symbol.get('name')!r}") + + +@pytest.mark.php +class TestPHPantom: + @pytest.mark.parametrize("language_server", [Language.PHP_PHPANTOM], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) + def test_rename_local_variable(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + workspace_edit = language_server.request_rename_symbol_edit(str(Path("index.php")), 9, 1, "welcomeMessage") + assert workspace_edit is not None, "Rename should be supported for local PHP variables" + + changes = _extract_changes(workspace_edit) + index_edits = [edits for uri, edits in changes.items() if uri.endswith("index.php")] + assert index_edits, f"Expected edits for index.php, got: {list(changes.keys())}" + + edits = index_edits[0] + assert len(edits) >= 2, f"Expected at least two local variable edits, got {len(edits)}" + assert {edit["range"]["start"]["line"] for edit in edits} >= {9, 11} + assert all(edit["newText"] == "$welcomeMessage" for edit in edits) + + def test_rename_method_across_files(self, tmp_path: Path) -> None: + repo_path = _copy_php_fixture(tmp_path) + _write_psr4_fixture(repo_path) + + with start_ls_context(Language.PHP_PHPANTOM, repo_path=str(repo_path), solidlsp_dir=tmp_path) as language_server: + welcome_symbol = _find_root_symbol(language_server, "src/Welcome.php", "Welcome") + greet_symbol = _find_child_symbol(welcome_symbol, "greet") + selection = greet_symbol["selectionRange"]["start"] + workspace_edit = language_server.request_rename_symbol_edit( + "src/Welcome.php", selection["line"], selection["character"], "salute" + ) + + assert workspace_edit is not None, "Rename should be supported for methods" + changes = _extract_changes(workspace_edit) + changed_files = sorted(uri.split("/")[-1] for uri in changes) + assert "Welcome.php" in changed_files + assert "UseWelcome.php" in changed_files + assert all(edit["newText"] == "salute" for edits in changes.values() for edit in edits) + + def test_rename_class_across_files(self, tmp_path: Path) -> None: + repo_path = _copy_php_fixture(tmp_path) + _write_psr4_fixture(repo_path) + + with start_ls_context(Language.PHP_PHPANTOM, repo_path=str(repo_path), solidlsp_dir=tmp_path) as language_server: + welcome_symbol = _find_root_symbol(language_server, "src/Welcome.php", "Welcome") + selection = welcome_symbol["selectionRange"]["start"] + workspace_edit = language_server.request_rename_symbol_edit( + "src/Welcome.php", selection["line"], selection["character"], "GreetingService" + ) + + assert workspace_edit is not None, "Rename should be supported for classes" + changes = _extract_changes(workspace_edit) + changed_files = sorted(uri.split("/")[-1] for uri in changes) + assert "GreetingService.php" in changed_files or "Welcome.php" in changed_files + assert "UseWelcome.php" in changed_files + assert all(edit["newText"] == "GreetingService" for edits in changes.values() for edit in edits) + + def test_psr4_class_rename_applies_file_rename(self, tmp_path: Path) -> None: + repo_path = _copy_php_fixture(tmp_path) + _write_psr4_fixture(repo_path) + + with project_with_ls_context(Language.PHP_PHPANTOM, str(repo_path)) as project: + symbol_retriever = LanguageServerSymbolRetriever(project) + code_editor = LanguageServerCodeEditor(symbol_retriever) + status_message = code_editor.rename_symbol("Welcome", relative_path="src/Welcome.php", new_name="GreetingService") + + assert "Successfully renamed 'Welcome' to 'GreetingService'" in status_message + assert not (repo_path / "src" / "Welcome.php").exists() + assert (repo_path / "src" / "GreetingService.php").exists() + + renamed_content = (repo_path / "src" / "GreetingService.php").read_text(encoding="utf-8") + usage_content = (repo_path / "src" / "UseWelcome.php").read_text(encoding="utf-8") + assert "class GreetingService" in renamed_content + assert "new GreetingService()" in usage_content + + def test_hover_info_is_available_via_find_symbol_include_info(self, tmp_path: Path) -> None: + repo_path = _copy_php_fixture(tmp_path) + _write_psr4_fixture(repo_path) + + with project_with_ls_context(Language.PHP_PHPANTOM, str(repo_path)) as project: + symbol_retriever = LanguageServerSymbolRetriever(project) + symbols = symbol_retriever.find("Welcome", within_relative_path="src/Welcome.php") + info_by_symbol = symbol_retriever.request_info_for_symbol_batch(symbols) + + welcome_infos = [info for symbol, info in info_by_symbol.items() if symbol.name == "Welcome"] + assert welcome_infos, "Expected Welcome symbol info to be available" + assert any(info and "Welcome service docs." in info for info in welcome_infos) + + def test_workspace_symbol_queries_cover_class_function_and_constant(self, tmp_path: Path) -> None: + repo_path = _copy_php_fixture(tmp_path) + _write_psr4_fixture(repo_path) + + with start_ls_context(Language.PHP_PHPANTOM, repo_path=str(repo_path), solidlsp_dir=tmp_path) as language_server: + class_symbols = language_server.request_workspace_symbol("Welcome") or [] + function_symbols = language_server.request_workspace_symbol("format_name") or [] + constant_symbols = language_server.request_workspace_symbol("MAX_GREETING_LENGTH") or [] + + def summarize(symbols: list[dict]) -> list[tuple[str, str]]: + return [(symbol["name"], symbol["location"]["uri"]) for symbol in symbols] + + assert any(name == "Demo\\Welcome" and uri.endswith("src/Welcome.php") for name, uri in summarize(class_symbols)) + assert all(uri.endswith(".php") for _name, uri in summarize(function_symbols)) + assert function_symbols == [] or any( + "location" in symbol and symbol["location"]["uri"].startswith("file://") for symbol in function_symbols + ) + assert constant_symbols == [] or any( + "location" in symbol and symbol["location"]["uri"].startswith("file://") for symbol in constant_symbols + ) diff --git a/test/solidlsp/powershell/__init__.py b/test/solidlsp/powershell/__init__.py new file mode 100644 index 0000000..3fb5513 --- /dev/null +++ b/test/solidlsp/powershell/__init__.py @@ -0,0 +1 @@ +# PowerShell language server tests diff --git a/test/solidlsp/powershell/test_powershell_basic.py b/test/solidlsp/powershell/test_powershell_basic.py new file mode 100644 index 0000000..bf1b9e4 --- /dev/null +++ b/test/solidlsp/powershell/test_powershell_basic.py @@ -0,0 +1,197 @@ +""" +Basic integration tests for the PowerShell language server functionality. + +These tests validate the functionality of the language server APIs +like request_document_symbols using the PowerShell test repository. +""" + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.powershell +class TestPowerShellLanguageServerBasics: + """Test basic functionality of the PowerShell language server.""" + + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) + def test_powershell_language_server_initialization(self, language_server: SolidLanguageServer) -> None: + """Test that PowerShell language server can be initialized successfully.""" + assert language_server is not None + assert language_server.language == Language.POWERSHELL + + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) + def test_powershell_request_document_symbols(self, language_server: SolidLanguageServer) -> None: + """Test request_document_symbols for PowerShell files.""" + # Test getting symbols from main.ps1 + all_symbols, _root_symbols = language_server.request_document_symbols("main.ps1").get_all_symbols_and_roots() + + # Extract function symbols (LSP Symbol Kind 12) + function_symbols = [symbol for symbol in all_symbols if symbol.get("kind") == 12] + function_names = [symbol["name"] for symbol in function_symbols] + + # PSES returns function names in format "function FuncName ()" - check for function name substring + def has_function(name: str) -> bool: + return any(name in fn for fn in function_names) + + # Should detect the main functions from main.ps1 + assert has_function("Greet-User"), f"Should find Greet-User function in {function_names}" + assert has_function("Process-Items"), f"Should find Process-Items function in {function_names}" + assert has_function("Main"), f"Should find Main function in {function_names}" + assert len(function_symbols) >= 3, f"Should find at least 3 functions, found {len(function_symbols)}" + + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) + def test_powershell_utils_functions(self, language_server: SolidLanguageServer) -> None: + """Test function detection in utils.ps1 file.""" + # Test with utils.ps1 + utils_all_symbols, _utils_root_symbols = language_server.request_document_symbols("utils.ps1").get_all_symbols_and_roots() + + utils_function_symbols = [symbol for symbol in utils_all_symbols if symbol.get("kind") == 12] + utils_function_names = [symbol["name"] for symbol in utils_function_symbols] + + # PSES returns function names in format "function FuncName ()" - check for function name substring + def has_function(name: str) -> bool: + return any(name in fn for fn in utils_function_names) + + # Should detect functions from utils.ps1 + expected_utils_functions = [ + "Convert-ToUpperCase", + "Convert-ToLowerCase", + "Remove-Whitespace", + "Backup-File", + "Test-ArrayContains", + "Write-LogMessage", + "Test-ValidEmail", + "Test-IsNumber", + ] + + for func_name in expected_utils_functions: + assert has_function(func_name), f"Should find {func_name} function in utils.ps1, got {utils_function_names}" + + assert len(utils_function_symbols) >= 8, f"Should find at least 8 functions in utils.ps1, found {len(utils_function_symbols)}" + + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) + def test_powershell_function_with_parameters(self, language_server: SolidLanguageServer) -> None: + """Test that functions with CmdletBinding and parameters are detected correctly.""" + all_symbols, _root_symbols = language_server.request_document_symbols("main.ps1").get_all_symbols_and_roots() + + function_symbols = [symbol for symbol in all_symbols if symbol.get("kind") == 12] + + # Find Greet-User function which has parameters + # PSES returns function names in format "function FuncName ()" + greet_user_symbol = next((sym for sym in function_symbols if "Greet-User" in sym["name"]), None) + assert greet_user_symbol is not None, f"Should find Greet-User function in {[s['name'] for s in function_symbols]}" + + # Find Process-Items function which has array parameter + process_items_symbol = next((sym for sym in function_symbols if "Process-Items" in sym["name"]), None) + assert process_items_symbol is not None, f"Should find Process-Items function in {[s['name'] for s in function_symbols]}" + + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) + def test_powershell_all_function_detection(self, language_server: SolidLanguageServer) -> None: + """Test that all expected functions are detected across both files.""" + # Get symbols from main.ps1 + main_all_symbols, _main_root_symbols = language_server.request_document_symbols("main.ps1").get_all_symbols_and_roots() + main_functions = [symbol for symbol in main_all_symbols if symbol.get("kind") == 12] + main_function_names = [func["name"] for func in main_functions] + + # Get symbols from utils.ps1 + utils_all_symbols, _utils_root_symbols = language_server.request_document_symbols("utils.ps1").get_all_symbols_and_roots() + utils_functions = [symbol for symbol in utils_all_symbols if symbol.get("kind") == 12] + utils_function_names = [func["name"] for func in utils_functions] + + # PSES returns function names in format "function FuncName ()" - check for function name substring + def has_main_function(name: str) -> bool: + return any(name in fn for fn in main_function_names) + + def has_utils_function(name: str) -> bool: + return any(name in fn for fn in utils_function_names) + + # Verify main.ps1 functions + expected_main = ["Greet-User", "Process-Items", "Main"] + for expected_func in expected_main: + assert has_main_function(expected_func), f"Should detect {expected_func} function in main.ps1, got {main_function_names}" + + # Verify utils.ps1 functions + expected_utils = [ + "Convert-ToUpperCase", + "Convert-ToLowerCase", + "Remove-Whitespace", + "Backup-File", + "Test-ArrayContains", + "Write-LogMessage", + "Test-ValidEmail", + "Test-IsNumber", + ] + for expected_func in expected_utils: + assert has_utils_function(expected_func), f"Should detect {expected_func} function in utils.ps1, got {utils_function_names}" + + # Verify total counts + assert len(main_functions) >= 3, f"Should find at least 3 functions in main.ps1, found {len(main_functions)}" + assert len(utils_functions) >= 8, f"Should find at least 8 functions in utils.ps1, found {len(utils_functions)}" + + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) + def test_powershell_class_method_symbols_use_bare_method_name(self, language_server: SolidLanguageServer) -> None: + """Test whether PSES already reports class methods with bare names.""" + symbols = language_server.request_full_symbol_tree(within_relative_path="main.ps1") + + assert SymbolUtils.symbol_tree_contains_name(symbols, "PersonFormatter"), "Should find PersonFormatter class in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "FormatName"), "Expected PowerShell method to be exposed with bare name" + + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) + def test_powershell_find_references_within_file(self, language_server: SolidLanguageServer) -> None: + """Test finding references to a function within the same file.""" + main_path = "main.ps1" + + # Get symbols to find the Greet-User function which is called from Main + all_symbols, _root_symbols = language_server.request_document_symbols(main_path).get_all_symbols_and_roots() + + # Find Greet-User function definition + function_symbols = [s for s in all_symbols if s.get("kind") == 12] + greet_user_symbol = next((s for s in function_symbols if "Greet-User" in s["name"]), None) + assert greet_user_symbol is not None, f"Should find Greet-User function in {[s['name'] for s in function_symbols]}" + + # Find references to Greet-User (should be called from Main function at line 91) + sel_start = greet_user_symbol["selectionRange"]["start"] + refs = language_server.request_references(main_path, sel_start["line"], sel_start["character"]) + + # Should find at least the call site in Main function + assert refs is not None and len(refs) >= 1, f"Should find references to Greet-User, got {refs}" + assert any("main.ps1" in ref.get("uri", ref.get("relativePath", "")) for ref in refs), ( + f"Should find reference in main.ps1, got {refs}" + ) + + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) + def test_powershell_find_definition_across_files(self, language_server: SolidLanguageServer) -> None: + """Test finding definition of functions across files (main.ps1 -> utils.ps1).""" + # main.ps1 calls Convert-ToUpperCase from utils.ps1 at line 99 (0-indexed: 98) + # The call is: $upperName = Convert-ToUpperCase -InputString $User + # We'll request definition from the call site in main.ps1 + main_path = "main.ps1" + + # Find definition of Convert-ToUpperCase from its usage in main.ps1 + # Line 99 (1-indexed) = line 98 (0-indexed), character position ~16 for "Convert-ToUpperCase" + definition_locations = language_server.request_definition(main_path, 98, 18) + + # Should find the definition in utils.ps1 + assert definition_locations is not None and len(definition_locations) >= 1, ( + f"Should find definition of Convert-ToUpperCase, got {definition_locations}" + ) + assert any("utils.ps1" in loc.get("uri", "") for loc in definition_locations), ( + f"Should find definition in utils.ps1, got {definition_locations}" + ) + + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s, colon_allowed=s["name"].startswith("$")): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/powershell/test_powershell_diagnostics.py b/test/solidlsp/powershell/test_powershell_diagnostics.py new file mode 100644 index 0000000..ac8706c --- /dev/null +++ b/test/solidlsp/powershell/test_powershell_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.powershell +class TestPowershellDiagnostics: + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.ps1", + (), + min_count=1, + ) diff --git a/test/solidlsp/python/test_python_basic.py b/test/solidlsp/python/test_python_basic.py new file mode 100644 index 0000000..283e3e5 --- /dev/null +++ b/test/solidlsp/python/test_python_basic.py @@ -0,0 +1,269 @@ +""" +Basic integration tests for the language server functionality. + +These tests validate the functionality of the language server APIs +like request_references using the test repository. +""" + +import os + +import pytest + +from serena.project import Project +from serena.util.text_utils import LineType +from solidlsp import SolidLanguageServer +from test.conftest import PYTHON_LANGUAGE_BACKENDS +from test.solidlsp.conftest import PYTHON_BACKEND_LANGUAGES, format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.python +class TestPythonLanguageServerBasics: + """Test basic functionality of the language server.""" + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_request_references_user_class(self, language_server: SolidLanguageServer) -> None: + """Test request_references on the User class.""" + # Get references to the User class in models.py + file_path = os.path.join("test_repo", "models.py") + # Line 31 contains the User class definition + # Use selectionRange only + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + user_symbol = next((s for s in symbols[0] if s.get("name") == "User"), None) + if not user_symbol or "selectionRange" not in user_symbol: + raise AssertionError("User symbol or its selectionRange not found") + sel_start = user_symbol["selectionRange"]["start"] + references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert len(references) > 1, "User class should be referenced in multiple files (using selectionRange if present)" + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_request_references_item_class(self, language_server: SolidLanguageServer) -> None: + """Test request_references on the Item class.""" + # Get references to the Item class in models.py + file_path = os.path.join("test_repo", "models.py") + # Line 56 contains the Item class definition + # Use selectionRange only + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + item_symbol = next((s for s in symbols[0] if s.get("name") == "Item"), None) + if not item_symbol or "selectionRange" not in item_symbol: + raise AssertionError("Item symbol or its selectionRange not found") + sel_start = item_symbol["selectionRange"]["start"] + references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + services_references = [ref for ref in references if "services.py" in ref["uri"]] + assert len(services_references) > 0, "At least one reference should be in services.py (using selectionRange if present)" + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_request_references_function_parameter(self, language_server: SolidLanguageServer) -> None: + """Test request_references on a function parameter.""" + # Get references to the id parameter in get_user method + file_path = os.path.join("test_repo", "services.py") + # Line 24 contains the get_user method with id parameter + # Use selectionRange only + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + get_user_symbol = next((s for s in symbols[0] if s.get("name") == "get_user"), None) + if not get_user_symbol or "selectionRange" not in get_user_symbol: + raise AssertionError("get_user symbol or its selectionRange not found") + sel_start = get_user_symbol["selectionRange"]["start"] + references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert len(references) > 0, "id parameter should be referenced within the method (using selectionRange if present)" + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_request_references_create_user_method(self, language_server: SolidLanguageServer) -> None: + # Get references to the create_user method in UserService + file_path = os.path.join("test_repo", "services.py") + # Line 15 contains the create_user method definition + # Use selectionRange only + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + create_user_symbol = next((s for s in symbols[0] if s.get("name") == "create_user"), None) + if not create_user_symbol or "selectionRange" not in create_user_symbol: + raise AssertionError("create_user symbol or its selectionRange not found") + sel_start = create_user_symbol["selectionRange"]["start"] + references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert len(references) > 1, "Should get valid references for create_user (using selectionRange if present)" + + @pytest.mark.parametrize("language_server", PYTHON_LANGUAGE_BACKENDS, indirect=True) + def test_request_text_document_diagnostics_with_filters(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("test_repo", "diagnostics_sample.py") + + diagnostics = language_server.request_text_document_diagnostics(file_path) + assert len(diagnostics) >= 2 + diagnostic_messages = [diagnostic["message"] for diagnostic in diagnostics] + assert any("missing_user" in message for message in diagnostic_messages), diagnostic_messages + assert any("undefined_name" in message for message in diagnostic_messages), diagnostic_messages + + factory_diagnostics = language_server.request_text_document_diagnostics(file_path, start_line=3, end_line=5, min_severity=1) + factory_messages = [diagnostic["message"] for diagnostic in factory_diagnostics] + assert factory_messages, "Expected diagnostics in broken_factory range" + assert all("missing_user" in message for message in factory_messages), factory_messages + + consumer_diagnostics = language_server.request_text_document_diagnostics(file_path, start_line=7, end_line=10, min_severity=1) + consumer_messages = [diagnostic["message"] for diagnostic in consumer_diagnostics] + assert consumer_messages, "Expected diagnostics in broken_consumer range" + assert all("undefined_name" in message for message in consumer_messages), consumer_messages + + +class TestProjectBasics: + @pytest.mark.parametrize("project", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_retrieve_content_around_line(self, project: Project) -> None: + """Test retrieve_content_around_line functionality with various scenarios.""" + file_path = os.path.join("test_repo", "models.py") + + # Scenario 1: Just a single line (User class definition) + line_31 = project.retrieve_content_around_line(file_path, 31) + assert len(line_31.lines) == 1 + assert "class User(BaseModel):" in line_31.lines[0].line_content + assert line_31.lines[0].line_number == 31 + assert line_31.lines[0].match_type == LineType.MATCH + + # Scenario 2: Context above and below + with_context_around_user = project.retrieve_content_around_line(file_path, 31, 2, 2) + assert len(with_context_around_user.lines) == 5 + # Check line content + assert "class User(BaseModel):" in with_context_around_user.matched_lines[0].line_content + assert with_context_around_user.num_matched_lines == 1 + assert " User model representing a system user." in with_context_around_user.lines[4].line_content + # Check line numbers + assert with_context_around_user.lines[0].line_number == 29 + assert with_context_around_user.lines[1].line_number == 30 + assert with_context_around_user.lines[2].line_number == 31 + assert with_context_around_user.lines[3].line_number == 32 + assert with_context_around_user.lines[4].line_number == 33 + # Check match types + assert with_context_around_user.lines[0].match_type == LineType.BEFORE_MATCH + assert with_context_around_user.lines[1].match_type == LineType.BEFORE_MATCH + assert with_context_around_user.lines[2].match_type == LineType.MATCH + assert with_context_around_user.lines[3].match_type == LineType.AFTER_MATCH + assert with_context_around_user.lines[4].match_type == LineType.AFTER_MATCH + + # Scenario 3a: Only context above + with_context_above = project.retrieve_content_around_line(file_path, 31, 3, 0) + assert len(with_context_above.lines) == 4 + assert "return cls(id=id, name=name)" in with_context_above.lines[0].line_content + assert "class User(BaseModel):" in with_context_above.matched_lines[0].line_content + assert with_context_above.num_matched_lines == 1 + # Check line numbers + assert with_context_above.lines[0].line_number == 28 + assert with_context_above.lines[1].line_number == 29 + assert with_context_above.lines[2].line_number == 30 + assert with_context_above.lines[3].line_number == 31 + # Check match types + assert with_context_above.lines[0].match_type == LineType.BEFORE_MATCH + assert with_context_above.lines[1].match_type == LineType.BEFORE_MATCH + assert with_context_above.lines[2].match_type == LineType.BEFORE_MATCH + assert with_context_above.lines[3].match_type == LineType.MATCH + + # Scenario 3b: Only context below + with_context_below = project.retrieve_content_around_line(file_path, 31, 0, 3) + assert len(with_context_below.lines) == 4 + assert "class User(BaseModel):" in with_context_below.matched_lines[0].line_content + assert with_context_below.num_matched_lines == 1 + assert with_context_below.lines[0].line_number == 31 + assert with_context_below.lines[1].line_number == 32 + assert with_context_below.lines[2].line_number == 33 + assert with_context_below.lines[3].line_number == 34 + # Check match types + assert with_context_below.lines[0].match_type == LineType.MATCH + assert with_context_below.lines[1].match_type == LineType.AFTER_MATCH + assert with_context_below.lines[2].match_type == LineType.AFTER_MATCH + assert with_context_below.lines[3].match_type == LineType.AFTER_MATCH + + # Scenario 4a: Edge case - context above but line is at 0 + first_line_with_context_around = project.retrieve_content_around_line(file_path, 0, 2, 1) + assert len(first_line_with_context_around.lines) <= 4 # Should have at most 4 lines (line 0 + 1 below + up to 2 above) + assert first_line_with_context_around.lines[0].line_number <= 2 # First line should be at most line 2 + # Check match type for the target line + for line in first_line_with_context_around.lines: + if line.line_number == 0: + assert line.match_type == LineType.MATCH + elif line.line_number < 0: + assert line.match_type == LineType.BEFORE_MATCH + else: + assert line.match_type == LineType.AFTER_MATCH + + # Scenario 4b: Edge case - context above but line is at 1 + second_line_with_context_above = project.retrieve_content_around_line(file_path, 1, 3, 1) + assert len(second_line_with_context_above.lines) <= 5 # Should have at most 5 lines (line 1 + 1 below + up to 3 above) + assert second_line_with_context_above.lines[0].line_number <= 1 # First line should be at most line 1 + # Check match type for the target line + for line in second_line_with_context_above.lines: + if line.line_number == 1: + assert line.match_type == LineType.MATCH + elif line.line_number < 1: + assert line.match_type == LineType.BEFORE_MATCH + else: + assert line.match_type == LineType.AFTER_MATCH + + # Scenario 4c: Edge case - context below but line is at the end of file + # First get the total number of lines in the file + all_content = project.read_file(file_path) + total_lines = len(all_content.split("\n")) + + last_line_with_context_around = project.retrieve_content_around_line(file_path, total_lines - 1, 1, 3) + assert len(last_line_with_context_around.lines) <= 5 # Should have at most 5 lines (last line + 1 above + up to 3 below) + assert last_line_with_context_around.lines[-1].line_number >= total_lines - 4 # Last line should be at least total_lines - 4 + # Check match type for the target line + for line in last_line_with_context_around.lines: + if line.line_number == total_lines - 1: + assert line.match_type == LineType.MATCH + elif line.line_number < total_lines - 1: + assert line.match_type == LineType.BEFORE_MATCH + else: + assert line.match_type == LineType.AFTER_MATCH + + @pytest.mark.parametrize("project", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_search_files_for_pattern(self, project: Project) -> None: + """Test search_files_for_pattern with various patterns and glob filters.""" + # Test 1: Search for class definitions across all files + class_pattern = r"class\s+\w+\s*(?:\([^{]*\)|:)" + matches = project.search_project_files_for_pattern(class_pattern) + assert len(matches) > 0 + # Should find multiple classes like User, Item, BaseModel, etc. + assert len(matches) >= 5 + + # Test 2: Search for specific class with include glob + user_class_pattern = r"class\s+User\s*(?:\([^{]*\)|:)" + matches = project.search_project_files_for_pattern(user_class_pattern, paths_include_glob="**/models.py") + assert len(matches) == 1 # Should only find User class in models.py + assert matches[0].source_file_path is not None + assert "models.py" in matches[0].source_file_path + + # Test 3: Search for method definitions with exclude glob + method_pattern = r"def\s+\w+\s*\([^)]*\):" + matches = project.search_project_files_for_pattern(method_pattern, paths_exclude_glob="**/models.py") + assert len(matches) > 0 + # Should find methods in services.py but not in models.py + assert all(match.source_file_path is not None and "models.py" not in match.source_file_path for match in matches) + + # Test 4: Search for specific method with both include and exclude globs + create_user_pattern = r"def\s+create_user\s*\([^)]*\)(?:\s*->[^:]+)?:" + matches = project.search_project_files_for_pattern( + create_user_pattern, paths_include_glob="**/*.py", paths_exclude_glob="**/models.py" + ) + assert len(matches) == 1 # Should only find create_user in services.py + assert matches[0].source_file_path is not None + assert "services.py" in matches[0].source_file_path + + # Test 5: Search for a pattern that should appear in multiple files + init_pattern = r"def\s+__init__\s*\([^)]*\):" + matches = project.search_project_files_for_pattern(init_pattern) + assert len(matches) > 1 # Should find __init__ in multiple classes + # Should find __init__ in both models.py and services.py + assert any(match.source_file_path is not None and "models.py" in match.source_file_path for match in matches) + assert any(match.source_file_path is not None and "services.py" in match.source_file_path for match in matches) + + # Test 6: Search with a pattern that should have no matches + no_match_pattern = r"def\s+this_method_does_not_exist\s*\([^)]*\):" + matches = project.search_project_files_for_pattern(no_match_pattern) + assert len(matches) == 0 + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/python/test_python_diagnostics.py b/test/solidlsp/python/test_python_diagnostics.py new file mode 100644 index 0000000..386d283 --- /dev/null +++ b/test/solidlsp/python/test_python_diagnostics.py @@ -0,0 +1,19 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from test.conftest import PYTHON_LANGUAGE_BACKENDS +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.python +class TestPythonDiagnostics: + @pytest.mark.parametrize("language_server", PYTHON_LANGUAGE_BACKENDS, indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + os.path.join("test_repo", "diagnostics_sample.py"), + ("missing_user", "undefined_name"), + min_count=2, + ) diff --git a/test/solidlsp/python/test_retrieval_with_ignored_dirs.py b/test/solidlsp/python/test_retrieval_with_ignored_dirs.py new file mode 100644 index 0000000..9c12af8 --- /dev/null +++ b/test/solidlsp/python/test_retrieval_with_ignored_dirs.py @@ -0,0 +1,63 @@ +from collections.abc import Generator +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import start_ls_context +from test.solidlsp.conftest import PYTHON_BACKEND_LANGUAGES + +# This mark will be applied to all tests in this module +pytestmark = pytest.mark.python + + +@pytest.fixture(scope="module") +def ls_with_ignored_dirs() -> Generator[SolidLanguageServer, None, None]: + """Fixture to set up an LS for the python test repo with the 'scripts' directory ignored.""" + ignored_paths = ["scripts", "custom_test"] + with start_ls_context(language=Language.PYTHON, ignored_paths=ignored_paths) as ls: + yield ls + + +@pytest.mark.parametrize("ls_with_ignored_dirs", PYTHON_BACKEND_LANGUAGES, indirect=True) +def test_symbol_tree_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): + """Tests that request_full_symbol_tree ignores the configured directory.""" + root = ls_with_ignored_dirs.request_full_symbol_tree()[0] + root_children = root["children"] + children_names = {child["name"] for child in root_children} + assert children_names == {"test_repo", "examples"} + + +@pytest.mark.parametrize("ls_with_ignored_dirs", PYTHON_BACKEND_LANGUAGES, indirect=True) +def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): + """Tests that find_references ignores the configured directory.""" + # Location of Item, which is referenced in scripts + definition_file = "test_repo/models.py" + definition_line = 56 + definition_col = 6 + + references = ls_with_ignored_dirs.request_references(definition_file, definition_line, definition_col) + + # assert that scripts does not appear in the references + assert not any("scripts" in ref["relativePath"] for ref in references) + + +@pytest.mark.parametrize("repo_path", PYTHON_BACKEND_LANGUAGES, indirect=True) +def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None: + """Tests that refs and symbols with glob patterns are ignored.""" + ignored_paths = ["*ipts", "custom_t*"] + with start_ls_context(language=Language.PYTHON, repo_path=str(repo_path), ignored_paths=ignored_paths) as ls: + # same as in the above tests + root = ls.request_full_symbol_tree()[0] + root_children = root["children"] + children_names = {child["name"] for child in root_children} + assert children_names == {"test_repo", "examples"} + + # test that the refs and symbols with glob patterns are ignored + definition_file = "test_repo/models.py" + definition_line = 56 + definition_col = 6 + + references = ls.request_references(definition_file, definition_line, definition_col) + assert not any("scripts" in ref["relativePath"] for ref in references) diff --git a/test/solidlsp/python/test_symbol_retrieval.py b/test/solidlsp/python/test_symbol_retrieval.py new file mode 100644 index 0000000..b95155d --- /dev/null +++ b/test/solidlsp/python/test_symbol_retrieval.py @@ -0,0 +1,410 @@ +""" +Tests for the language server symbol-related functionality. + +These tests focus on the following methods: +- request_containing_symbol +- request_referencing_symbols +""" + +import os + +import pytest + +from serena.symbol import LanguageServerSymbol +from solidlsp import SolidLanguageServer +from solidlsp.ls_types import SymbolKind +from test.solidlsp.conftest import PYTHON_BACKEND_LANGUAGES + +pytestmark = pytest.mark.python + + +class TestLanguageServerSymbols: + """Test the language server's symbol-related functionality.""" + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_request_containing_symbol_function(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol for a function.""" + # Test for a position inside the create_user method + file_path = os.path.join("test_repo", "services.py") + # Line 17 is inside the create_user method body + containing_symbol = language_server.request_containing_symbol(file_path, 17, 20, include_body=True) + + # Verify that we found the containing symbol + assert containing_symbol is not None + assert containing_symbol["name"] == "create_user" + assert containing_symbol["kind"] == SymbolKind.Method + if "body" in containing_symbol: + assert containing_symbol["body"].get_text().strip().startswith("def create_user(self") + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_references_to_variables(self, language_server: SolidLanguageServer) -> None: + """Test request_referencing_symbols for a variable.""" + file_path = os.path.join("test_repo", "variables.py") + # Line 75 contains the field status that is later modified + ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, 74, 4)] + + assert len(ref_symbols) > 0 + ref_lines = [ref["location"]["range"]["start"]["line"] for ref in ref_symbols if "location" in ref and "range" in ref["location"]] + ref_names = [ref["name"] for ref in ref_symbols] + assert 87 in ref_lines + assert 95 in ref_lines + assert "dataclass_instance" in ref_names + assert "second_dataclass" in ref_names + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_request_containing_symbol_class(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol for a class.""" + # Test for a position inside the UserService class but outside any method + file_path = os.path.join("test_repo", "services.py") + # Line 9 is the class definition line for UserService + containing_symbol = language_server.request_containing_symbol(file_path, 9, 7) + + # Verify that we found the containing symbol + assert containing_symbol is not None + assert containing_symbol["name"] == "UserService" + assert containing_symbol["kind"] == SymbolKind.Class + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_request_containing_symbol_nested(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol with nested scopes.""" + # Test for a position inside a method which is inside a class + file_path = os.path.join("test_repo", "services.py") + # Line 18 is inside the create_user method inside UserService class + containing_symbol = language_server.request_containing_symbol(file_path, 18, 25) + + # Verify that we found the innermost containing symbol (the method) + assert containing_symbol is not None + assert containing_symbol["name"] == "create_user" + assert containing_symbol["kind"] == SymbolKind.Method + + # Get the parent containing symbol + if "location" in containing_symbol and "range" in containing_symbol["location"]: + parent_symbol = language_server.request_containing_symbol( + file_path, + containing_symbol["location"]["range"]["start"]["line"], + containing_symbol["location"]["range"]["start"]["character"] - 1, + ) + + # Verify that the parent is the class + assert parent_symbol is not None + assert parent_symbol["name"] == "UserService" + assert parent_symbol["kind"] == SymbolKind.Class + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_request_containing_symbol_none(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol for a position with no containing symbol.""" + # Test for a position outside any function/class (e.g., in imports) + file_path = os.path.join("test_repo", "services.py") + # Line 1 is in imports, not inside any function or class + containing_symbol = language_server.request_containing_symbol(file_path, 1, 10) + + # Should return None or an empty dictionary + assert containing_symbol is None or containing_symbol == {} + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_request_referencing_symbols_function(self, language_server: SolidLanguageServer) -> None: + """Test request_referencing_symbols for a function.""" + # Test referencing symbols for create_user function + file_path = os.path.join("test_repo", "services.py") + # Line 15 contains the create_user function definition + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + create_user_symbol = next((s for s in symbols[0] if s.get("name") == "create_user"), None) + if not create_user_symbol or "selectionRange" not in create_user_symbol: + raise AssertionError("create_user symbol or its selectionRange not found") + sel_start = create_user_symbol["selectionRange"]["start"] + ref_symbols = [ + ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) + ] + assert len(ref_symbols) > 0, "No referencing symbols found for create_user (selectionRange)" + + # Verify the structure of referencing symbols + for symbol in ref_symbols: + assert "name" in symbol + assert "kind" in symbol + if "location" in symbol and "range" in symbol["location"]: + assert "start" in symbol["location"]["range"] + assert "end" in symbol["location"]["range"] + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_request_referencing_symbols_class(self, language_server: SolidLanguageServer) -> None: + """Test request_referencing_symbols for a class.""" + # Test referencing symbols for User class + file_path = os.path.join("test_repo", "models.py") + # Line 31 contains the User class definition + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + user_symbol = next((s for s in symbols[0] if s.get("name") == "User"), None) + if not user_symbol or "selectionRange" not in user_symbol: + raise AssertionError("User symbol or its selectionRange not found") + sel_start = user_symbol["selectionRange"]["start"] + ref_symbols = [ + ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) + ] + services_references = [ + symbol + for symbol in ref_symbols + if "location" in symbol and "uri" in symbol["location"] and "services.py" in symbol["location"]["uri"] + ] + assert len(services_references) > 0, "No referencing symbols from services.py for User (selectionRange)" + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_request_referencing_symbols_parameter(self, language_server: SolidLanguageServer) -> None: + """Test request_referencing_symbols for a function parameter.""" + # Test referencing symbols for id parameter in get_user + file_path = os.path.join("test_repo", "services.py") + # Line 24 contains the get_user method with id parameter + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + get_user_symbol = next((s for s in symbols[0] if s.get("name") == "get_user"), None) + if not get_user_symbol or "selectionRange" not in get_user_symbol: + raise AssertionError("get_user symbol or its selectionRange not found") + sel_start = get_user_symbol["selectionRange"]["start"] + ref_symbols = [ + ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) + ] + method_refs = [ + symbol + for symbol in ref_symbols + if "location" in symbol and "range" in symbol["location"] and symbol["location"]["range"]["start"]["line"] > sel_start["line"] + ] + assert len(method_refs) > 0, "No referencing symbols within method body for get_user (selectionRange)" + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_request_referencing_symbols_none(self, language_server: SolidLanguageServer) -> None: + """Test request_referencing_symbols for a position with no symbol.""" + # For positions with no symbol, the method might throw an error or return None/empty list + # We'll modify our test to handle this by using a try-except block + + file_path = os.path.join("test_repo", "services.py") + # Line 3 is a blank line or comment + try: + ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, 3, 0)] + # If we get here, make sure we got an empty result + assert ref_symbols == [] or ref_symbols is None + except Exception: + # The method might raise an exception for invalid positions + # which is acceptable behavior + pass + + # Tests for request_defining_symbol + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_request_defining_symbol_variable(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for a variable usage.""" + # Test finding the definition of a symbol in the create_user method + file_path = os.path.join("test_repo", "services.py") + # Line 21 contains self.users[id] = user + defining_symbol = language_server.request_defining_symbol(file_path, 21, 10) + + # Verify that we found the defining symbol + # The defining symbol method returns a dictionary with information about the defining symbol + assert defining_symbol is not None + assert defining_symbol.get("name") == "create_user" + + # Verify the location and kind of the symbol + # SymbolKind.Method = 6 for a method + assert defining_symbol.get("kind") == SymbolKind.Method.value + if "location" in defining_symbol and "uri" in defining_symbol["location"]: + assert "services.py" in defining_symbol["location"]["uri"] + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_request_defining_symbol_imported_class(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for an imported class.""" + # Test finding the definition of the 'User' class used in the UserService.create_user method + file_path = os.path.join("test_repo", "services.py") + # Line 20 references 'User' which was imported from models + defining_symbol = language_server.request_defining_symbol(file_path, 20, 15) + + # Verify that we found the defining symbol - this should be the User class from models + assert defining_symbol is not None + assert defining_symbol.get("name") == "User" + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_request_defining_symbol_method_call(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for a method call.""" + # Create an example file path for a file that calls UserService.create_user + examples_file_path = os.path.join("examples", "user_management.py") + + # Find the line number where create_user is called + # This could vary, so we'll use a relative position that makes sense + defining_symbol = language_server.request_defining_symbol(examples_file_path, 46, 29) + assert defining_symbol is not None + assert defining_symbol.get("name") == "create_user" + # The defining symbol should be in the services.py file + assert "services.py" in defining_symbol["location"]["uri"] + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_request_defining_symbol_none(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for a position with no symbol.""" + # Test for a position with no symbol (e.g., whitespace or comment) + file_path = os.path.join("test_repo", "services.py") + # Line 3 is a blank line + defining_symbol = language_server.request_defining_symbol(file_path, 3, 0) + + # Should return None for positions with no symbol + assert defining_symbol is None + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_request_containing_symbol_variable(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol where the symbol is a variable.""" + # Test for a position inside a variable definition + file_path = os.path.join("test_repo", "services.py") + # Line 74 defines the 'user' variable + containing_symbol = language_server.request_containing_symbol(file_path, 73, 1) + + # Verify that we found the containing symbol + assert containing_symbol is not None + assert containing_symbol["name"] == "user_var_str" + assert containing_symbol["kind"] == SymbolKind.Variable + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_request_defining_symbol_nested_function(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for a nested function or closure.""" + # Use the existing nested.py file which contains nested classes and methods + file_path = os.path.join("test_repo", "nested.py") + + # Test 1: Find definition of nested method - line with 'b = OuterClass().NestedClass().find_me()' + defining_symbol = language_server.request_defining_symbol(file_path, 15, 35) # Position of find_me() call + + # This should resolve to the find_me method in the NestedClass + assert defining_symbol is not None + assert defining_symbol.get("name") == "find_me" + assert defining_symbol.get("kind") == SymbolKind.Method.value + + # Test 2: Find definition of the nested class + defining_symbol = language_server.request_defining_symbol(file_path, 15, 18) # Position of NestedClass + + # This should resolve to the NestedClass + assert defining_symbol is not None + assert defining_symbol.get("name") == "NestedClass" + assert defining_symbol.get("kind") == SymbolKind.Class.value + + # Test 3: Find definition of a method-local function + defining_symbol = language_server.request_defining_symbol(file_path, 9, 15) # Position inside func_within_func + + assert defining_symbol is not None + assert defining_symbol.get("name") == "func_within_func" + + # Test 2: Find definition of the nested class + defining_symbol = language_server.request_defining_symbol(file_path, 15, 18) # Position of NestedClass + + # This should resolve to the NestedClass + assert defining_symbol is not None + assert defining_symbol.get("name") == "NestedClass" + assert defining_symbol.get("kind") == SymbolKind.Class.value + + # Test 3: Find definition of a method-local function + defining_symbol = language_server.request_defining_symbol(file_path, 9, 15) # Position inside func_within_func + + # This is challenging for many language servers and may fail + assert defining_symbol is not None + assert defining_symbol.get("name") == "func_within_func" + assert defining_symbol.get("kind") in [SymbolKind.Function.value, SymbolKind.Method.value] + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_symbol_methods_integration(self, language_server: SolidLanguageServer) -> None: + """Test the integration between different symbol-related methods.""" + # This test demonstrates using the various symbol methods together + # by finding a symbol and then checking its definition + + file_path = os.path.join("test_repo", "services.py") + + # First approach: Use a method from the UserService class + # Step 1: Find a method we know exists + containing_symbol = language_server.request_containing_symbol(file_path, 15, 8) # create_user method + assert containing_symbol is not None + assert containing_symbol["name"] == "create_user" + + # Step 2: Get the defining symbol for the same position + # This should be the same method + defining_symbol = language_server.request_defining_symbol(file_path, 15, 8) + assert defining_symbol is not None + assert defining_symbol["name"] == "create_user" + + # Step 3: Verify that they refer to the same symbol + assert defining_symbol["kind"] == containing_symbol["kind"] + assert defining_symbol["location"]["uri"] == containing_symbol["location"]["uri"] + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_symbol_tree_structure(self, language_server: SolidLanguageServer) -> None: + """Test that the symbol tree structure is correctly built.""" + # Get all symbols in the test file + repo_structure = language_server.request_full_symbol_tree() + assert len(repo_structure) == 1 + # Assert that the root symbol is the test_repo directory + assert repo_structure[0]["name"] == "test_repo" + assert repo_structure[0]["kind"] == SymbolKind.Package + assert "children" in repo_structure[0] + # Assert that the children are the top-level packages + child_names = {child["name"] for child in repo_structure[0]["children"]} + child_kinds = {child["kind"] for child in repo_structure[0]["children"]} + assert child_names == {"test_repo", "custom_test", "examples", "scripts"} + assert child_kinds == {SymbolKind.Package} + examples_package = next(child for child in repo_structure[0]["children"] if child["name"] == "examples") + # assert that children are __init__ and user_management + assert {child["name"] for child in examples_package["children"]} == {"__init__", "user_management"} + assert {child["kind"] for child in examples_package["children"]} == {SymbolKind.File} + + # assert that tree of user_management node is same as retrieved directly + user_management_node = next(child for child in examples_package["children"] if child["name"] == "user_management") + if "location" in user_management_node and "relativePath" in user_management_node["location"]: + user_management_rel_path = user_management_node["location"]["relativePath"] + assert user_management_rel_path == os.path.join("examples", "user_management.py") + _, user_management_roots = language_server.request_document_symbols( + os.path.join("examples", "user_management.py") + ).get_all_symbols_and_roots() + assert user_management_roots == user_management_node["children"] + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_symbol_tree_structure_subdir(self, language_server: SolidLanguageServer) -> None: + """Test that the symbol tree structure is correctly built.""" + # Get all symbols in the test file + examples_package_roots = language_server.request_full_symbol_tree(within_relative_path="examples") + assert len(examples_package_roots) == 1 + examples_package = examples_package_roots[0] + assert examples_package["name"] == "examples" + assert examples_package["kind"] == SymbolKind.Package + # assert that children are __init__ and user_management + assert {child["name"] for child in examples_package["children"]} == {"__init__", "user_management"} + assert {child["kind"] for child in examples_package["children"]} == {SymbolKind.File} + + # assert that tree of user_management node is same as retrieved directly + user_management_node = next(child for child in examples_package["children"] if child["name"] == "user_management") + if "location" in user_management_node and "relativePath" in user_management_node["location"]: + user_management_rel_path = user_management_node["location"]["relativePath"] + assert user_management_rel_path == os.path.join("examples", "user_management.py") + _, user_management_roots = language_server.request_document_symbols( + os.path.join("examples", "user_management.py") + ).get_all_symbols_and_roots() + assert user_management_roots == user_management_node["children"] + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_request_dir_overview(self, language_server: SolidLanguageServer) -> None: + """Test that request_dir_overview returns correct symbol information for files in a directory.""" + # Get overview of the examples directory + overview = language_server.request_dir_overview("test_repo") + + # Verify that we have entries for both files + assert os.path.join("test_repo", "nested.py") in overview + + # Get the symbols for user_management.py + services_symbols = overview[os.path.join("test_repo", "services.py")] + assert len(services_symbols) > 0 + + # Check for specific symbols from services.py + expected_symbols = { + "UserService", + "ItemService", + "create_service_container", + "user_var_str", + "user_service", + } + retrieved_symbols = {symbol["name"] for symbol in services_symbols if "name" in symbol} + assert expected_symbols.issubset(retrieved_symbols) + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_request_document_overview(self, language_server: SolidLanguageServer) -> None: + """Test that request_document_overview returns correct symbol information for a file.""" + # Get overview of the user_management.py file + overview = language_server.request_document_overview(os.path.join("examples", "user_management.py")) + + # Verify that we have entries for both files + symbol_names = {LanguageServerSymbol(s_info).name for s_info in overview} + assert {"UserStats", "UserManager", "process_user_data", "main"}.issubset(symbol_names) diff --git a/test/solidlsp/r/__init__.py b/test/solidlsp/r/__init__.py new file mode 100644 index 0000000..3348d2e --- /dev/null +++ b/test/solidlsp/r/__init__.py @@ -0,0 +1 @@ +# Empty init file for R tests diff --git a/test/solidlsp/r/test_r_basic.py b/test/solidlsp/r/test_r_basic.py new file mode 100644 index 0000000..99190d5 --- /dev/null +++ b/test/solidlsp/r/test_r_basic.py @@ -0,0 +1,111 @@ +""" +Basic tests for R Language Server integration +""" + +import os +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.skipif(not language_tests_enabled(Language.R), reason="R tests are disabled (R not available)") +@pytest.mark.r +class TestRLanguageServer: + """Test basic functionality of the R language server.""" + + @pytest.mark.parametrize("language_server", [Language.R], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.R], indirect=True) + def test_server_initialization(self, language_server: SolidLanguageServer, repo_path: Path): + """Test that the R language server initializes properly.""" + assert language_server is not None + assert language_server.language_id == "r" + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.R], indirect=True) + def test_symbol_retrieval(self, language_server: SolidLanguageServer): + """Test R document symbol extraction.""" + all_symbols, _root_symbols = language_server.request_document_symbols(os.path.join("R", "utils.R")).get_all_symbols_and_roots() + + # Should find the three exported functions + function_symbols = [s for s in all_symbols if s.get("kind") == 12] # Function kind + assert len(function_symbols) >= 3 + + # Check that we found the expected functions + function_names = {s.get("name") for s in function_symbols} + expected_functions = {"calculate_mean", "process_data", "create_data_frame"} + assert expected_functions.issubset(function_names), f"Expected functions {expected_functions} but found {function_names}" + + @pytest.mark.parametrize("language_server", [Language.R], indirect=True) + def test_find_definition_across_files(self, language_server: SolidLanguageServer): + """Test finding function definitions across files.""" + analysis_file = os.path.join("examples", "analysis.R") + + # In analysis.R line 7: create_data_frame(n = 50) + # The function create_data_frame is defined in R/utils.R + # Find definition of create_data_frame function call (0-indexed: line 6) + definition_location_list = language_server.request_definition(analysis_file, 6, 17) # cursor on 'create_data_frame' + + assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" + assert len(definition_location_list) >= 1 + definition_location = definition_location_list[0] + assert definition_location["uri"].endswith("utils.R") + # Definition should be around line 37 (0-indexed: 36) where create_data_frame is defined + assert definition_location["range"]["start"]["line"] >= 35 + + @pytest.mark.parametrize("language_server", [Language.R], indirect=True) + def test_find_references_across_files(self, language_server: SolidLanguageServer): + """Test finding function references across files.""" + analysis_file = os.path.join("examples", "analysis.R") + + # Test from usage side: find references to calculate_mean from its usage in analysis.R + # In analysis.R line 13: calculate_mean(clean_data$value) + # calculate_mean function call is at line 13 (0-indexed: line 12) + references = language_server.request_references(analysis_file, 12, 15) # cursor on 'calculate_mean' + + assert references, f"Expected non-empty references for calculate_mean but got {references=}" + + # Must find the definition in utils.R (cross-file reference) + reference_files = [ref["uri"] for ref in references] + assert any(uri.endswith("utils.R") for uri in reference_files), "Cross-file reference to definition in utils.R not found" + + # Verify we actually found the right location in utils.R + utils_refs = [ref for ref in references if ref["uri"].endswith("utils.R")] + assert len(utils_refs) >= 1, "Should find at least one reference in utils.R" + utils_ref = utils_refs[0] + # Should be around line 6 where calculate_mean is defined (0-indexed: line 5) + assert utils_ref["range"]["start"]["line"] == 5, ( + f"Expected reference at line 5 in utils.R, got line {utils_ref['range']['start']['line']}" + ) + + def test_file_matching(self): + """Test that R files are properly matched.""" + matcher = Language.R.get_source_fn_matcher() + + assert matcher.is_relevant_filename("script.R") + assert matcher.is_relevant_filename("analysis.r") + assert not matcher.is_relevant_filename("script.py") + assert not matcher.is_relevant_filename("README.md") + + def test_r_language_enum(self): + """Test R language enum value.""" + assert Language.R == "r" + assert str(Language.R) == "r" + + @pytest.mark.parametrize("language_server", [Language.R], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/rego/test_rego_basic.py b/test/solidlsp/rego/test_rego_basic.py new file mode 100644 index 0000000..8bbe730 --- /dev/null +++ b/test/solidlsp/rego/test_rego_basic.py @@ -0,0 +1,153 @@ +"""Tests for Rego language server (Regal) functionality.""" + +import os + +import pytest + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from solidlsp.ls_utils import SymbolUtils +from test.conftest import language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.skipif(not language_tests_enabled(Language.REGO), reason="Rego tests are disabled (regal not available)") +@pytest.mark.rego +class TestRegoLanguageServer: + """Test Regal language server functionality for Rego.""" + + @pytest.mark.parametrize("language_server", [Language.REGO], indirect=True) + def test_request_document_symbols_authz(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols can be retrieved from authz.rego.""" + file_path = os.path.join("policies", "authz.rego") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + assert symbols is not None + assert len(symbols) > 0 + + # Extract symbol names + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} + + # Verify specific Rego rules/functions are found + assert "allow" in symbol_names, "allow rule not found" + assert "allow_read" in symbol_names, "allow_read rule not found" + assert "is_admin" in symbol_names, "is_admin function not found" + assert "admin_roles" in symbol_names, "admin_roles constant not found" + + @pytest.mark.parametrize("language_server", [Language.REGO], indirect=True) + def test_request_document_symbols_helpers(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols can be retrieved from helpers.rego.""" + file_path = os.path.join("utils", "helpers.rego") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + assert symbols is not None + assert len(symbols) > 0 + + # Extract symbol names + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} + + # Verify specific helper functions are found + assert "is_valid_user" in symbol_names, "is_valid_user function not found" + assert "is_valid_email" in symbol_names, "is_valid_email function not found" + assert "is_valid_username" in symbol_names, "is_valid_username function not found" + + @pytest.mark.parametrize("language_server", [Language.REGO], indirect=True) + def test_find_symbol_full_tree(self, language_server: SolidLanguageServer) -> None: + """Test finding symbols across entire workspace using symbol tree.""" + symbols = language_server.request_full_symbol_tree() + + # Use SymbolUtils to check for expected symbols + assert SymbolUtils.symbol_tree_contains_name(symbols, "allow"), "allow rule not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "is_valid_user"), "is_valid_user function not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "is_admin"), "is_admin function not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.REGO], indirect=True) + def test_request_definition_within_file(self, language_server: SolidLanguageServer) -> None: + """Test go-to-definition for symbols within the same file.""" + # In authz.rego, check_permission references admin_roles + file_path = os.path.join("policies", "authz.rego") + + # Get document symbols + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + # Find the is_admin symbol which references admin_roles + is_admin_symbol = next((s for s in symbol_list if s.get("name") == "is_admin"), None) + assert is_admin_symbol is not None, "is_admin symbol should always be found in authz.rego" + assert "range" in is_admin_symbol, "is_admin symbol should have a range" + + # Request definition from within is_admin (line 25, which references admin_roles at line 21) + # Line 25 is: admin_roles[_] == user.role + line = is_admin_symbol["range"]["start"]["line"] + 1 + char = 4 # Position at "admin_roles" + + definitions = language_server.request_definition(file_path, line, char) + assert definitions is not None and len(definitions) > 0, "Should find definition for admin_roles" + + # Verify the definition points to admin_roles in the same file + assert any("authz.rego" in defn.get("relativePath", "") for defn in definitions), "Definition should be in authz.rego" + + @pytest.mark.parametrize("language_server", [Language.REGO], indirect=True) + def test_request_definition_across_files(self, language_server: SolidLanguageServer) -> None: + """Test go-to-definition for symbols across files (cross-file references).""" + # In authz.rego line 11, the allow rule calls utils.is_valid_user + # This function is defined in utils/helpers.rego + file_path = os.path.join("policies", "authz.rego") + + # Get document symbols + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + # Find the allow symbol + allow_symbol = next((s for s in symbol_list if s.get("name") == "allow"), None) + assert allow_symbol is not None, "allow symbol should always be found in authz.rego" + assert "range" in allow_symbol, "allow symbol should have a range" + + # Request definition from line 11 where utils.is_valid_user is called + # Line 11: utils.is_valid_user(input.user) + line = 10 # 0-indexed, so line 11 in file is line 10 in LSP + char = 7 # Position at "is_valid_user" in "utils.is_valid_user" + + definitions = language_server.request_definition(file_path, line, char) + assert definitions is not None and len(definitions) > 0, "Should find cross-file definition for is_valid_user" + + # Verify the definition points to helpers.rego (cross-file) + assert any("helpers.rego" in defn.get("relativePath", "") for defn in definitions), ( + "Definition should be in utils/helpers.rego (cross-file reference)" + ) + + @pytest.mark.parametrize("language_server", [Language.REGO], indirect=True) + def test_find_symbols_validation(self, language_server: SolidLanguageServer) -> None: + """Test finding symbols in validation.rego which has imports.""" + file_path = os.path.join("policies", "validation.rego") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + assert symbols is not None + assert len(symbols) > 0 + + # Extract symbol names + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} + + # Verify expected symbols + assert "validate_user_input" in symbol_names, "validate_user_input rule not found" + assert "has_valid_credentials" in symbol_names, "has_valid_credentials function not found" + assert "validate_request" in symbol_names, "validate_request rule not found" + + @pytest.mark.parametrize("language_server", [Language.REGO], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if s["kind"] == SymbolKind.Package: + continue + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/ruby/test_ruby_basic.py b/test/solidlsp/ruby/test_ruby_basic.py new file mode 100644 index 0000000..2a76b4f --- /dev/null +++ b/test/solidlsp/ruby/test_ruby_basic.py @@ -0,0 +1,73 @@ +import os +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.ruby +class TestRubyLanguageServer: + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "DemoClass"), "DemoClass not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "helper_function"), "helper_function not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "print_value"), "print_value not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("main.rb") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + helper_symbol = None + for sym in symbols[0]: + if sym.get("name") == "helper_function": + helper_symbol = sym + break + print(helper_symbol) + assert helper_symbol is not None, "Could not find 'helper_function' symbol in main.rb" + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.RUBY], indirect=True) + def test_find_definition_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + # Test finding Calculator.add method definition from line 17: Calculator.new.add(demo.value, 10) + definition_location_list = language_server.request_definition( + str(repo_path / "main.rb"), 16, 17 + ) # add method at line 17 (0-indexed 16), position 17 + + unique_locations = { + ( + location["relativePath"], + location["range"]["start"]["line"], + location["range"]["start"]["character"], + location["range"]["end"]["line"], + location["range"]["end"]["character"], + ): location + for location in definition_location_list + } + + assert len(unique_locations) == 1 + definition_location = next(iter(unique_locations.values())) + print(f"Found definition: {definition_location}") + assert definition_location["uri"].endswith("lib.rb") + assert definition_location["range"]["start"]["line"] == 1 # add method on line 2 (0-indexed 1) + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name( + s, + whitespace_allowed=s["name"] == "<< self", + period_allowed=s["name"].startswith("self."), + ): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/ruby/test_ruby_symbol_retrieval.py b/test/solidlsp/ruby/test_ruby_symbol_retrieval.py new file mode 100644 index 0000000..ef0c50f --- /dev/null +++ b/test/solidlsp/ruby/test_ruby_symbol_retrieval.py @@ -0,0 +1,628 @@ +""" +Tests for the Ruby language server symbol-related functionality. + +These tests focus on the following methods: +- request_containing_symbol +- request_referencing_symbols +- request_defining_symbol +- request_document_symbols integration +""" + +import os + +import pytest + +from serena.util.text_utils import find_text_coordinates +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind + +pytestmark = pytest.mark.ruby + + +class TestRubyLanguageServerSymbols: + """Test the Ruby language server's symbol-related functionality.""" + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_request_containing_symbol_method(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol for a method.""" + # Test for a position inside the create_user method + file_path = os.path.join("services.rb") + # Look for a position inside the create_user method body + containing_symbol = language_server.request_containing_symbol(file_path, 11, 10, include_body=True) + + # Verify that we found the containing symbol + assert containing_symbol is not None, "Should find containing symbol for method position" + assert containing_symbol["name"] == "create_user", f"Expected 'create_user', got '{containing_symbol['name']}'" + assert containing_symbol["kind"] == SymbolKind.Method.value, ( + f"Expected Method kind ({SymbolKind.Method.value}), got {containing_symbol['kind']}" + ) + + # Verify location information + assert "location" in containing_symbol, "Containing symbol should have location information" + location = containing_symbol["location"] + assert "range" in location, "Location should contain range information" + assert "start" in location["range"], "Range should have start position" + assert "end" in location["range"], "Range should have end position" + + # Verify container information + if "containerName" in containing_symbol: + assert containing_symbol["containerName"] in [ + "Services::UserService", + "UserService", + ], f"Expected UserService container, got '{containing_symbol['containerName']}'" + + # Verify body content if available + if "body" in containing_symbol: + body = containing_symbol["body"].get_text() + assert "def create_user" in body, "Method body should contain method definition" + assert len(body.strip()) > 0, "Method body should not be empty" + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_request_containing_symbol_class(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol for a class.""" + # Test for a position inside the UserService class but outside any method + file_path = os.path.join("services.rb") + # Line around the class definition + containing_symbol = language_server.request_containing_symbol(file_path, 5, 5) + + # Verify that we found the containing symbol + assert containing_symbol is not None, "Should find containing symbol for class position" + assert containing_symbol["name"] == "UserService", f"Expected 'UserService', got '{containing_symbol['name']}'" + assert containing_symbol["kind"] == SymbolKind.Class.value, ( + f"Expected Class kind ({SymbolKind.Class.value}), got {containing_symbol['kind']}" + ) + + # Verify location information exists + assert "location" in containing_symbol, "Class symbol should have location information" + location = containing_symbol["location"] + assert "range" in location, "Location should contain range" + assert "start" in location["range"] and "end" in location["range"], "Range should have start and end positions" + + # Verify the class is properly nested in the Services module + if "containerName" in containing_symbol: + assert containing_symbol["containerName"] == "Services", ( + f"Expected 'Services' as container, got '{containing_symbol['containerName']}'" + ) + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_request_containing_symbol_module(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol for a module context.""" + # Test that we can find the Services module in document symbols + file_path = os.path.join("services.rb") + symbols, _roots = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + # Verify Services module appears in document symbols + services_module = None + for symbol in symbols: + if symbol.get("name") == "Services" and symbol.get("kind") == SymbolKind.Module: + services_module = symbol + break + + assert services_module is not None, "Services module not found in document symbols" + + # Test that UserService class has Services as container + # Position inside UserService class + containing_symbol = language_server.request_containing_symbol(file_path, 4, 8) + assert containing_symbol is not None + assert containing_symbol["name"] == "UserService" + assert containing_symbol["kind"] == SymbolKind.Class + # Verify the module context is preserved in containerName (if supported by the language server) + # ruby-lsp doesn't provide containerName, but Solargraph does + if "containerName" in containing_symbol: + assert containing_symbol.get("containerName") == "Services" + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_request_containing_symbol_nested_class(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol with nested classes.""" + # Test for a position inside a nested class method + file_path = os.path.join("nested.rb") + # Position inside NestedClass.find_me method + containing_symbol = language_server.request_containing_symbol(file_path, 20, 10) + + # Verify that we found the innermost containing symbol + assert containing_symbol is not None + assert containing_symbol["name"] == "find_me" + assert containing_symbol["kind"] == SymbolKind.Method + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_request_containing_symbol_none(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol for a position with no containing symbol.""" + # Test for a position outside any class/method (e.g., in requires) + file_path = os.path.join("services.rb") + # Line 1 is a require statement, not inside any class or method + containing_symbol = language_server.request_containing_symbol(file_path, 1, 5) + + # Should return None or an empty dictionary + assert containing_symbol is None or containing_symbol == {} + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_request_referencing_symbols_method(self, language_server: SolidLanguageServer) -> None: + """Test request_referencing_symbols for a method.""" + # Test referencing symbols for create_user method + file_path = os.path.join("services.rb") + # Line containing the create_user method definition + symbols, _roots = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + create_user_symbol = None + + # Find create_user method in the document symbols (Ruby returns flat list) + for symbol in symbols: + if symbol.get("name") == "create_user": + create_user_symbol = symbol + break + + if not create_user_symbol or "selectionRange" not in create_user_symbol: + pytest.skip("create_user symbol or its selectionRange not found") + + sel_start = create_user_symbol["selectionRange"]["start"] + ref_symbols = [ + ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) + ] + + # We might not have references in our simple test setup, so just verify structure + for symbol in ref_symbols: + assert "name" in symbol + assert "kind" in symbol + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_request_referencing_symbols_class(self, language_server: SolidLanguageServer) -> None: + """Test request_referencing_symbols for a class.""" + # Test referencing symbols for User class + file_path = os.path.join("models.rb") + # Find User class in document symbols + symbols, _roots = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + user_symbol = None + + for symbol in symbols: + if symbol.get("name") == "User": + user_symbol = symbol + break + + if not user_symbol or "selectionRange" not in user_symbol: + pytest.skip("User symbol or its selectionRange not found") + + sel_start = user_symbol["selectionRange"]["start"] + ref_symbols = [ + ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) + ] + + # Verify structure of referencing symbols + for symbol in ref_symbols: + assert "name" in symbol + assert "kind" in symbol + if "location" in symbol and "range" in symbol["location"]: + assert "start" in symbol["location"]["range"] + assert "end" in symbol["location"]["range"] + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_request_defining_symbol_variable(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for a variable usage.""" + # Test finding the definition of a variable in a method + file_path = os.path.join("services.rb") + # Look for @users variable usage + defining_symbol = language_server.request_defining_symbol(file_path, 12, 10) + + # This test might fail if the language server doesn't support it well + if defining_symbol is not None: + assert "name" in defining_symbol + assert "kind" in defining_symbol + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_request_defining_symbol_class(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for a class reference.""" + # Test finding the definition of the User class used in services + file_path = os.path.join("services.rb") + # Line that references User class + defining_symbol = language_server.request_defining_symbol(file_path, 11, 15) + + # This might not work perfectly in all Ruby language servers + if defining_symbol is not None: + assert "name" in defining_symbol + # The name might be "User" or the method that contains it + assert defining_symbol.get("name") is not None + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_request_defining_symbol_none(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for a position with no symbol.""" + # Test for a position with no symbol (e.g., whitespace or comment) + file_path = os.path.join("services.rb") + # Line 3 is likely a blank line or comment + defining_symbol = language_server.request_defining_symbol(file_path, 3, 0) + + # Should return None for positions with no symbol + assert defining_symbol is None or defining_symbol == {} + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_request_defining_symbol_nested_class(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for nested class access.""" + # Test finding definition of NestedClass + file_path = os.path.join("nested.rb") + # Position where NestedClass is referenced + defining_symbol = language_server.request_defining_symbol(file_path, 44, 25) + + # This is challenging for many language servers + if defining_symbol is not None: + assert "name" in defining_symbol + assert defining_symbol.get("name") in ["NestedClass", "OuterClass"] + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_symbol_methods_integration(self, language_server: SolidLanguageServer) -> None: + """Test the integration between different symbol-related methods.""" + file_path = os.path.join("models.rb") + + # Step 1: Find a method we know exists + containing_symbol = language_server.request_containing_symbol(file_path, 8, 5) # inside initialize method + if containing_symbol is not None: + assert containing_symbol["name"] == "initialize" + + # Step 2: Get the defining symbol for the same position + defining_symbol = language_server.request_defining_symbol(file_path, 8, 5) + if defining_symbol is not None: + assert defining_symbol["name"] == "initialize" + + # Step 3: Verify that they refer to the same symbol type + assert defining_symbol["kind"] == containing_symbol["kind"] + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_symbol_tree_structure_basic(self, language_server: SolidLanguageServer) -> None: + """Test that the symbol tree structure includes Ruby symbols.""" + # Get all symbols in the test repository + repo_structure = language_server.request_full_symbol_tree() + assert len(repo_structure) >= 1 + + # Look for our Ruby files in the structure + found_ruby_files = False + for root in repo_structure: + if "children" in root: + for child in root["children"]: + if child.get("name") in ["models", "services", "nested"]: + found_ruby_files = True + break + + # We should find at least some Ruby files in the symbol tree + assert found_ruby_files, "Ruby files not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_document_symbols_detailed(self, language_server: SolidLanguageServer) -> None: + """Test document symbols for detailed Ruby file structure.""" + file_path = os.path.join("models.rb") + symbols, roots = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + # Verify we have symbols + assert len(symbols) > 0 or len(roots) > 0 + + # Look for expected class names + symbol_names = set() + all_symbols = symbols if symbols else roots + + for symbol in all_symbols: + symbol_names.add(symbol.get("name")) + # Add children names too + if "children" in symbol: + for child in symbol["children"]: + symbol_names.add(child.get("name")) + + # We should find at least some of our defined classes/methods + expected_symbols = {"User", "Item", "Order", "ItemHelpers"} + found_symbols = symbol_names.intersection(expected_symbols) + assert len(found_symbols) > 0, f"Expected symbols not found. Found: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_module_and_class_hierarchy(self, language_server: SolidLanguageServer) -> None: + """Test symbol detection for modules and nested class hierarchies.""" + file_path = os.path.join("nested.rb") + symbols, roots = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + # Verify we can detect the nested structure + assert len(symbols) > 0 or len(roots) > 0 + + # Look for OuterClass and its nested elements + symbol_names = set() + all_symbols = symbols if symbols else roots + + for symbol in all_symbols: + symbol_names.add(symbol.get("name")) + if "children" in symbol: + for child in symbol["children"]: + symbol_names.add(child.get("name")) + # Check deeply nested too + if "children" in child: + for grandchild in child["children"]: + symbol_names.add(grandchild.get("name")) + + # Should find the outer class at minimum + assert "OuterClass" in symbol_names, f"OuterClass not found in symbols: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_references_to_variables(self, language_server: SolidLanguageServer) -> None: + """Test request_referencing_symbols for a variable with detailed verification.""" + file_path = os.path.join("variables.rb") + # Test references to @status variable in DataContainer class (around line 9) + ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, 8, 4)] + + if len(ref_symbols) > 0: + # Verify we have references + assert len(ref_symbols) > 0, "Should find references to @status variable" + + # Check that we have location information + ref_with_locations = [ref for ref in ref_symbols if "location" in ref and "range" in ref["location"]] + assert len(ref_with_locations) > 0, "References should include location information" + + # Verify line numbers are reasonable (should be within the file) + ref_lines = [ref["location"]["range"]["start"]["line"] for ref in ref_with_locations] + assert all(line >= 0 for line in ref_lines), "Reference lines should be valid" + + # Check for specific reference locations we expect + # Lines where @status is modified/accessed + expected_line_ranges = [(20, 40), (45, 70)] # Approximate ranges + found_in_expected_range = any(any(start <= line <= end for start, end in expected_line_ranges) for line in ref_lines) + assert found_in_expected_range, f"Expected references in ranges {expected_line_ranges}, found lines: {ref_lines}" + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_request_referencing_symbols_parameter(self, language_server: SolidLanguageServer) -> None: + """Test request_referencing_symbols for a method parameter.""" + # Test referencing symbols for a method parameter in get_user method + file_path = os.path.join("services.rb") + # Find get_user method and test parameter references + symbols, _roots = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + get_user_symbol = None + + for symbol in symbols: + if symbol.get("name") == "get_user": + get_user_symbol = symbol + break + + if not get_user_symbol or "selectionRange" not in get_user_symbol: + pytest.skip("get_user symbol or its selectionRange not found") + + # Test parameter reference within method body + method_start_line = get_user_symbol["selectionRange"]["start"]["line"] + ref_symbols = [ + ref.symbol + for ref in language_server.request_referencing_symbols(file_path, method_start_line + 1, 10) # Position within method body + ] + + # Verify structure of referencing symbols + for symbol in ref_symbols: + assert "name" in symbol, "Symbol should have name" + assert "kind" in symbol, "Symbol should have kind" + if "location" in symbol and "range" in symbol["location"]: + range_info = symbol["location"]["range"] + assert "start" in range_info, "Range should have start" + assert "end" in range_info, "Range should have end" + # Verify line number is valid (references can be before method definition too) + assert range_info["start"]["line"] >= 0, "Reference line should be valid" + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_request_referencing_symbols_none(self, language_server: SolidLanguageServer) -> None: + """Test request_referencing_symbols for a position with no symbol.""" + # Test for a position with no symbol (comment or blank line) + file_path = os.path.join("services.rb") + + # Try multiple positions that should have no symbols + test_positions = [(1, 0), (2, 0)] # Comment/require lines + + for line, char in test_positions: + try: + ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, line, char)] + # If we get here, make sure we got an empty result or minimal results + if ref_symbols: + # Some language servers might return minimal info, verify it's reasonable + assert len(ref_symbols) <= 3, f"Expected few/no references at line {line}, got {len(ref_symbols)}" + + except Exception as e: + # Some language servers throw exceptions for invalid positions, which is acceptable + assert "symbol" in str(e).lower() or "position" in str(e).lower() or "reference" in str(e).lower(), ( + f"Exception should be related to symbol/position/reference issues, got: {e}" + ) + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_request_dir_overview(self, language_server: SolidLanguageServer) -> None: + """Test that request_dir_overview returns correct symbol information for files in a directory.""" + # Get overview of the test repo directory + overview = language_server.request_dir_overview(".") + + # Verify that we have entries for our main files + expected_files = ["services.rb", "models.rb", "variables.rb", "nested.rb"] + found_files = [] + + for file_path in overview.keys(): + for expected in expected_files: + if expected in file_path: + found_files.append(expected) + break + + assert len(found_files) >= 2, f"Should find at least 2 expected files, found: {found_files}" + + # Test specific symbols from services.rb if it exists + services_file_key = None + for file_path in overview.keys(): + if "services.rb" in file_path: + services_file_key = file_path + break + + if services_file_key: + services_symbols = overview[services_file_key] + assert len(services_symbols) > 0, "services.rb should have symbols" + + # Check for expected symbols with detailed verification + symbol_names = [s[0] for s in services_symbols if isinstance(s, tuple) and len(s) > 0] + if not symbol_names: # If not tuples, try different format + symbol_names = [s.get("name") for s in services_symbols if hasattr(s, "get")] + + expected_symbols = ["Services", "UserService", "ItemService"] + found_expected = [name for name in expected_symbols if name in symbol_names] + assert len(found_expected) >= 1, f"Should find at least one expected symbol, found: {found_expected} in {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_request_document_overview(self, language_server: SolidLanguageServer) -> None: + """Test that request_document_overview returns correct symbol information for a file.""" + # Get overview of the user_management.rb file + file_path = os.path.join("examples", "user_management.rb") + overview = language_server.request_document_overview(file_path) + + # Verify that we have symbol information + assert len(overview) > 0, "Document overview should contain symbols" + + # Look for expected symbols from the file + symbol_names = set() + for s_info in overview: + if isinstance(s_info, tuple) and len(s_info) > 0: + symbol_names.add(s_info[0]) + elif hasattr(s_info, "get"): + symbol_names.add(s_info.get("name")) + elif isinstance(s_info, str): + symbol_names.add(s_info) + + # We should find some of our defined classes/methods + expected_symbols = {"UserStats", "UserManager", "process_user_data", "main"} + found_symbols = symbol_names.intersection(expected_symbols) + assert len(found_symbols) > 0, f"Expected to find some symbols from {expected_symbols}, found: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_request_containing_symbol_variable(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol where the target is a variable.""" + # Test for a position inside a variable definition or usage + file_path = os.path.join("variables.rb") + # Position around a variable assignment (e.g., @status = "pending") + containing_symbol = language_server.request_containing_symbol(file_path, 10, 5) + + # Verify that we found a containing symbol (likely the method or class) + if containing_symbol is not None: + assert "name" in containing_symbol, "Containing symbol should have a name" + assert "kind" in containing_symbol, "Containing symbol should have a kind" + # The containing symbol should be a method, class, or similar construct + expected_kinds = [SymbolKind.Method, SymbolKind.Class, SymbolKind.Function, SymbolKind.Constructor] + assert containing_symbol["kind"] in [k.value for k in expected_kinds], ( + f"Expected containing symbol to be method/class/function, got kind: {containing_symbol['kind']}" + ) + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_request_containing_symbol_function(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol for a function (not method).""" + # Test for a position inside a standalone function + file_path = os.path.join("variables.rb") + # Position inside the demonstrate_variable_usage function + containing_symbol = language_server.request_containing_symbol(file_path, 100, 10) + + if containing_symbol is not None: + assert containing_symbol["name"] in [ + "demonstrate_variable_usage", + "main", + ], f"Expected function name, got: {containing_symbol['name']}" + assert containing_symbol["kind"] in [ + SymbolKind.Function.value, + SymbolKind.Method.value, + ], f"Expected function or method kind, got: {containing_symbol['kind']}" + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_request_containing_symbol_nested(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol with nested scopes.""" + # Test for a position inside a method which is inside a class + file_path = os.path.join("services.rb") + # Position inside create_user method within UserService class + containing_symbol = language_server.request_containing_symbol(file_path, 12, 15) + + # Verify that we found the innermost containing symbol (the method) + assert containing_symbol is not None + assert containing_symbol["name"] == "create_user" + assert containing_symbol["kind"] == SymbolKind.Method + + # Verify the container context is preserved + if "containerName" in containing_symbol: + assert "UserService" in containing_symbol["containerName"] + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_symbol_tree_structure_subdir(self, language_server: SolidLanguageServer) -> None: + """Test that the symbol tree structure correctly handles subdirectories.""" + # Get symbols within the examples subdirectory + examples_structure = language_server.request_full_symbol_tree(within_relative_path="examples") + + if len(examples_structure) > 0: + # Should find the examples directory structure + assert len(examples_structure) >= 1, "Should find examples directory structure" + + # Look for the user_management file in the structure + found_user_management = False + for root in examples_structure: + if "children" in root: + for child in root["children"]: + if "user_management" in child.get("name", ""): + found_user_management = True + # Verify the structure includes symbol information + if "children" in child: + child_names = [c.get("name") for c in child["children"]] + expected_names = ["UserStats", "UserManager", "process_user_data"] + found_expected = [name for name in expected_names if name in child_names] + assert len(found_expected) > 0, ( + f"Should find symbols in user_management, expected {expected_names}, found {child_names}" + ) + break + + if not found_user_management: + pytest.skip("user_management file not found in examples subdirectory structure") + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_request_defining_symbol_imported_class(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for an imported/required class.""" + # Test finding the definition of a class used from another file + file_path = os.path.join("examples", "user_management.rb") + # Position where Services::UserService is referenced + defining_symbol = language_server.request_defining_symbol(file_path, 25, 20) + + # This might not work perfectly in all Ruby language servers due to require complexity + if defining_symbol is not None: + assert "name" in defining_symbol + # The defining symbol should relate to UserService or Services + # The defining symbol should relate to UserService, Services, or the containing class + # Different language servers may resolve this differently + expected_names = ["UserService", "Services", "new", "UserManager"] + assert defining_symbol.get("name") in expected_names, f"Expected one of {expected_names}, got: {defining_symbol.get('name')}" + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_request_defining_symbol_method_call(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for a method call.""" + # Test finding the definition of a method being called + file_path = os.path.join("examples", "user_management.rb") + + # Position at a method call like create_user + with language_server.open_file(file_path, open_in_ls=False) as fb: + pos = find_text_coordinates(fb.contents, r"user = @service\.(create_user)") + + # Verify that we can find the method definition + defining_symbol = language_server.request_defining_symbol(file_path, pos.line, pos.col) + assert "name" in defining_symbol + assert "kind" in defining_symbol + assert defining_symbol.get("name") == "create_user" + assert defining_symbol.get("kind") == SymbolKind.Method.value + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_request_defining_symbol_nested_function(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for a nested function or block.""" + # Test finding definition within nested contexts + file_path = os.path.join("nested.rb") + # Position inside or referencing nested functionality + defining_symbol = language_server.request_defining_symbol(file_path, 15, 10) + + # This is challenging for many language servers + if defining_symbol is not None: + assert "name" in defining_symbol + assert "kind" in defining_symbol + # Could be method, function, or variable depending on implementation + valid_kinds = [SymbolKind.Method.value, SymbolKind.Function.value, SymbolKind.Variable.value, SymbolKind.Class.value] + assert defining_symbol.get("kind") in valid_kinds + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_containing_symbol_of_var_is_file(self, language_server: SolidLanguageServer) -> None: + """Test that the containing symbol of a file-level variable is handled appropriately.""" + # Test behavior with file-level variables or constants + file_path = os.path.join("variables.rb") + # Position at file-level variable/constant + containing_symbol = language_server.request_containing_symbol(file_path, 5, 5) + + # Different language servers handle file-level symbols differently + # Some return None, others return file-level containers + if containing_symbol is not None: + # If we get a symbol, verify its structure + assert "name" in containing_symbol + assert "kind" in containing_symbol diff --git a/test/solidlsp/rust/test_rust_2024_edition.py b/test/solidlsp/rust/test_rust_2024_edition.py new file mode 100644 index 0000000..77eb084 --- /dev/null +++ b/test/solidlsp/rust/test_rust_2024_edition.py @@ -0,0 +1,111 @@ +import os +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.conftest import start_ls_context + + +@pytest.fixture(scope="class") +def rust_language_server() -> Iterator[SolidLanguageServer]: + """Set up the test class with the Rust 2024 edition test repository.""" + test_repo_2024_path = TestRust2024EditionLanguageServer.test_repo_2024_path + + if not test_repo_2024_path.exists(): + pytest.skip("Rust 2024 edition test repository not found") + + # Create and start the language server for the 2024 edition repo + with start_ls_context(Language.RUST, str(test_repo_2024_path)) as ls: + yield ls + + +@pytest.mark.rust +class TestRust2024EditionLanguageServer: + test_repo_2024_path = Path(__file__).parent.parent.parent / "resources" / "repos" / "rust" / "test_repo_2024" + + def test_find_references_raw(self, rust_language_server) -> None: + # Test finding references to the 'add' function defined in main.rs + file_path = os.path.join("src", "main.rs") + symbols = rust_language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + add_symbol = None + for sym in symbols[0]: + if sym.get("name") == "add": + add_symbol = sym + break + assert add_symbol is not None, "Could not find 'add' function symbol in main.rs" + sel_start = add_symbol["selectionRange"]["start"] + refs = rust_language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + # The add function should be referenced within main.rs itself (in the main function) + assert any("main.rs" in ref.get("relativePath", "") for ref in refs), "main.rs should reference add function" + + def test_find_symbol(self, rust_language_server) -> None: + symbols = rust_language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main function not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add function not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "multiply"), "multiply function not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Calculator"), "Calculator struct not found in symbol tree" + + def test_find_referencing_symbols_multiply(self, rust_language_server) -> None: + # Find references to 'multiply' function defined in lib.rs + file_path = os.path.join("src", "lib.rs") + symbols = rust_language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + multiply_symbol = None + for sym in symbols[0]: + if sym.get("name") == "multiply": + multiply_symbol = sym + break + assert multiply_symbol is not None, "Could not find 'multiply' function symbol in lib.rs" + sel_start = multiply_symbol["selectionRange"]["start"] + refs = rust_language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + # The multiply function exists but may not be referenced anywhere, which is fine + # This test just verifies we can find the symbol and request references without error + assert isinstance(refs, list), "Should return a list of references (even if empty)" + + def test_find_calculator_struct_and_impl(self, rust_language_server) -> None: + # Test finding the Calculator struct and its impl block + file_path = os.path.join("src", "lib.rs") + symbols = rust_language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + # Find the Calculator struct + calculator_struct = None + calculator_impl = None + for sym in symbols[0]: + if sym.get("name") == "Calculator" and sym.get("kind") == 23: # Struct kind + calculator_struct = sym + elif sym.get("name") == "Calculator" and sym.get("kind") == 11: # Interface/Impl kind + calculator_impl = sym + + assert calculator_struct is not None, "Could not find 'Calculator' struct symbol in lib.rs" + + # The struct should have the 'result' field + struct_children = calculator_struct.get("children", []) + field_names = [child.get("name") for child in struct_children] + assert "result" in field_names, "Calculator struct should have 'result' field" + + # Find the impl block and check its methods + if calculator_impl is not None: + impl_children = calculator_impl.get("children", []) + method_names = [child.get("name") for child in impl_children] + assert "new" in method_names, "Calculator impl should have 'new' method" + assert "add" in method_names, "Calculator impl should have 'add' method" + assert "get_result" in method_names, "Calculator impl should have 'get_result' method" + + def test_overview_methods(self, rust_language_server) -> None: + symbols = rust_language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main missing from overview" + assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add missing from overview" + assert SymbolUtils.symbol_tree_contains_name(symbols, "multiply"), "multiply missing from overview" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Calculator"), "Calculator missing from overview" + + def test_rust_2024_edition_specific(self) -> None: + # Verify we're actually working with the 2024 edition repository + cargo_toml_path = self.test_repo_2024_path / "Cargo.toml" + assert cargo_toml_path.exists(), "Cargo.toml should exist in test repository" + + with open(cargo_toml_path) as f: + content = f.read() + assert 'edition = "2024"' in content, "Should be using Rust 2024 edition" diff --git a/test/solidlsp/rust/test_rust_analyzer_detection.py b/test/solidlsp/rust/test_rust_analyzer_detection.py new file mode 100644 index 0000000..fea2bb0 --- /dev/null +++ b/test/solidlsp/rust/test_rust_analyzer_detection.py @@ -0,0 +1,555 @@ +""" +Tests for rust-analyzer detection logic. + +These tests describe the expected behavior of RustAnalyzer._ensure_rust_analyzer_installed(): + +1. Rustup should be checked FIRST (avoids picking up incorrect PATH aliases) +2. Common installation locations (Homebrew, cargo, Scoop) should be checked as fallback +3. System PATH should be checked last (can pick up incompatible versions) +4. Error messages should list all searched locations +5. Windows-specific paths should be checked on Windows + +WHY these tests matter: +- Users install rust-analyzer via Homebrew, cargo, Scoop, or system packages - not just rustup +- macOS Homebrew installs to /opt/homebrew/bin (Apple Silicon) or /usr/local/bin (Intel) +- Windows users install via Scoop, Chocolatey, or cargo +- Detection failing means Serena is unusable for Rust, even when rust-analyzer is correctly installed +- Without these tests, the detection logic can silently break for non-rustup users +""" + +import os +import pathlib +import sys +from unittest.mock import MagicMock, patch + +import pytest + +# Platform detection for skipping platform-specific tests +IS_WINDOWS = sys.platform == "win32" +IS_UNIX = sys.platform != "win32" + + +class TestRustAnalyzerDetection: + """Unit tests for rust-analyzer binary detection logic.""" + + @pytest.mark.rust + def test_detect_from_path_when_functional(self): + """ + GIVEN rustup is not available + AND rust-analyzer IS in system PATH and is functional + WHEN _ensure_rust_analyzer_installed is called + THEN it should return the path from shutil.which + + WHY: PATH is checked after rustup but before common locations. + This covers standalone installs via Nix, distro packages, etc. + The binary is verified to be functional to avoid broken rustup proxy symlinks. + Fixes: https://github.com/oraios/serena/issues/800 + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + # Mock rustup to be unavailable + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value=None): + # Mock PATH to have rust-analyzer + with patch("shutil.which") as mock_which: + mock_which.return_value = "/custom/bin/rust-analyzer" + with patch("os.path.isfile", return_value=True): + with patch("os.access", return_value=True): + with patch.object(RustAnalyzer.DependencyProvider, "_is_rust_analyzer_functional", return_value=True): + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() + + assert result == "/custom/bin/rust-analyzer" + mock_which.assert_called_with("rust-analyzer") + + @pytest.mark.rust + def test_detect_from_nix_path_without_rustup(self): + """ + GIVEN rustup is NOT installed + AND rust-analyzer is installed via Nix and available in PATH + WHEN _ensure_rust_analyzer_installed is called + THEN it should find and use the Nix-provided rust-analyzer + + WHY: NixOS users install rust-analyzer via home-manager or nix-env, + which places it in ~/.nix-profile/bin/ on PATH. Without rustup, + Serena should still detect and use this binary. + Fixes: https://github.com/oraios/serena/issues/800 + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + nix_path = "/home/user/.nix-profile/bin/rust-analyzer" + + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value=None): + with patch("shutil.which", return_value=nix_path): + with patch("os.path.isfile", return_value=True): + with patch("os.access", return_value=True): + with patch.object(RustAnalyzer.DependencyProvider, "_is_rust_analyzer_functional", return_value=True): + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() + + assert result == nix_path + + @pytest.mark.rust + def test_skip_broken_rustup_proxy_in_path(self): + """ + GIVEN rustup is NOT available via 'rustup which' + AND rust-analyzer in PATH is a broken rustup proxy symlink + WHEN _ensure_rust_analyzer_installed is called + THEN it should skip the broken proxy and fall through to common paths + + WHY: On some systems, rust-analyzer in PATH is a symlink to rustup. + If rustup doesn't have the component installed, running the binary fails. + We verify functionality with --version before accepting a PATH result. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + # Use platform-appropriate paths and binary names + home = pathlib.Path.home() + if IS_WINDOWS: + binary_name = "rust-analyzer.exe" + broken_proxy_path = str(home / "AppData" / "Local" / "Microsoft" / "WindowsApps" / binary_name) + cargo_path = str(home / ".cargo" / "bin" / binary_name) + else: + binary_name = "rust-analyzer" + broken_proxy_path = "/usr/bin/rust-analyzer" + cargo_path = os.path.expanduser("~/.cargo/bin/rust-analyzer") + + def mock_isfile(path): + return path in [broken_proxy_path, cargo_path] + + def mock_access(path, mode): + return path in [broken_proxy_path, cargo_path] + + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value=None): + with patch("shutil.which", return_value=broken_proxy_path): + with patch("os.path.isfile", side_effect=mock_isfile): + with patch("os.access", side_effect=mock_access): + # The PATH binary is a broken rustup proxy + with patch.object(RustAnalyzer.DependencyProvider, "_is_rust_analyzer_functional", return_value=False): + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() + + # Should skip broken proxy and find cargo-installed version + assert result == cargo_path + + @pytest.mark.rust + @pytest.mark.skipif(IS_WINDOWS, reason="Homebrew paths only apply to macOS/Linux") + def test_detect_from_homebrew_apple_silicon_path(self): + """ + GIVEN rustup is NOT available + AND rust-analyzer is installed via Homebrew on Apple Silicon Mac + AND it is NOT in PATH (shutil.which returns None) + WHEN _ensure_rust_analyzer_installed is called + THEN it should find /opt/homebrew/bin/rust-analyzer + + WHY: Apple Silicon Macs use /opt/homebrew/bin for Homebrew. + This path should be checked as fallback when rustup is unavailable. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + def mock_isfile(path): + return path == "/opt/homebrew/bin/rust-analyzer" + + def mock_access(path, mode): + return path == "/opt/homebrew/bin/rust-analyzer" + + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", side_effect=mock_isfile): + with patch("os.access", side_effect=mock_access): + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() + + assert result == "/opt/homebrew/bin/rust-analyzer" + + @pytest.mark.rust + @pytest.mark.skipif(IS_WINDOWS, reason="Homebrew paths only apply to macOS/Linux") + def test_detect_from_homebrew_intel_path(self): + """ + GIVEN rustup is NOT available + AND rust-analyzer is installed via Homebrew on Intel Mac + AND it is NOT in PATH + WHEN _ensure_rust_analyzer_installed is called + THEN it should find /usr/local/bin/rust-analyzer + + WHY: Intel Macs use /usr/local/bin for Homebrew. + Linux systems may also install to this location. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + def mock_isfile(path): + return path == "/usr/local/bin/rust-analyzer" + + def mock_access(path, mode): + return path == "/usr/local/bin/rust-analyzer" + + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", side_effect=mock_isfile): + with patch("os.access", side_effect=mock_access): + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() + + assert result == "/usr/local/bin/rust-analyzer" + + @pytest.mark.rust + @pytest.mark.skipif(IS_WINDOWS, reason="Unix cargo path - Windows has separate test") + def test_detect_from_cargo_install_path(self): + """ + GIVEN rustup is NOT available + AND rust-analyzer is installed via `cargo install rust-analyzer` + AND it is NOT in PATH or Homebrew locations + WHEN _ensure_rust_analyzer_installed is called + THEN it should find ~/.cargo/bin/rust-analyzer + + WHY: `cargo install rust-analyzer` is a common installation method. + The binary lands in ~/.cargo/bin which may not be in PATH. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + cargo_path = os.path.expanduser("~/.cargo/bin/rust-analyzer") + + def mock_isfile(path): + return path == cargo_path + + def mock_access(path, mode): + return path == cargo_path + + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", side_effect=mock_isfile): + with patch("os.access", side_effect=mock_access): + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() + + assert result == cargo_path + + @pytest.mark.rust + def test_detect_from_rustup_when_available(self): + """ + GIVEN rustup has rust-analyzer installed + WHEN _ensure_rust_analyzer_installed is called + THEN it should return the rustup path + + WHY: Rustup is checked FIRST to avoid picking up incorrect aliases from PATH. + This ensures compatibility with the toolchain. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", return_value=False): + with patch.object( + RustAnalyzer.DependencyProvider, + "_get_rust_analyzer_via_rustup", + return_value="/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/rust-analyzer", + ): + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() + + assert "rustup" in result or ".rustup" in result + + @pytest.mark.rust + @pytest.mark.skipif(IS_WINDOWS, reason="Unix error messages - Windows has separate test") + def test_error_message_lists_searched_locations_when_not_found(self): + """ + GIVEN rust-analyzer is NOT installed anywhere + AND rustup is NOT installed + WHEN _ensure_rust_analyzer_installed is called + THEN it should raise RuntimeError with helpful message listing searched locations + + WHY: Users need to know WHERE Serena looked so they can fix their installation. + The old error "Neither rust-analyzer nor rustup is installed" was unhelpful. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", return_value=False): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value=None): + with pytest.raises(RuntimeError) as exc_info: + RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() + + error_message = str(exc_info.value) + # Error should list the locations that were searched (Unix paths) + assert "/opt/homebrew/bin/rust-analyzer" in error_message or "Homebrew" in error_message + assert "cargo" in error_message.lower() or ".cargo/bin" in error_message + # Error should suggest installation methods + assert "rustup" in error_message.lower() or "Rustup" in error_message + + @pytest.mark.rust + def test_detection_priority_prefers_rustup_over_path_and_common_locations(self): + """ + GIVEN rust-analyzer is available via rustup + AND rust-analyzer also exists in PATH and common locations + WHEN _ensure_rust_analyzer_installed is called + THEN it should return the rustup version + + WHY: Rustup provides version management and ensures compatibility. + Using PATH directly can pick up incorrect aliases or incompatible versions + that cause LSP crashes (as discovered in CI failures). + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + rustup_path = "/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/rust-analyzer" + + # Rustup has rust-analyzer, PATH also has it, common locations also exist + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=rustup_path): + with patch("shutil.which", return_value="/custom/path/rust-analyzer"): + with patch("os.path.isfile", return_value=True): + with patch("os.access", return_value=True): + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() + + # Should use rustup version, NOT PATH or common locations + assert result == rustup_path + + @pytest.mark.rust + @pytest.mark.skipif(IS_WINDOWS, reason="Uses Unix paths - Windows has different behavior") + def test_skips_nonexecutable_files(self): + """ + GIVEN a file exists at a detection path but is NOT executable + WHEN _ensure_rust_analyzer_installed is called + THEN it should skip that path and continue checking others + + WHY: A non-executable file (e.g., broken symlink, wrong permissions) + should not be returned as a valid rust-analyzer path. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + def mock_isfile(path): + # File exists at Homebrew location but not executable + return path == "/opt/homebrew/bin/rust-analyzer" + + def mock_access(path, mode): + # Homebrew location exists but not executable + if path == "/opt/homebrew/bin/rust-analyzer": + return False + # Cargo location is executable + if path == os.path.expanduser("~/.cargo/bin/rust-analyzer"): + return True + return False + + def mock_isfile_for_cargo(path): + return path in ["/opt/homebrew/bin/rust-analyzer", os.path.expanduser("~/.cargo/bin/rust-analyzer")] + + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", side_effect=mock_isfile_for_cargo): + with patch("os.access", side_effect=mock_access): + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() + + # Should skip non-executable Homebrew and use cargo + assert result == os.path.expanduser("~/.cargo/bin/rust-analyzer") + + @pytest.mark.rust + def test_detect_from_scoop_shims_path_on_windows(self): + """ + GIVEN rustup is NOT available + AND rust-analyzer is installed via Scoop on Windows + AND it is NOT in PATH + WHEN _ensure_rust_analyzer_installed is called + THEN it should find ~/scoop/shims/rust-analyzer.exe + + WHY: Scoop is a popular package manager for Windows. + The binary lands in ~/scoop/shims which may not be in PATH. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + home = pathlib.Path.home() + scoop_path = str(home / "scoop" / "shims" / "rust-analyzer.exe") + + def mock_isfile(path): + return path == scoop_path + + def mock_access(path, mode): + return path == scoop_path + + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): + with patch("platform.system", return_value="Windows"): + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", side_effect=mock_isfile): + with patch("os.access", side_effect=mock_access): + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() + + assert result == scoop_path + + @pytest.mark.rust + def test_detect_from_cargo_path_on_windows(self): + """ + GIVEN rustup is NOT available + AND rust-analyzer is installed via cargo on Windows + AND it is NOT in PATH or Scoop locations + WHEN _ensure_rust_analyzer_installed is called + THEN it should find ~/.cargo/bin/rust-analyzer.exe + + WHY: `cargo install rust-analyzer` works on Windows. + The binary has .exe extension and lands in ~/.cargo/bin. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + home = pathlib.Path.home() + cargo_path = str(home / ".cargo" / "bin" / "rust-analyzer.exe") + + def mock_isfile(path): + return path == cargo_path + + def mock_access(path, mode): + return path == cargo_path + + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): + with patch("platform.system", return_value="Windows"): + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", side_effect=mock_isfile): + with patch("os.access", side_effect=mock_access): + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() + + assert result == cargo_path + + @pytest.mark.rust + def test_windows_error_message_suggests_windows_package_managers(self): + """ + GIVEN rust-analyzer is NOT installed anywhere on Windows + AND rustup is NOT installed + WHEN _ensure_rust_analyzer_installed is called + THEN it should raise RuntimeError with Windows-specific installation suggestions + + WHY: Windows users need Windows-specific package manager suggestions + (Scoop, Chocolatey) instead of Homebrew/apt. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + with patch("platform.system", return_value="Windows"): + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", return_value=False): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value=None): + with pytest.raises(RuntimeError) as exc_info: + RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() + + error_message = str(exc_info.value) + # Error should suggest Windows-specific package managers + assert "Scoop" in error_message or "scoop" in error_message + assert "Chocolatey" in error_message or "choco" in error_message + # Should NOT suggest Homebrew on Windows + assert "Homebrew" not in error_message and "brew" not in error_message + + @pytest.mark.rust + def test_auto_install_via_rustup_when_not_found(self): + """ + GIVEN rust-analyzer is NOT installed anywhere + AND rustup IS installed + WHEN _ensure_rust_analyzer_installed is called + AND rustup component add succeeds + THEN it should return the rustup-installed path + + WHY: Serena should auto-install rust-analyzer via rustup when possible. + This matches the original behavior and enables CI to work without pre-installing. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", return_value=False): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup") as mock_rustup_path: + # First call returns None (not installed), second returns path (after install) + mock_rustup_path.side_effect = [None, "/home/user/.rustup/toolchains/stable/bin/rust-analyzer"] + with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value="1.70.0"): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() + + assert result == "/home/user/.rustup/toolchains/stable/bin/rust-analyzer" + mock_run.assert_called_once() + assert mock_run.call_args[0][0] == ["rustup", "component", "add", "rust-analyzer"] + + @pytest.mark.rust + def test_auto_install_failure_falls_through_to_common_paths(self): + """ + GIVEN rust-analyzer is NOT installed anywhere + AND rustup IS installed + WHEN _ensure_rust_analyzer_installed is called + AND rustup component add FAILS + THEN it should fall through to common paths and eventually raise helpful error + + WHY: The new resilient behavior tries all fallback options before failing. + When rustup auto-install fails, we try common paths (Homebrew, cargo, etc.) + as a last resort. This is more robust than failing immediately. + The error message should still help users install rust-analyzer. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", return_value=False): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value="1.70.0"): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=1, stdout="", stderr="error: component 'rust-analyzer' is not available" + ) + with pytest.raises(RuntimeError) as exc_info: + RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() + + error_message = str(exc_info.value) + # Error should provide helpful installation instructions + assert "rust-analyzer is not installed" in error_message.lower() + assert "rustup" in error_message.lower() # Should suggest rustup installation + + @pytest.mark.rust + def test_auto_install_success_but_binary_not_found_falls_through(self): + """ + GIVEN rust-analyzer is NOT installed anywhere + AND rustup IS installed + WHEN _ensure_rust_analyzer_installed is called + AND rustup component add SUCCEEDS + BUT the binary is still not found after installation + THEN it should fall through to common paths and eventually raise helpful error + + WHY: Even if rustup install reports success but binary isn't found, + we try common paths as fallback. The final error provides installation + guidance to help users resolve the issue. + """ + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + with patch("shutil.which", return_value=None): + with patch("os.path.isfile", return_value=False): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value="1.70.0"): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + with pytest.raises(RuntimeError) as exc_info: + RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() + + error_message = str(exc_info.value) + # Error should indicate rust-analyzer is not available and provide install instructions + assert "rust-analyzer is not installed" in error_message.lower() + assert "searched locations" in error_message.lower() # Should show what was checked + + +class TestRustAnalyzerDetectionIntegration: + """ + Integration tests that verify detection works on the current system. + These tests are skipped if rust-analyzer is not installed. + """ + + @pytest.mark.rust + def test_detection_finds_installed_rust_analyzer(self): + """ + GIVEN rust-analyzer is installed on this system (via any method) + WHEN _ensure_rust_analyzer_installed is called + THEN it should return a valid path + + This test verifies the detection logic works end-to-end on the current system. + """ + import shutil + + from solidlsp.language_servers.rust_analyzer import RustAnalyzer + + # Skip if rust-analyzer is not installed at all + if not shutil.which("rust-analyzer"): + # Check common locations + common_paths = [ + "/opt/homebrew/bin/rust-analyzer", + "/usr/local/bin/rust-analyzer", + os.path.expanduser("~/.cargo/bin/rust-analyzer"), + ] + if not any(os.path.isfile(p) and os.access(p, os.X_OK) for p in common_paths): + pytest.skip("rust-analyzer not installed on this system") + + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() + + assert result is not None + assert os.path.isfile(result) + assert os.access(result, os.X_OK) diff --git a/test/solidlsp/rust/test_rust_basic.py b/test/solidlsp/rust/test_rust_basic.py new file mode 100644 index 0000000..4c7ec2c --- /dev/null +++ b/test/solidlsp/rust/test_rust_basic.py @@ -0,0 +1,99 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.rust +class TestRustLanguageServer: + @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) + def test_find_references_raw(self, language_server: SolidLanguageServer) -> None: + # Directly test the request_references method for the add function + file_path = os.path.join("src", "lib.rs") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + add_symbol = None + for sym in symbols[0]: + if sym.get("name") == "add": + add_symbol = sym + break + assert add_symbol is not None, "Could not find 'add' function symbol in lib.rs" + sel_start = add_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert any("main.rs" in ref.get("relativePath", "") for ref in refs), ( + "main.rs should reference add (raw, tried all positions in selectionRange)" + ) + + @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main function not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add function not found in symbol tree" + # Add more as needed based on test_repo + + @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) + def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: + # Find references to 'add' defined in lib.rs, should be referenced from main.rs + file_path = os.path.join("src", "lib.rs") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + add_symbol = None + for sym in symbols[0]: + if sym.get("name") == "add": + add_symbol = sym + break + assert add_symbol is not None, "Could not find 'add' function symbol in lib.rs" + sel_start = add_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert any("main.rs" in ref.get("relativePath", "") for ref in refs), ( + "main.rs should reference add (tried all positions in selectionRange)" + ) + + @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) + def test_overview_methods(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main missing from overview" + assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add missing from overview" + + if language_has_verified_implementation_support(Language.RUST): + + @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.RUST) + pos = find_identifier_position(repo_path / os.path.join("src", "lib.rs"), "format_greeting") + assert pos is not None, "Could not find Greeter.format_greeting in fixture" + + implementations = language_server.request_implementation(os.path.join("src", "lib.rs"), *pos) + assert implementations, "Expected at least one implementation of Greeter.format_greeting" + assert any("src/lib.rs" in implementation.get("relativePath", "").replace("\\", "/") for implementation in implementations), ( + f"Expected ConsoleGreeter.format_greeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.RUST) + pos = find_identifier_position(repo_path / os.path.join("src", "lib.rs"), "format_greeting") + assert pos is not None, "Could not find Greeter.format_greeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols(os.path.join("src", "lib.rs"), *pos) + assert implementing_symbols, "Expected implementing symbols for Greeter.format_greeting" + assert any( + symbol.get("name") == "format_greeting" and "src/lib.rs" in symbol["location"].get("relativePath", "").replace("\\", "/") + for symbol in implementing_symbols + ), f"Expected ConsoleGreeter.format_greeting symbol, got: {implementing_symbols}" + + @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s, whitespace_allowed=True): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/rust/test_rust_diagnostics.py b/test/solidlsp/rust/test_rust_diagnostics.py new file mode 100644 index 0000000..b08f7cf --- /dev/null +++ b/test/solidlsp/rust/test_rust_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.rust +class TestRustDiagnostics: + @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/diagnostics_sample.rs", + (), + min_count=1, + ) diff --git a/test/solidlsp/scala/test_metals_db_utils.py b/test/solidlsp/scala/test_metals_db_utils.py new file mode 100644 index 0000000..5a31af2 --- /dev/null +++ b/test/solidlsp/scala/test_metals_db_utils.py @@ -0,0 +1,268 @@ +""" +Unit tests for the metals_db_utils module. + +Tests the detection of Metals H2 database status and stale lock handling. +""" + +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from solidlsp.util.metals_db_utils import ( + MetalsDbStatus, + check_metals_db_status, + cleanup_stale_lock, + is_metals_process_alive, + parse_h2_lock_file, +) + + +@pytest.mark.scala +class TestParseH2LockFile: + """Tests for parse_h2_lock_file function.""" + + def test_returns_none_when_file_does_not_exist(self, tmp_path: Path) -> None: + """Should return None when lock file doesn't exist.""" + lock_path = tmp_path / "nonexistent.lock.db" + result = parse_h2_lock_file(lock_path) + assert result is None + + def test_parses_server_format_lock_file(self, tmp_path: Path) -> None: + """Should parse lock file with server:host:port format.""" + lock_path = tmp_path / "metals.mv.db.lock.db" + lock_path.write_text("server:localhost:9092\n") + + result = parse_h2_lock_file(lock_path) + + assert result is not None + assert result.port == 9092 + assert result.lock_path == lock_path + + def test_parses_port_only_format(self, tmp_path: Path) -> None: + """Should extract port from content containing a port number.""" + lock_path = tmp_path / "metals.mv.db.lock.db" + lock_path.write_text("some content 9123 more content\n") + + result = parse_h2_lock_file(lock_path) + + assert result is not None + assert result.port == 9123 + + def test_parses_pid_format(self, tmp_path: Path) -> None: + """Should extract PID from lock file content.""" + lock_path = tmp_path / "metals.mv.db.lock.db" + lock_path.write_text("pid=12345\nserver:localhost:9092\n") + + result = parse_h2_lock_file(lock_path) + + assert result is not None + assert result.pid == 12345 + assert result.port == 9092 + + def test_handles_unreadable_file(self, tmp_path: Path) -> None: + """Should return None for unreadable files.""" + lock_path = tmp_path / "metals.mv.db.lock.db" + lock_path.write_text("content") + # Make file unreadable (Unix only) + if os.name != "nt": + lock_path.chmod(0o000) + try: + result = parse_h2_lock_file(lock_path) + assert result is None + finally: + lock_path.chmod(0o644) + + def test_truncates_raw_content(self, tmp_path: Path) -> None: + """Should truncate raw_content to 200 chars.""" + lock_path = tmp_path / "metals.mv.db.lock.db" + long_content = "x" * 500 + lock_path.write_text(long_content) + + result = parse_h2_lock_file(lock_path) + + assert result is not None + assert len(result.raw_content) == 200 + + +@pytest.mark.scala +class TestIsMetalsProcessAlive: + """Tests for is_metals_process_alive function.""" + + def test_returns_false_for_nonexistent_process(self) -> None: + """Should return False for a PID that doesn't exist.""" + # Use a very high PID that's unlikely to exist + result = is_metals_process_alive(999999999) + assert result is False + + def test_returns_true_for_metals_process(self) -> None: + """Should return True for a running Metals process.""" + import psutil + + with patch.object(psutil, "Process") as mock_process_class: + mock_proc = MagicMock() + mock_proc.is_running.return_value = True + mock_proc.cmdline.return_value = [ + "java", + "-Dmetals.client=vscode", + "-jar", + "metals.jar", + ] + mock_process_class.return_value = mock_proc + + result = is_metals_process_alive(12345) + + assert result is True + + def test_returns_false_for_non_metals_java_process(self) -> None: + """Should return False for a Java process that isn't Metals.""" + import psutil + + with patch.object(psutil, "Process") as mock_process_class: + mock_proc = MagicMock() + mock_proc.is_running.return_value = True + mock_proc.cmdline.return_value = [ + "java", + "-jar", + "some-other-app.jar", + ] + mock_process_class.return_value = mock_proc + + result = is_metals_process_alive(12345) + + assert result is False + + def test_returns_false_for_non_running_process(self) -> None: + """Should return False for a process that's not running.""" + import psutil + + with patch.object(psutil, "Process") as mock_process_class: + mock_proc = MagicMock() + mock_proc.is_running.return_value = False + mock_process_class.return_value = mock_proc + + result = is_metals_process_alive(12345) + + assert result is False + + def test_handles_no_such_process(self) -> None: + """Should return False when process doesn't exist.""" + import psutil + + with patch.object(psutil, "Process") as mock_process_class: + mock_process_class.side_effect = psutil.NoSuchProcess(12345) + + result = is_metals_process_alive(12345) + + assert result is False + + +@pytest.mark.scala +class TestCheckMetalsDbStatus: + """Tests for check_metals_db_status function.""" + + def test_returns_no_database_when_metals_dir_missing(self, tmp_path: Path) -> None: + """Should return NO_DATABASE when .metals directory doesn't exist.""" + status, lock_info = check_metals_db_status(tmp_path) + + assert status == MetalsDbStatus.NO_DATABASE + assert lock_info is None + + def test_returns_no_database_when_db_missing(self, tmp_path: Path) -> None: + """Should return NO_DATABASE when database file doesn't exist.""" + metals_dir = tmp_path / ".metals" + metals_dir.mkdir() + + status, lock_info = check_metals_db_status(tmp_path) + + assert status == MetalsDbStatus.NO_DATABASE + assert lock_info is None + + def test_returns_no_lock_when_lock_file_missing(self, tmp_path: Path) -> None: + """Should return NO_LOCK when database exists but lock doesn't.""" + metals_dir = tmp_path / ".metals" + metals_dir.mkdir() + db_path = metals_dir / "metals.mv.db" + db_path.touch() + + status, lock_info = check_metals_db_status(tmp_path) + + assert status == MetalsDbStatus.NO_LOCK + assert lock_info is None + + def test_returns_active_instance_when_process_alive(self, tmp_path: Path) -> None: + """Should return ACTIVE_INSTANCE when lock holder is running.""" + import solidlsp.util.metals_db_utils as metals_utils + + metals_dir = tmp_path / ".metals" + metals_dir.mkdir() + db_path = metals_dir / "metals.mv.db" + db_path.touch() + lock_path = metals_dir / "metals.mv.db.lock.db" + lock_path.write_text("pid=12345\nserver:localhost:9092\n") + + with patch.object(metals_utils, "is_metals_process_alive", return_value=True): + status, lock_info = check_metals_db_status(tmp_path) + + assert status == MetalsDbStatus.ACTIVE_INSTANCE + assert lock_info is not None + assert lock_info.is_stale is False + + def test_returns_stale_lock_when_process_dead(self, tmp_path: Path) -> None: + """Should return STALE_LOCK when lock holder is not running.""" + import solidlsp.util.metals_db_utils as metals_utils + + metals_dir = tmp_path / ".metals" + metals_dir.mkdir() + db_path = metals_dir / "metals.mv.db" + db_path.touch() + lock_path = metals_dir / "metals.mv.db.lock.db" + lock_path.write_text("pid=12345\nserver:localhost:9092\n") + + with patch.object(metals_utils, "is_metals_process_alive", return_value=False): + status, lock_info = check_metals_db_status(tmp_path) + + assert status == MetalsDbStatus.STALE_LOCK + assert lock_info is not None + assert lock_info.is_stale is True + + +@pytest.mark.scala +class TestCleanupStaleLock: + """Tests for cleanup_stale_lock function.""" + + def test_removes_lock_file(self, tmp_path: Path) -> None: + """Should successfully remove a lock file.""" + lock_path = tmp_path / "metals.mv.db.lock.db" + lock_path.touch() + + result = cleanup_stale_lock(lock_path) + + assert result is True + assert not lock_path.exists() + + def test_returns_true_when_file_already_removed(self, tmp_path: Path) -> None: + """Should return True when file doesn't exist.""" + lock_path = tmp_path / "nonexistent.lock.db" + + result = cleanup_stale_lock(lock_path) + + assert result is True + + def test_returns_false_on_permission_error(self, tmp_path: Path) -> None: + """Should return False when file can't be removed due to permissions.""" + if os.name == "nt": + pytest.skip("Permission test not reliable on Windows") + + lock_path = tmp_path / "metals.mv.db.lock.db" + lock_path.touch() + # Make parent directory read-only + tmp_path.chmod(0o555) + + try: + result = cleanup_stale_lock(lock_path) + assert result is False + assert lock_path.exists() + finally: + tmp_path.chmod(0o755) diff --git a/test/solidlsp/scala/test_scala_language_server.py b/test/solidlsp/scala/test_scala_language_server.py new file mode 100644 index 0000000..aa94eb3 --- /dev/null +++ b/test/solidlsp/scala/test_scala_language_server.py @@ -0,0 +1,73 @@ +import os + +import pytest + +from solidlsp.language_servers.scala_language_server import ScalaLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.settings import SolidLSPSettings +from test.solidlsp.util.diagnostics import assert_file_diagnostics + +pytest.skip("Scala must be compiled for these tests to run through, which is a huge hassle", allow_module_level=True) + +MAIN_FILE_PATH = os.path.join("src", "main", "scala", "com", "example", "Main.scala") + +pytestmark = pytest.mark.scala + + +@pytest.fixture(scope="module") +def scala_ls(): + repo_root = os.path.abspath("test/resources/repos/scala") + config = LanguageServerConfig(code_language=Language.SCALA) + solidlsp_settings = SolidLSPSettings() + ls = ScalaLanguageServer(config, repo_root, solidlsp_settings) + + with ls.start_server_context(): + yield ls + + +def test_scala_document_symbols(scala_ls): + """Test document symbols for Main.scala""" + symbols, _ = scala_ls.request_document_symbols(MAIN_FILE_PATH).get_all_symbols_and_roots() + symbol_names = [s["name"] for s in symbols] + assert symbol_names[0] == "com.example" + assert symbol_names[1] == "Main" + assert symbol_names[2] == "main" + assert symbol_names[3] == "result" + assert symbol_names[4] == "sum" + assert symbol_names[5] == "add" + assert symbol_names[6] == "someMethod" + assert symbol_names[7] == "str" + assert symbol_names[8] == "Config" + assert symbol_names[9] == "field1" # confirm https://github.com/oraios/serena/issues/688 + + +def test_scala_references_within_same_file(scala_ls): + """Test finding references within the same file.""" + definitions = scala_ls.request_definition(MAIN_FILE_PATH, 12, 23) + first_def = definitions[0] + assert first_def["uri"].endswith("Main.scala") + assert first_def["range"]["start"]["line"] == 16 + assert first_def["range"]["start"]["character"] == 6 + assert first_def["range"]["end"]["line"] == 16 + assert first_def["range"]["end"]["character"] == 9 + + +def test_scala_find_definition_and_references_across_files(scala_ls): + definitions = scala_ls.request_definition(MAIN_FILE_PATH, 8, 25) + assert len(definitions) == 1 + + first_def = definitions[0] + assert first_def["uri"].endswith("Utils.scala") + assert first_def["range"]["start"]["line"] == 7 + assert first_def["range"]["start"]["character"] == 6 + assert first_def["range"]["end"]["line"] == 7 + assert first_def["range"]["end"]["character"] == 14 + + +def test_file_diagnostics(scala_ls) -> None: + assert_file_diagnostics( + scala_ls, + "src/main/scala/com/example/DiagnosticsSample.scala", + (), + min_count=1, + ) diff --git a/test/solidlsp/scala/test_scala_stale_lock_handling.py b/test/solidlsp/scala/test_scala_stale_lock_handling.py new file mode 100644 index 0000000..d2819c0 --- /dev/null +++ b/test/solidlsp/scala/test_scala_stale_lock_handling.py @@ -0,0 +1,261 @@ +""" +Tests for ScalaLanguageServer stale lock detection and handling modes. + +These tests verify the ScalaLanguageServer's behavior when detecting stale Metals locks. +They use mocking to avoid requiring an actual Scala project or Metals server. +""" + +import logging +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from _pytest.logging import LogCaptureFixture + +from solidlsp.language_servers.scala_language_server import ScalaLanguageServer +from solidlsp.ls_config import Language +from solidlsp.settings import SolidLSPSettings +from solidlsp.util.metals_db_utils import MetalsDbStatus, MetalsLockInfo + +pytestmark = pytest.mark.scala + + +class TestStaleLockHandling: + """Tests for ScalaLanguageServer stale lock detection and handling modes.""" + + @pytest.fixture + def sample_lock_info(self, tmp_path: Path) -> MetalsLockInfo: + """Create a sample MetalsLockInfo for testing.""" + lock_path = tmp_path / ".metals" / "metals.mv.db.lock.db" + return MetalsLockInfo( + pid=12345, + port=9092, + lock_path=lock_path, + is_stale=True, + raw_content="SERVER:localhost:9092:12345", + ) + + @pytest.fixture + def mock_setup_dependencies(self) -> Any: + """Mock _setup_runtime_dependencies to avoid needing Java/Coursier.""" + return patch.object( + ScalaLanguageServer, + "_setup_runtime_dependencies", + return_value=["/fake/metals"], + ) + + def test_auto_clean_mode_cleans_stale_lock( + self, + tmp_path: Path, + sample_lock_info: MetalsLockInfo, + mock_setup_dependencies: Any, + caplog: LogCaptureFixture, + ) -> None: + """Test AUTO_CLEAN mode removes stale lock and proceeds.""" + cleanup_mock = MagicMock(return_value=True) + + with ( + patch( + "solidlsp.util.metals_db_utils.check_metals_db_status", + return_value=(MetalsDbStatus.STALE_LOCK, sample_lock_info), + ), + patch( + "solidlsp.util.metals_db_utils.cleanup_stale_lock", + cleanup_mock, + ), + mock_setup_dependencies, + patch.object(ScalaLanguageServer, "__init__", lambda self, *args, **kwargs: None), + ): + # Create instance without calling __init__ + ls = object.__new__(ScalaLanguageServer) + settings = SolidLSPSettings(ls_specific_settings={Language.SCALA: {"on_stale_lock": "auto-clean"}}) + + # Call the method under test + ls._check_metals_db_status(str(tmp_path), settings) + + # Verify cleanup was called + cleanup_mock.assert_called_once_with(sample_lock_info.lock_path) + + def test_warn_mode_logs_warning_without_cleanup( + self, + tmp_path: Path, + sample_lock_info: MetalsLockInfo, + mock_setup_dependencies: Any, + caplog: LogCaptureFixture, + ) -> None: + """Test WARN mode logs warning but does not clean up.""" + cleanup_mock = MagicMock(return_value=True) + + with ( + patch( + "solidlsp.util.metals_db_utils.check_metals_db_status", + return_value=(MetalsDbStatus.STALE_LOCK, sample_lock_info), + ), + patch( + "solidlsp.util.metals_db_utils.cleanup_stale_lock", + cleanup_mock, + ), + mock_setup_dependencies, + caplog.at_level(logging.WARNING), + ): + ls = object.__new__(ScalaLanguageServer) + settings = SolidLSPSettings(ls_specific_settings={Language.SCALA: {"on_stale_lock": "warn"}}) + + ls._check_metals_db_status(str(tmp_path), settings) + + # Verify cleanup was NOT called + cleanup_mock.assert_not_called() + + # Verify warning was logged + assert any("Stale Metals lock detected" in record.message for record in caplog.records) + + def test_fail_mode_raises_exception( + self, + tmp_path: Path, + sample_lock_info: MetalsLockInfo, + mock_setup_dependencies: Any, + ) -> None: + """Test FAIL mode raises MetalsStaleLockError.""" + from solidlsp.ls_exceptions import MetalsStaleLockError + + with ( + patch( + "solidlsp.util.metals_db_utils.check_metals_db_status", + return_value=(MetalsDbStatus.STALE_LOCK, sample_lock_info), + ), + mock_setup_dependencies, + pytest.raises(MetalsStaleLockError) as exc_info, + ): + ls = object.__new__(ScalaLanguageServer) + settings = SolidLSPSettings(ls_specific_settings={Language.SCALA: {"on_stale_lock": "fail"}}) + + ls._check_metals_db_status(str(tmp_path), settings) + + assert str(sample_lock_info.lock_path) in str(exc_info.value) + + def test_active_instance_logs_info_when_enabled( + self, + tmp_path: Path, + mock_setup_dependencies: Any, + caplog: LogCaptureFixture, + ) -> None: + """Test ACTIVE_INSTANCE logs info message when log_multi_instance_notice is true.""" + active_lock_info = MetalsLockInfo( + pid=99999, + port=9092, + lock_path=tmp_path / ".metals" / "metals.mv.db.lock.db", + is_stale=False, + raw_content="SERVER:localhost:9092:99999", + ) + + with ( + patch( + "solidlsp.util.metals_db_utils.check_metals_db_status", + return_value=(MetalsDbStatus.ACTIVE_INSTANCE, active_lock_info), + ), + mock_setup_dependencies, + caplog.at_level(logging.INFO), + ): + ls = object.__new__(ScalaLanguageServer) + settings = SolidLSPSettings( + ls_specific_settings={ + Language.SCALA: { + "on_stale_lock": "auto-clean", + "log_multi_instance_notice": True, + } + } + ) + + ls._check_metals_db_status(str(tmp_path), settings) + + # Verify info about multi-instance was logged + assert any("Another Metals instance detected" in record.message for record in caplog.records) + + def test_active_instance_silent_when_notice_disabled( + self, + tmp_path: Path, + mock_setup_dependencies: Any, + caplog: LogCaptureFixture, + ) -> None: + """Test ACTIVE_INSTANCE does not log when log_multi_instance_notice is false.""" + active_lock_info = MetalsLockInfo( + pid=99999, + port=9092, + lock_path=tmp_path / ".metals" / "metals.mv.db.lock.db", + is_stale=False, + raw_content="SERVER:localhost:9092:99999", + ) + + with ( + patch( + "solidlsp.util.metals_db_utils.check_metals_db_status", + return_value=(MetalsDbStatus.ACTIVE_INSTANCE, active_lock_info), + ), + mock_setup_dependencies, + caplog.at_level(logging.INFO), + ): + ls = object.__new__(ScalaLanguageServer) + settings = SolidLSPSettings( + ls_specific_settings={ + Language.SCALA: { + "on_stale_lock": "auto-clean", + "log_multi_instance_notice": False, + } + } + ) + + ls._check_metals_db_status(str(tmp_path), settings) + + # Verify no multi-instance message was logged + assert not any("Another Metals instance detected" in record.message for record in caplog.records) + + def test_no_database_proceeds_silently( + self, + tmp_path: Path, + mock_setup_dependencies: Any, + caplog: LogCaptureFixture, + ) -> None: + """Test NO_DATABASE status proceeds without any special handling.""" + with ( + patch( + "solidlsp.util.metals_db_utils.check_metals_db_status", + return_value=(MetalsDbStatus.NO_DATABASE, None), + ), + mock_setup_dependencies, + caplog.at_level(logging.DEBUG), + ): + ls = object.__new__(ScalaLanguageServer) + settings = SolidLSPSettings(ls_specific_settings={Language.SCALA: {"on_stale_lock": "auto-clean"}}) + + # Should complete without error + ls._check_metals_db_status(str(tmp_path), settings) + + # No stale lock or multi-instance messages + assert not any("Stale" in record.message for record in caplog.records) + assert not any("Another Metals instance" in record.message for record in caplog.records) + + def test_no_lock_proceeds_silently( + self, + tmp_path: Path, + mock_setup_dependencies: Any, + caplog: LogCaptureFixture, + ) -> None: + """Test NO_LOCK status proceeds without any special handling.""" + with ( + patch( + "solidlsp.util.metals_db_utils.check_metals_db_status", + return_value=(MetalsDbStatus.NO_LOCK, None), + ), + mock_setup_dependencies, + caplog.at_level(logging.DEBUG), + ): + ls = object.__new__(ScalaLanguageServer) + settings = SolidLSPSettings(ls_specific_settings={Language.SCALA: {"on_stale_lock": "auto-clean"}}) + + # Should complete without error + ls._check_metals_db_status(str(tmp_path), settings) + + # No stale lock or multi-instance messages + assert not any("Stale" in record.message for record in caplog.records) + assert not any("Another Metals instance" in record.message for record in caplog.records) diff --git a/test/solidlsp/scss/__init__.py b/test/solidlsp/scss/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/solidlsp/scss/test_scss_basic.py b/test/solidlsp/scss/test_scss_basic.py new file mode 100644 index 0000000..ed87958 --- /dev/null +++ b/test/solidlsp/scss/test_scss_basic.py @@ -0,0 +1,398 @@ +""" +Basic integration tests for the SCSS language server (Some Sass). + +Some Sass provides full @use/@forward workspace navigation, so this suite +exercises both in-file document symbols and cross-file go-to-definition for +variables and mixins. +""" + +import os +import re +from pathlib import Path + +import pytest + +from serena.util.text_utils import find_text_coordinates +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from solidlsp.lsp_protocol_handler import lsp_types as LSPTypes +from test.solidlsp.conftest import read_repo_file, request_all_symbols + + +@pytest.mark.scss +class TestScssLanguageServerBasics: + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.SCSS], indirect=True) + def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_variables_document_symbols(self, language_server: SolidLanguageServer) -> None: + all_symbols, _ = language_server.request_document_symbols("_variables.scss").get_all_symbols_and_roots() + names = [s["name"] for s in all_symbols] + for var in ("$color-primary", "$color-secondary", "$color-text", "$space-md", "$space-lg"): + assert var in names, f"Expected variable {var} to appear in SCSS symbols: {names}" + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_mixins_document_symbols(self, language_server: SolidLanguageServer) -> None: + all_symbols, _ = language_server.request_document_symbols("_mixins.scss").get_all_symbols_and_roots() + names = [s["name"] for s in all_symbols] + # Some Sass surfaces @mixin and @function entries; names may be bare ("card-surface") + # or include the @-keyword. Check substring inclusion for robustness. + joined = " | ".join(names) + for expected in ("card-surface", "focus-ring", "rem"): + assert expected in joined, f"Expected '{expected}' to appear in SCSS mixin symbols: {names}" + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_buttons_document_symbols(self, language_server: SolidLanguageServer) -> None: + all_symbols, _ = language_server.request_document_symbols("buttons.scss").get_all_symbols_and_roots() + names = [s["name"] for s in all_symbols] + joined = " | ".join(names) + for selector in (".button", ".button-primary", ".button-secondary"): + assert selector in joined, f"Expected selector '{selector}' to appear in SCSS symbols: {names}" + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_cross_file_definition_variable(self, language_server: SolidLanguageServer) -> None: + """`vars.$color-text` in buttons.scss must resolve into _variables.scss.""" + path = "buttons.scss" + needle = "$color-text" + coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})") + assert coords is not None, f"Could not find {needle!r} in {path}" + line, col = coords.line, coords.col + # Cursor inside the variable identifier. + definitions = language_server.request_definition(path, line, col + 2) + assert definitions, f"Expected non-empty cross-file definition list for vars.$color-text, got {definitions}" + target_uris = [d["uri"] for d in definitions] + assert any(uri.endswith("_variables.scss") for uri in target_uris), ( + f"Expected definition to resolve into _variables.scss, got URIs: {target_uris}" + ) + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_cross_file_definition_mixin(self, language_server: SolidLanguageServer) -> None: + """`mix.card-surface` in buttons.scss must resolve into _mixins.scss.""" + path = "buttons.scss" + needle = "card-surface" + coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})") + assert coords is not None, f"Could not find {needle!r} in {path}" + line, col = coords.line, coords.col + definitions = language_server.request_definition(path, line, col + 2) + assert definitions, f"Expected non-empty cross-file definition list for mix.card-surface, got {definitions}" + target_uris = [d["uri"] for d in definitions] + assert any(uri.endswith("_mixins.scss") for uri in target_uris), ( + f"Expected definition to resolve into _mixins.scss, got URIs: {target_uris}" + ) + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_cross_file_definition_function(self, language_server: SolidLanguageServer) -> None: + """`mix.rem(16)` in main.scss must resolve into _mixins.scss (an @function).""" + path = "main.scss" + needle = "mix.rem" + coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})") + assert coords is not None, f"Could not find {needle!r} in {path}" + line, col = coords.line, coords.col + # Cursor inside the function identifier (skip the `mix.` prefix). + definitions = language_server.request_definition(path, line, col + 5) + assert definitions, f"Expected non-empty cross-file definition list for mix.rem, got {definitions}" + target_uris = [d["uri"] for d in definitions] + assert any(uri.endswith("_mixins.scss") for uri in target_uris), ( + f"Expected definition to resolve into _mixins.scss, got URIs: {target_uris}" + ) + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_full_symbol_tree_includes_all_files(self, language_server: SolidLanguageServer) -> None: + all_symbols = request_all_symbols(language_server) + relative_paths = {s.get("location", {}).get("relativePath") for s in all_symbols} + for f in ("_variables.scss", "_mixins.scss", "buttons.scss", "main.scss"): + assert f in relative_paths, f"Expected {f} to appear in symbol tree" + + +@pytest.mark.scss +class TestScssReferences: + """Find-references for symbols re-exported via @use across files.""" + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_mixin_references_span_files(self, language_server: SolidLanguageServer) -> None: + """References for ``card-surface`` must include both ``buttons.scss`` + (`.button`, `.button-primary`, `.button-secondary` all `@include` it) and + ``main.scss`` (`.panel` `@include`s it). + + Some Sass returns no references when the request originates on the + ``@mixin`` declaration itself, so we probe from a usage site in + ``buttons.scss`` — that's also closer to how an editor user invokes + find-references in practice. + """ + path = "buttons.scss" + needle = "card-surface" + coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})") + assert coords is not None, f"Could not find {needle!r} in {path}" + line, col = coords.line, coords.col + refs = language_server.request_references(path, line, col + 2) + ref_paths = {r.get("relativePath", "") for r in refs} + assert any(p.endswith("buttons.scss") for p in ref_paths), ( + f"Expected card-surface references to include buttons.scss, got: {ref_paths}" + ) + assert any(p.endswith("main.scss") for p in ref_paths), f"Expected card-surface references to include main.scss, got: {ref_paths}" + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_variable_references_span_files(self, language_server: SolidLanguageServer) -> None: + """`$color-primary` is read in ``buttons.scss`` (`.button-primary` background) + and ``_mixins.scss`` (default value of ``focus-ring``); references invoked + from the ``buttons.scss`` usage site must include both files. + """ + path = "buttons.scss" + needle = "$color-primary" + coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})") + assert coords is not None, f"Could not find {needle!r} in {path}" + line, col = coords.line, coords.col + refs = language_server.request_references(path, line, col + 2) + ref_paths = {r.get("relativePath", "") for r in refs} + assert any(p.endswith("buttons.scss") for p in ref_paths), ( + f"Expected $color-primary references to include buttons.scss, got: {ref_paths}" + ) + assert any(p.endswith("_mixins.scss") for p in ref_paths), ( + f"Expected $color-primary references to include _mixins.scss (default param of focus-ring), got: {ref_paths}" + ) + + +@pytest.mark.scss +class TestScssForward: + """`@forward` re-exports a module; consumers should reach forwarded symbols.""" + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_forwarded_buttons_appear_in_workspace(self, language_server: SolidLanguageServer) -> None: + """``main.scss`` does ``@forward "buttons"``; the workspace symbol tree must + still include ``buttons.scss`` selectors so consumers of `main` can navigate. + """ + all_symbols = request_all_symbols(language_server) + button_symbols = [s for s in all_symbols if s.get("location", {}).get("relativePath") == "buttons.scss"] + names = [s["name"] for s in button_symbols] + joined = " | ".join(names) + assert ".button" in joined, f"Expected .button selector to remain reachable via @forward, got: {names}" + + +@pytest.mark.scss +class TestScssHover: + """Some Sass returns rich hover content (SassDoc / value preview).""" + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_hover_on_variable_use(self, language_server: SolidLanguageServer) -> None: + path = "buttons.scss" + needle = "$color-text" + coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})") + assert coords is not None, f"Could not find {needle!r} in {path}" + line, col = coords.line, coords.col + hover = language_server.request_hover(path, line, col + 2) + assert hover is not None, f"Expected hover info for $color-text in {path}, got None" + contents = hover.get("contents") + assert contents, f"Expected non-empty hover contents, got: {hover}" + text = contents["value"] if isinstance(contents, dict) else str(contents) + assert "color-text" in text, f"Expected '$color-text' or its value in hover text, got: {text}" + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_hover_on_mixin_call(self, language_server: SolidLanguageServer) -> None: + path = "buttons.scss" + needle = "card-surface" + coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})") + assert coords is not None, f"Could not find {needle!r} in {path}" + line, col = coords.line, coords.col + hover = language_server.request_hover(path, line, col + 2) + assert hover is not None, f"Expected hover info for card-surface in {path}, got None" + contents = hover.get("contents") + assert contents, f"Expected non-empty hover contents, got: {hover}" + text = contents["value"] if isinstance(contents, dict) else str(contents) + assert "card-surface" in text, f"Expected 'card-surface' in hover text, got: {text}" + + +@pytest.mark.scss +class TestScssCompletions: + """Completions after a namespaced @use prefix should list re-exported members.""" + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_completion_after_namespace_dot(self, language_server: SolidLanguageServer) -> None: + """Completion immediately after `vars.` in buttons.scss must include the + variables defined in _variables.scss (e.g. ``$color-primary``). + """ + path = "buttons.scss" + # `color: vars.$color-text;` — invoke completion at the `.` position so the + # LSP sees the namespace prefix and offers its members. + needle = "vars.$color-text" + coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})") + assert coords is not None, f"Could not find {needle!r} in {path}" + line, col = coords.line, coords.col + # Position the cursor right after the `.` (4 chars: 'v','a','r','s','.' -> idx 5) + completions = language_server.request_completions(path, line, col + 5) + labels = {c.get("completionText", "") for c in completions} + # Different Some Sass releases label vars with or without leading $; accept both. + joined = " | ".join(sorted(labels)) + assert any("color-primary" in label for label in labels), f"Expected $color-primary completion after `vars.` prefix, got: {joined}" + + +@pytest.mark.scss +class TestScssSymbolKinds: + """Validate that Some Sass classifies SCSS symbols with sensible LSP kinds.""" + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_variable_symbol_kind(self, language_server: SolidLanguageServer) -> None: + all_symbols, _ = language_server.request_document_symbols("_variables.scss").get_all_symbols_and_roots() + by_name = {s["name"]: s for s in all_symbols} + assert "$color-primary" in by_name, f"Variable not in symbol list: {list(by_name)}" + kind = SymbolKind(by_name["$color-primary"]["kind"]) + assert kind in (SymbolKind.Variable, SymbolKind.Constant, SymbolKind.Property), ( + f"Expected $color-primary to be Variable/Constant/Property, got {kind.name}" + ) + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_mixin_and_function_symbol_kinds(self, language_server: SolidLanguageServer) -> None: + all_symbols, _ = language_server.request_document_symbols("_mixins.scss").get_all_symbols_and_roots() + + # Names may include the @-keyword (e.g. "@mixin card-surface") or be bare; + # match by substring. + def find_one(needle: str) -> dict: + matches = [s for s in all_symbols if needle in s["name"]] + assert matches, f"No symbol matched '{needle}' in {[s['name'] for s in all_symbols]}" + return matches[0] + + mixin = find_one("card-surface") + func = find_one("rem") + # Some Sass historically reports mixins/functions as Method or Function. + # Accept either, but reject obviously-wrong kinds (e.g. Variable/Class). + callable_kinds = {SymbolKind.Method, SymbolKind.Function} + assert SymbolKind(mixin["kind"]) in callable_kinds, ( + f"Expected card-surface kind in {{Method, Function}}, got {SymbolKind(mixin['kind']).name}" + ) + assert SymbolKind(func["kind"]) in callable_kinds, f"Expected rem kind in {{Method, Function}}, got {SymbolKind(func['kind']).name}" + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_completion_kind_is_meaningful(self, language_server: SolidLanguageServer) -> None: + """A `$variable` completion must come back with a meaningful kind. Some Sass + tags color-valued variables as ``Color`` (so editors render swatches) and + non-color variables as ``Variable``/``Property``/``Constant`` — accept any + of these but reject generic ``Text`` which would indicate the LSP failed + to classify the completion. + """ + path = "buttons.scss" + needle = "vars.$color-text" + coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})") + assert coords is not None, f"Could not find {needle!r} in {path}" + line, col = coords.line, coords.col + completions = language_server.request_completions(path, line, col + 5) + var_items = [c for c in completions if "color-primary" in c.get("completionText", "")] + assert var_items, "no $color-primary completion item found" + accepted = { + LSPTypes.CompletionItemKind.Variable, + LSPTypes.CompletionItemKind.Property, + LSPTypes.CompletionItemKind.Constant, + LSPTypes.CompletionItemKind.Color, + LSPTypes.CompletionItemKind.Value, + } + kinds = {LSPTypes.CompletionItemKind(c["kind"]).name for c in var_items} + assert any(LSPTypes.CompletionItemKind(c["kind"]) in accepted for c in var_items), ( + f"Expected variable-like CompletionItemKind for $color-primary, got: {kinds}" + ) + + +# --- Plain CSS via Some Sass -------------------------------------------------- +# +# Some Sass advertises the LSP ``css`` languageId as a first-class consumer (see +# ``packages/language-services/src/language-services-types.ts:LanguageConfiguration``) +# and dispatches per-feature handlers via the ``somesass.css.*.enabled`` toggles — +# all of which default to ``false`` upstream and which Serena flips on at init. +# These tests verify the routing actually works end-to-end against a small CSS +# fixture under ``css/`` in the same SCSS test repo. + + +@pytest.mark.scss +class TestSomeSassWithPlainCss: + """``Language.SCSS`` also handles plain ``.css`` via ``some-sass-language-server``.""" + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_main_css_document_symbols(self, language_server: SolidLanguageServer) -> None: + """Each top-level rule selector in ``main.css`` must surface as a document symbol.""" + all_symbols, _ = language_server.request_document_symbols("css/main.css").get_all_symbols_and_roots() + names = [s["name"] for s in all_symbols] + joined = " | ".join(names) + for selector in ("body", "#page-header", "#site-title", ".button", ".button-primary", ".button-secondary"): + assert selector in joined, f"Expected selector '{selector}' to appear in CSS symbols: {names}" + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_theme_css_document_symbols(self, language_server: SolidLanguageServer) -> None: + """``theme.css`` contains a single ``:root`` block; the LS must report it as a symbol.""" + all_symbols, _ = language_server.request_document_symbols("css/theme.css").get_all_symbols_and_roots() + names = [s["name"] for s in all_symbols] + joined = " | ".join(names) + assert ":root" in joined, f"Expected ':root' selector to appear in CSS symbols: {names}" + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_full_symbol_tree_includes_css_files(self, language_server: SolidLanguageServer) -> None: + """The ``.css`` files alongside the SCSS workspace must populate the workspace symbol tree.""" + all_symbols = request_all_symbols(language_server) + relative_paths = {s.get("location", {}).get("relativePath") for s in all_symbols} + # `relativePath` uses OS-native separators (cf. test_symbol_retrieval.py), + # so build expected paths via os.path.join to keep this test cross-platform. + for f in (os.path.join("css", "main.css"), os.path.join("css", "reset.css"), os.path.join("css", "theme.css")): + assert f in relative_paths, f"Expected {f} to appear in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_hover_on_css_property(self, language_server: SolidLanguageServer) -> None: + """Hover on a CSS property name must produce non-empty MDN-backed content + (Some Sass forwards ``vscode-css-languageservice``'s property reference data). + """ + path = "css/main.css" + needle = "background-color" + coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})") + assert coords is not None, f"Could not find {needle!r} in {path}" + line, col = coords.line, coords.col + hover = language_server.request_hover(path, line, col + 2) + assert hover is not None, f"Expected hover info for background-color in {path}, got None" + contents = hover.get("contents") + assert contents, f"Expected non-empty hover contents, got: {hover}" + text = contents["value"] if isinstance(contents, dict) else str(contents) + assert "background" in text.lower(), f"Expected hover text to mention 'background', got: {text}" + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_property_completion_in_css_rule(self, language_server: SolidLanguageServer) -> None: + """Inside a CSS rule body the LS must offer standard property names — + proves ``somesass.css.completion.enabled = true`` is being honoured. + """ + path = "css/main.css" + needle = "padding:" + coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})") + assert coords is not None, f"Could not find {needle!r} in {path}" + line, col = coords.line, coords.col + completions = language_server.request_completions(path, line, col) + labels = {c.get("completionText", "") for c in completions} + assert any(label in labels for label in ("padding", "margin", "color", "border")), ( + f"Expected at least one common CSS property name in completions, got sample: {sorted(labels)[:20]}" + ) + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_cross_file_completion_for_css_custom_property(self, language_server: SolidLanguageServer) -> None: + """Completion inside a ``var(...)`` call in ``main.css`` must surface the + ``--color-*`` custom properties declared in ``theme.css``. + + This is the single test that empirically validates Some Sass crosses file + boundaries for plain CSS — without ``somesass.css.completion.enabled = true`` + the request would short-circuit to ``null`` at the handler entrypoint. + + Note: ``vscode-css-languageservice`` (Some Sass' CSS engine) deliberately + does NOT implement go-to-definition for CSS custom properties yet — see + microsoft/vscode-css-languageservice#734. Completion is the closest + upstream-supported API that proves cross-file awareness. + """ + path = "css/main.css" + # `` color: var(--color-text);`` — invoke completion right after the + # leading dashes so the LS treats the request as a partial custom-property + # identifier and offers matching declarations from the workspace. + needle = "var(--color-text" + coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})") + assert coords is not None, f"Could not find {needle!r} in {path}" + line, col = coords.line, coords.col + cursor_col = col + len("var(--") + completions = language_server.request_completions(path, line, cursor_col) + labels = {c.get("completionText", "") for c in completions} + joined = " | ".join(sorted(labels)) + assert any("--color-primary" in label for label in labels), ( + f"Expected --color-primary completion (declared cross-file in theme.css), got: {joined}" + ) diff --git a/test/solidlsp/scss/test_scss_diagnostics.py b/test/solidlsp/scss/test_scss_diagnostics.py new file mode 100644 index 0000000..f5a2c90 --- /dev/null +++ b/test/solidlsp/scss/test_scss_diagnostics.py @@ -0,0 +1,42 @@ +""" +Diagnostics tests for the SCSS / Sass / CSS language server (Some Sass). + +Two sources are exercised — the SCSS parser (undefined ``$variable``) and the +plain-CSS parser (syntax error inside a rule body). ``somesass.css.diagnostics.enabled`` +is flipped on at initialization so the .css path is not gated off; lint diagnostics +are deliberately left disabled (the upstream rules are opinionated about vendor +prefixes / empty rules / etc.) so only syntax-level errors surface here. +""" + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.scss +class TestScssDiagnostics: + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_scss_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.scss", + (), + min_count=1, + ) + + @pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True) + def test_plain_css_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + """Plain ``.css`` diagnostics flow through the same Some Sass server. + + Without ``somesass.css.diagnostics.enabled = true`` (which Serena pushes via + initializationOptions), this would short-circuit to an empty list at the + handler entrypoint. + """ + assert_file_diagnostics( + language_server, + "css/diagnostics_sample.css", + (), + min_count=1, + ) diff --git a/test/solidlsp/solidity/__init__.py b/test/solidlsp/solidity/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/solidlsp/solidity/test_solidity_basic.py b/test/solidlsp/solidity/test_solidity_basic.py new file mode 100644 index 0000000..2279140 --- /dev/null +++ b/test/solidlsp/solidity/test_solidity_basic.py @@ -0,0 +1,207 @@ +""" +Basic integration tests for the Solidity language server. + +Tests validate symbol detection and reference finding using the Solidity test repository, +which contains a simple ERC-20 Token contract, a SafeMath library, and an IERC20 interface. +""" + +import re +from pathlib import Path +from typing import Optional + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_has_verified_implementation_support +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +def _find_identifier_position(file_path: Path, symbol_name: str) -> Optional[tuple[int, int]]: + """Return the (line, column) of the first occurrence of *symbol_name* as an identifier. + + Scans the file for a word-boundary match of *symbol_name* so that the position + returned is the exact location of the identifier, regardless of what range the + language server reports for the surrounding symbol. Returns None if not found. + """ + pattern = re.compile(r"\b" + re.escape(symbol_name) + r"\b") + with file_path.open(encoding="utf-8") as fh: + for line_idx, line in enumerate(fh): + m = pattern.search(line) + if m: + return line_idx, m.start() + return None + + +@pytest.mark.solidity +class TestSolidityLanguageServerBasics: + """Test basic functionality of the Solidity language server.""" + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.SOLIDITY], indirect=True) + def test_solidity_language_server_initialization(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that the Solidity language server starts and initializes correctly.""" + assert language_server is not None + assert language_server.language == Language.SOLIDITY + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.SOLIDITY], indirect=True) + def test_token_contract_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that document symbols are found in Token.sol. + + Verifies contract, state variables, errors, events, and function symbols. + """ + all_symbols, root_symbols = language_server.request_document_symbols("contracts/Token.sol").get_all_symbols_and_roots() + + assert all_symbols is not None, "Should return symbols for Token.sol" + assert len(all_symbols) > 0, f"Should find symbols in Token.sol, found {len(all_symbols)}" + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Contract-level symbol + assert "Token" in symbol_names, "Should detect the Token contract" + + # State variables + assert "name" in symbol_names, "Should detect the 'name' state variable" + assert "symbol" in symbol_names, "Should detect the 'symbol' state variable" + assert "decimals" in symbol_names, "Should detect the 'decimals' state variable" + + # Custom errors + assert "ZeroAddress" in symbol_names, "Should detect the 'ZeroAddress' custom error" + assert "InsufficientBalance" in symbol_names, "Should detect the 'InsufficientBalance' custom error" + + # Functions + assert "totalSupply" in symbol_names, "Should detect the 'totalSupply' function" + assert "balanceOf" in symbol_names, "Should detect the 'balanceOf' function" + assert "transfer" in symbol_names, "Should detect the 'transfer' function" + assert "approve" in symbol_names, "Should detect the 'approve' function" + assert "transferFrom" in symbol_names, "Should detect the 'transferFrom' function" + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.SOLIDITY], indirect=True) + def test_interface_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that document symbols are found in IERC20.sol.""" + all_symbols, root_symbols = language_server.request_document_symbols("contracts/interfaces/IERC20.sol").get_all_symbols_and_roots() + + assert all_symbols is not None, "Should return symbols for IERC20.sol" + assert len(all_symbols) > 0, f"Should find symbols in IERC20.sol, found {len(all_symbols)}" + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Interface + assert "IERC20" in symbol_names, "Should detect the IERC20 interface" + + # Events + assert "Transfer" in symbol_names, "Should detect the Transfer event" + assert "Approval" in symbol_names, "Should detect the Approval event" + + # View functions + assert "totalSupply" in symbol_names, "Should detect totalSupply" + assert "balanceOf" in symbol_names, "Should detect balanceOf" + assert "allowance" in symbol_names, "Should detect allowance" + + # Mutating functions + assert "transfer" in symbol_names, "Should detect transfer" + assert "approve" in symbol_names, "Should detect approve" + assert "transferFrom" in symbol_names, "Should detect transferFrom" + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.SOLIDITY], indirect=True) + def test_library_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that document symbols are found in SafeMath.sol.""" + all_symbols, root_symbols = language_server.request_document_symbols("contracts/lib/SafeMath.sol").get_all_symbols_and_roots() + + assert all_symbols is not None, "Should return symbols for SafeMath.sol" + assert len(all_symbols) > 0, f"Should find symbols in SafeMath.sol, found {len(all_symbols)}" + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Library + assert "SafeMath" in symbol_names, "Should detect the SafeMath library" + + # Library functions + assert "add" in symbol_names, "Should detect the 'add' function" + assert "sub" in symbol_names, "Should detect the 'sub' function" + assert "mul" in symbol_names, "Should detect the 'mul' function" + assert "div" in symbol_names, "Should detect the 'div' function" + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.SOLIDITY], indirect=True) + def test_within_file_references(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test finding within-file references to the _transfer helper in Token.sol.""" + # Use the file to find the exact identifier position: the Solidity LSP reports + # the symbol range starting at the preceding whitespace/comment block, not the + # function keyword, so we locate '_transfer' directly in the source. + pos = _find_identifier_position(repo_path / "contracts/Token.sol", "_transfer") + assert pos is not None, "Should find '_transfer' identifier in Token.sol" + definition_line, definition_char = pos + + references = language_server.request_references("contracts/Token.sol", definition_line, definition_char) + + assert references is not None, "Should return references for '_transfer'" + assert len(references) >= 2, ( + f"'_transfer' should have at least 2 references (callers), found {len(references)}" + ) # called in transfer() and transferFrom() + + ref_files = {ref.get("uri", "") for ref in references} + assert any("Token.sol" in uri for uri in ref_files), "References should include Token.sol" + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.SOLIDITY], indirect=True) + def test_cross_file_references(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test finding cross-file references: IERC20.transfer implemented in Token.sol.""" + # Use 'transfer' in the interface — Token.sol inherits IERC20 and overrides it, + # so the LSP resolves the implementation site in Token.sol as a cross-file reference. + pos = _find_identifier_position(repo_path / "contracts/interfaces/IERC20.sol", "transfer") + assert pos is not None, "Should find 'transfer' identifier in IERC20.sol" + definition_line, definition_char = pos + + references = language_server.request_references("contracts/interfaces/IERC20.sol", definition_line, definition_char) + + assert references is not None, "Should return cross-file references for IERC20.transfer" + assert len(references) >= 1, f"IERC20.transfer should be referenced at least once (in Token.sol), found {len(references)}" + + ref_files = {ref.get("uri", "") for ref in references} + assert any("Token.sol" in uri for uri in ref_files), "IERC20.transfer references should include Token.sol" + + if language_has_verified_implementation_support(Language.SOLIDITY): + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.SOLIDITY], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + pos = _find_identifier_position(repo_path / "contracts/interfaces/IERC20.sol", "transfer") + assert pos is not None, "Should find 'transfer' identifier in IERC20.sol" + + implementations = language_server.request_implementation("contracts/interfaces/IERC20.sol", *pos) + assert implementations, "Expected Token.transfer to be returned as an implementation" + assert any("Token.sol" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected Token.transfer implementation, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.SOLIDITY], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + pos = _find_identifier_position(repo_path / "contracts/interfaces/IERC20.sol", "transfer") + assert pos is not None, "Should find 'transfer' identifier in IERC20.sol" + + implementing_symbols = language_server.request_implementing_symbols("contracts/interfaces/IERC20.sol", *pos) + assert implementing_symbols, "Expected implementing symbols for IERC20.transfer" + assert any( + symbol.get("name") == "transfer" and "Token.sol" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected Token.transfer symbol, got: {implementing_symbols}" + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/solidity/test_solidity_diagnostics.py b/test/solidlsp/solidity/test_solidity_diagnostics.py new file mode 100644 index 0000000..21a11f3 --- /dev/null +++ b/test/solidlsp/solidity/test_solidity_diagnostics.py @@ -0,0 +1,115 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.language_servers.solidity_language_server import SolidityLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +class _NeverSetEvent: + """Stand-in for :class:`threading.Event` whose wait never succeeds.""" + + def wait(self, timeout: float | None = None) -> bool: + return False + + def clear(self) -> None: + return None + + def set(self) -> None: + return None + + +class _AlwaysSignalledEvent: + """Stand-in for :class:`threading.Event` whose wait always reports a completion; counts clears.""" + + def __init__(self) -> None: + self.cleared = 0 + + def wait(self, timeout: float | None = None) -> bool: + return True + + def clear(self) -> None: + self.cleared += 1 + + def set(self) -> None: + return None + + +@pytest.mark.solidity +class TestSolidityDiagnostics: + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "contracts/DiagnosticsSample.sol", + (), + min_count=1, + ) + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + def test_file_diagnostics_via_validation_completion( + self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Diagnostics must still arrive via the validation-completion fallback when the initial + publish wait does not observe them (the failure mode of cold/slow CI runners). + """ + assert isinstance(language_server, SolidityLanguageServer) + + # force the fast path to miss so that only the validation-completion fallback can deliver + monkeypatch.setattr(language_server, "_wait_for_relevant_published_diagnostics", lambda **kwargs: None) + + assert_file_diagnostics( + language_server, + "contracts/DiagnosticsSample.sol", + (), + min_count=1, + ) + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + def test_file_diagnostics_without_validation_signal( + self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A server that never signals validation completion must yield an empty result within the + bound instead of hanging or raising. + """ + assert isinstance(language_server, SolidityLanguageServer) + + # force the fast path to miss and make the completion signal unobtainable + monkeypatch.setattr(language_server, "_wait_for_relevant_published_diagnostics", lambda **kwargs: None) + monkeypatch.setattr(language_server, "_VALIDATION_COMPLETION_TIMEOUT", 0.2) + monkeypatch.setattr(language_server, "_validation_completed", _NeverSetEvent()) + + diagnostics = language_server.request_text_document_diagnostics("contracts/DiagnosticsSample.sol", min_severity=1) + assert diagnostics == [] + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + def test_file_diagnostics_rearms_after_spurious_completion( + self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A completion signal belonging to another document (the payload carries no URI) must + re-arm the fallback wait instead of ending the request without diagnostics. + """ + assert isinstance(language_server, SolidityLanguageServer) + + expected_diagnostic = { + "uri": "file:///spurious-completion-test", + "severity": 1, + "message": "delivered after re-arm", + "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 1}}, + "code": None, + } + # first wake is spurious (no publication for the request URI), second delivers + grace_reads: list[list | None] = [None, [expected_diagnostic]] + event = _AlwaysSignalledEvent() + + # force the fast path to miss and script the fallback's wake/read sequence + monkeypatch.setattr(language_server, "_wait_for_relevant_published_diagnostics", lambda **kwargs: None) + monkeypatch.setattr(language_server, "_validation_completed", event) + monkeypatch.setattr(language_server, "_wait_for_published_diagnostics", lambda **kwargs: grace_reads.pop(0)) + + diagnostics = language_server.request_text_document_diagnostics("contracts/DiagnosticsSample.sol", min_severity=1) + + # one clear at request start plus exactly one re-arm after the spurious wake + assert event.cleared == 2 + assert not grace_reads # both scripted reads consumed + assert [diagnostic["message"] for diagnostic in diagnostics] == ["delivered after re-arm"] diff --git a/test/solidlsp/svelte/__init__.py b/test/solidlsp/svelte/__init__.py new file mode 100644 index 0000000..3b8849f --- /dev/null +++ b/test/solidlsp/svelte/__init__.py @@ -0,0 +1,15 @@ +"""Svelte language server tests.""" + +import shutil + + +def _test_npm_available() -> str: + """Test if npm is available and return error reason if not.""" + # Check if npm is installed + if not shutil.which("npm"): + return "npm is not installed or not in PATH" + return "" # No error, npm is available + + +NPM_UNAVAILABLE_REASON = _test_npm_available() +NPM_UNAVAILABLE = bool(NPM_UNAVAILABLE_REASON) diff --git a/test/solidlsp/svelte/conftest.py b/test/solidlsp/svelte/conftest.py new file mode 100644 index 0000000..1b6183b --- /dev/null +++ b/test/solidlsp/svelte/conftest.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import logging +import os +import shutil +import subprocess +from pathlib import Path + +import pytest +from filelock import FileLock + +log = logging.getLogger(__name__) + +repo_path = Path(__file__).resolve().parents[2] / "resources" / "repos" / "svelte" / "test_repo" +NODE_MODULES = repo_path / "node_modules" +PACKAGE_LOCK = repo_path / "package-lock.json" +SVELTE_MARKER = NODE_MODULES / "svelte" / "package.json" +SVELTE_KIT_ADAPTER_MARKER = NODE_MODULES / "@sveltejs" / "adapter-auto" / "package.json" +SVELTE_KIT_TSCONFIG = repo_path / ".svelte-kit" / "tsconfig.json" +INSTALL_LOCK = repo_path / ".svelte-install.lock" + + +def _fixture_ready() -> bool: + return SVELTE_MARKER.exists() and SVELTE_KIT_ADAPTER_MARKER.exists() and SVELTE_KIT_TSCONFIG.exists() + + +def _run_svelte_kit_sync(npm_executable: str) -> None: + """Generate .svelte-kit (notably its tsconfig.json carrying the $lib path aliases), failing loudly on error. + + The fixture's own ``prepare`` script masks sync failures (``svelte-kit sync || echo ''``); a missing + ``.svelte-kit/tsconfig.json`` leaves the fixture tsconfig's ``extends`` dangling, tsserver silently loses + the ``$lib`` path aliases, and cross-file tests fail with partial results that look like LS flakes. + """ + sync = subprocess.run( + [npm_executable, "exec", "--", "svelte-kit", "sync"], + cwd=str(repo_path), + capture_output=True, + text=True, + check=False, + env=os.environ.copy(), + ) + if sync.returncode != 0 or not SVELTE_KIT_TSCONFIG.exists(): + pytest.fail( + f"svelte-kit sync failed (rc={sync.returncode}) or did not produce {SVELTE_KIT_TSCONFIG}; " + "without it the $lib path aliases do not resolve and cross-file svelte tests fail with partial results.\n" + "Known cause: npm silently skipping platform-specific optional dependencies (npm/cli#4828), which " + "leaves rolldown without its native binding; remedy: remove node_modules and reinstall.\n" + f"stdout:\n{sync.stdout}\nstderr:\n{sync.stderr}" + ) + log.info("svelte-kit sync succeeded; %s is present", SVELTE_KIT_TSCONFIG) + + +@pytest.fixture(scope="session", autouse=True) +def _install_svelte_test_repo_node_modules() -> None: + """Populate the Svelte fixture's project dependencies via npm and generate .svelte-kit.""" + if _fixture_ready(): + log.info("Svelte test repo node_modules and .svelte-kit already populated; skipping npm install") + return + + npm_executable = shutil.which("npm.cmd") or shutil.which("npm") + if npm_executable is None: + pytest.skip("npm is not available; cannot install Svelte test repo dependencies") + + if not PACKAGE_LOCK.exists(): + pytest.fail(f"Svelte fixture lockfile is missing: {PACKAGE_LOCK}. Regenerate it before running npm ci.") + + with FileLock(str(INSTALL_LOCK)): + if _fixture_ready(): + log.info("Svelte test repo dependencies populated by another worker; skipping npm install") + return + + if not SVELTE_MARKER.exists() or not SVELTE_KIT_ADAPTER_MARKER.exists(): + log.warning("Installing npm dependencies into the Svelte test repo at %s with npm ci.", repo_path) + proc = subprocess.run( + [npm_executable, "ci"], + cwd=str(repo_path), + capture_output=True, + text=True, + check=False, + env=os.environ.copy(), + ) + if proc.returncode != 0: + log.error("npm ci failed (rc=%s).\nstdout:\n%s\nstderr:\n%s", proc.returncode, proc.stdout, proc.stderr) + pytest.skip(f"npm ci failed in {repo_path} (rc={proc.returncode}); see logs for details") + + if not SVELTE_MARKER.exists() or not SVELTE_KIT_ADAPTER_MARKER.exists(): + pytest.skip("npm ci completed but required Svelte fixture packages are missing") + + log.info("Svelte test repo node_modules installed successfully") + + _run_svelte_kit_sync(npm_executable) diff --git a/test/solidlsp/svelte/test_svelte_basic.py b/test/solidlsp/svelte/test_svelte_basic.py new file mode 100644 index 0000000..7218cc0 --- /dev/null +++ b/test/solidlsp/svelte/test_svelte_basic.py @@ -0,0 +1,100 @@ +import os +from pathlib import Path + +import pytest + +from serena.util.text_utils import find_text_coordinates +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.solidlsp.conftest import read_repo_file +from test.solidlsp.svelte import conftest as svelte_test_conftest +from test.solidlsp.util.diagnostics import assert_file_diagnostics + +pytestmark = pytest.mark.svelte + + +class TestSvelteLanguageServer: + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.SVELTE], indirect=True) + def test_svelte_language_server_root_matches_repo_path(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + assert language_server.is_running() + assert repo_path.resolve() == svelte_test_conftest.repo_path.resolve() + assert Path(language_server.language_server.repo_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_svelte_and_typescript_files_in_symbol_tree(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree() + + assert SymbolUtils.symbol_tree_contains_name(symbols, "game"), "game variable not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Game"), "Game class not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "words"), "words export not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "count"), "count export not found in symbol tree" + # GAME_VERSION is defined *only* in a .ts file (game.ts); it is merely imported elsewhere. + # It therefore guards against the regression where .ts document symbols are not routed to the + # companion TS server (unlike game/Game/words/count, which also appear in .svelte files). + assert SymbolUtils.symbol_tree_contains_name(symbols, "GAME_VERSION"), ( + "GAME_VERSION (defined only in a .ts file) not found in symbol tree" + ) + + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_document_symbols_inside_svelte_file(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "lib", "components", "Counter.svelte") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + symbol_names = [symbol.get("name") for symbol in symbols[0]] + + assert "offset" in symbol_names + assert "modulo" in symbol_names + + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_document_symbols_inside_typescript_file(self, language_server: SolidLanguageServer) -> None: + # document symbols of a plain .ts file must be served by the companion TS server, not the + # base svelte LS (which only provides documentSymbol for .svelte files); see issue #1552. + file_path = os.path.join("src", "lib", "game.ts") + symbol_names = {symbol["name"] for symbol in language_server.request_document_symbols(file_path).iter_symbols()} + + # top-level symbols + assert "GAME_VERSION" in symbol_names, symbol_names + assert "Game" in symbol_names, symbol_names + # members nested inside the Game class + assert "enter" in symbol_names, symbol_names + assert "toString" in symbol_names, symbol_names + + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_overview_of_typescript_file(self, language_server: SolidLanguageServer) -> None: + # get_symbols_overview on a .ts file must not be empty in svelte-only mode (issue #1552). + file_path = os.path.join("src", "lib", "game.ts") + overview = language_server.request_overview(file_path) + + top_level_names = {symbol["name"] for symbols in overview.values() for symbol in symbols} + assert "GAME_VERSION" in top_level_names, overview + assert "Game" in top_level_names, overview + + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_definition_from_component_import_to_svelte_file(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "lib", "components", "Header.svelte") + coords = find_text_coordinates(read_repo_file(language_server, file_path), r"(count)") + + definitions = language_server.request_definition(file_path, coords.line, coords.col) + definition_paths = sorted(definition["relativePath"].replace("\\", "/") for definition in definitions) + + assert len(definitions) == 1, definition_paths + assert definitions[0]["relativePath"].replace("\\", "/") == "src/lib/components/Counter.svelte", definition_paths + + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_diagnostics_in_typescript_file(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + os.path.join("src", "lib", "diagnostics_sample.ts"), + ("missingGreeting", "missingConsumerValue"), + min_count=2, + ) + + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_diagnostics_in_svelte_file(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + os.path.join("src", "lib", "diagnostics_sample.svelte"), + ("number", "string"), + min_count=1, + ) diff --git a/test/solidlsp/svelte/test_svelte_references.py b/test/solidlsp/svelte/test_svelte_references.py new file mode 100644 index 0000000..07630c8 --- /dev/null +++ b/test/solidlsp/svelte/test_svelte_references.py @@ -0,0 +1,26 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + +pytestmark = pytest.mark.svelte + + +class TestSvelteReferences: + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_references_across_svelte_and_typescript(self, language_server: SolidLanguageServer) -> None: + refs = language_server.request_references(os.path.join("src", "lib", "components", "Words.svelte"), 1, 17) + ref_paths = {ref["relativePath"].replace("\\", "/") for ref in refs} + + assert "src/routes/(sverdle)/words.server.ts" in ref_paths, sorted(ref_paths) + assert "src/lib/game.ts" in ref_paths, sorted(ref_paths) + assert "src/routes/(sverdle)/+page.svelte" in ref_paths, sorted(ref_paths) + + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_references_from_typescript_file(self, language_server: SolidLanguageServer) -> None: + refs = language_server.request_references(os.path.join("src", "lib", "game.ts"), 3, 13) + ref_paths = {ref["relativePath"].replace("\\", "/") for ref in refs} + + assert "src/routes/(sverdle)/+page.server.ts" in ref_paths, sorted(ref_paths) diff --git a/test/solidlsp/svelte/test_svelte_rename.py b/test/solidlsp/svelte/test_svelte_rename.py new file mode 100644 index 0000000..58a0254 --- /dev/null +++ b/test/solidlsp/svelte/test_svelte_rename.py @@ -0,0 +1,123 @@ +import os +from collections.abc import Iterable +from urllib.parse import unquote + +import pytest + +from serena.util.text_utils import find_text_coordinates +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import TextEdit, WorkspaceEdit +from test.solidlsp.conftest import read_repo_file + +pytestmark = pytest.mark.svelte + + +def _iter_workspace_edit_entries(workspace_edit: WorkspaceEdit) -> Iterable[tuple[str, TextEdit]]: + if workspace_edit.get("changes"): + for uri, edits in workspace_edit["changes"].items(): + for edit in edits: + yield uri, edit + + for change in workspace_edit.get("documentChanges") or []: + if "textDocument" not in change or "edits" not in change: + continue + uri = change["textDocument"]["uri"] + for edit in change["edits"]: + yield uri, edit + + +def _assert_rename_edit( + workspace_edit: WorkspaceEdit | None, + new_name: str, + expected_path_fragments: set[str], +) -> None: + assert workspace_edit is not None, "rename should return a WorkspaceEdit" + + entries = list(_iter_workspace_edit_entries(workspace_edit)) + assert entries, workspace_edit + + edited_paths = {unquote(uri).replace("\\", "/") for uri, _edit in entries} + for expected_path in expected_path_fragments: + assert any(expected_path in edited_path for edited_path in edited_paths), ( + f"Expected rename edit for {expected_path}, got {sorted(edited_paths)}" + ) + + for uri, edit in entries: + assert "range" in edit, f"TextEdit in {uri} should have a range" + assert "newText" in edit, f"TextEdit in {uri} should have newText" + assert new_name in edit["newText"], f"TextEdit in {uri} should include {new_name}, got {edit['newText']}" + assert edit["range"]["start"]["line"] >= 0 + assert edit["range"]["start"]["character"] >= 0 + + +class TestSvelteRename: + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_rename_svelte_export_updates_svelte_importers(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "lib", "components", "Counter.svelte") + coords = find_text_coordinates(read_repo_file(language_server, file_path), r"(count)") + + workspace_edit = language_server.request_rename_symbol_edit(file_path, coords.line, coords.col, "score") + + _assert_rename_edit( + workspace_edit, + "score", + {"src/lib/components/Counter.svelte", "src/lib/components/Header.svelte"}, + ) + + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_rename_svelte_export_updates_ts_and_svelte_files(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "lib", "components", "Words.svelte") + coords = find_text_coordinates(read_repo_file(language_server, file_path), r"(words)") + + workspace_edit = language_server.request_rename_symbol_edit(file_path, coords.line, coords.col, "vocabulary") + + _assert_rename_edit( + workspace_edit, + "vocabulary", + { + "src/lib/components/Words.svelte", + "src/routes/(sverdle)/words.server.ts", + "src/lib/game.ts", + "src/routes/(sverdle)/+page.svelte", + }, + ) + + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_rename_ts_export_declaration_site_workspace_edit(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "routes", "(sverdle)", "words.server.ts") + coords = find_text_coordinates(read_repo_file(language_server, file_path), r"(allowed)") + + assert coords is not None + + workspace_edit = language_server.request_rename_symbol_edit(file_path, coords.line, coords.col, "allowedWords") + + _assert_rename_edit( + workspace_edit, + "allowedWords", + {"src/routes/(sverdle)/words.server.ts"}, + ) + + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_rename_ts_class_cross_file_workspace_edit_when_supported(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "lib", "game.ts") + coords = find_text_coordinates(read_repo_file(language_server, file_path), r"(Game)") + + assert coords is not None + + workspace_edit = language_server.request_rename_symbol_edit(file_path, coords.line, coords.col, "SverdleGame") + + assert workspace_edit is not None, ( + "SvelteLanguageServer.request_rename_symbol_edit returned None for a cross-file TS class rename; " + "companion SvelteTypeScriptServer (typescript-svelte-plugin) should provide this edit." + ) + + _assert_rename_edit( + workspace_edit, + "SverdleGame", + { + "src/lib/game.ts", + "src/routes/(sverdle)/+page.server.ts", + "src/lib/components/Counter.svelte", + }, + ) diff --git a/test/solidlsp/svelte/test_svelte_symbols.py b/test/solidlsp/svelte/test_svelte_symbols.py new file mode 100644 index 0000000..c6171d4 --- /dev/null +++ b/test/solidlsp/svelte/test_svelte_symbols.py @@ -0,0 +1,67 @@ +"""Agent-level symbol discovery/navigation for plain .ts files of a svelte-only project. + +Reproduces https://github.com/oraios/serena/issues/1552: in ``languages: [svelte]`` mode, +document-symbol requests for .ts/.js files were answered by the base svelte LS (which only +serves .svelte files) instead of being routed to the companion, svelte-plugin-aware TS server. +As a consequence, symbols defined in plain .ts files were undiscoverable through ``find_symbol``/ +``get_symbols_overview``, and ``find_referencing_symbols`` failed with ``No symbol matching ...`` +because it must first locate the target symbol via ``documentSymbol``. + +These tests exercise the high-level :class:`LanguageServerSymbolRetriever` that backs those agent +tools, complementing the lower-level checks in ``test_svelte_basic.py``. +""" + +import os + +import pytest + +from serena.project import Project +from serena.symbol import LanguageServerSymbolRetriever +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind + +pytestmark = pytest.mark.svelte + +# a plain .ts module whose exported symbols are consumed by both .ts and .svelte files +GAME_TS = os.path.join("src", "lib", "game.ts") + + +class TestSvelteTypeScriptSymbolDiscovery: + @pytest.mark.parametrize("project_with_ls", [Language.SVELTE], indirect=True) + def test_find_symbol_in_typescript_file(self, project_with_ls: Project) -> None: + retriever = LanguageServerSymbolRetriever(project_with_ls) + + # GAME_VERSION is *defined* only in game.ts (merely imported elsewhere) + version_symbols = retriever.find("GAME_VERSION", within_relative_path=GAME_TS) + assert [s.name for s in version_symbols] == ["GAME_VERSION"] + assert version_symbols[0].relative_path is not None + assert version_symbols[0].relative_path.replace("\\", "/") == "src/lib/game.ts" + + # the Game class is discoverable and correctly typed + game_symbols = retriever.find("Game", within_relative_path=GAME_TS, include_kinds=[SymbolKind.Class]) + assert len(game_symbols) == 1, game_symbols + assert game_symbols[0].symbol_kind == SymbolKind.Class + + # nested members of the .ts class are discoverable via their name path + enter_symbols = retriever.find("Game/enter", within_relative_path=GAME_TS) + assert len(enter_symbols) == 1, enter_symbols + assert enter_symbols[0].get_name_path() == "Game/enter" + + @pytest.mark.parametrize("project_with_ls", [Language.SVELTE], indirect=True) + def test_find_referencing_symbols_locates_typescript_symbol(self, project_with_ls: Project) -> None: + retriever = LanguageServerSymbolRetriever(project_with_ls) + + # The core of issue #1552 for find-references: the target symbol must first be located via + # documentSymbol. Before the fix, .ts document symbols were empty, so locating the symbol + # raised ValueError("No symbol matching 'GAME_VERSION' found") *before the companion TS + # server was ever consulted*. Verify the symbol is now uniquely discoverable. + symbol = retriever.find_unique("GAME_VERSION", within_relative_path=GAME_TS) + assert symbol.name == "GAME_VERSION" + assert symbol.relative_path is not None + assert symbol.relative_path.replace("\\", "/") == "src/lib/game.ts" + + # The end-to-end find_referencing_symbols call must now get past symbol discovery without + # raising. (The cross-file/.svelte reference *content* is covered by test_svelte_references.py + # and depends on the companion TS server's reference graph rather than on this fix.) + references = retriever.find_referencing_symbols("GAME_VERSION", GAME_TS) + assert isinstance(references, list) diff --git a/test/solidlsp/swift/test_swift_basic.py b/test/solidlsp/swift/test_swift_basic.py new file mode 100644 index 0000000..f4395f4 --- /dev/null +++ b/test/solidlsp/swift/test_swift_basic.py @@ -0,0 +1,245 @@ +""" +Basic integration tests for the Swift language server functionality. + +These tests validate the functionality of the language server APIs +like request_references using the Swift test repository. +""" + +import os + +import pytest + +from serena.project import Project +from serena.util.text_utils import LineType +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import is_ci, language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics + +pytestmark = [pytest.mark.swift, pytest.mark.skipif(not language_tests_enabled(Language.SWIFT), reason="Swift tests are disabled")] + + +class TestSwiftLanguageServerBasics: + """Test basic functionality of the Swift language server.""" + + @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) + def test_goto_definition_calculator_class(self, language_server: SolidLanguageServer) -> None: + """Test goto_definition on Calculator class usage.""" + file_path = os.path.join("src", "main.swift") + + # Find the Calculator usage at line 5: let calculator = Calculator() + # Position should be at the "Calculator()" call + definitions = language_server.request_definition(file_path, 4, 23) # Position at Calculator() call + assert isinstance(definitions, list), "Definitions should be a list" + assert len(definitions) > 0, "Should find definition for Calculator class" + + # Verify the definition points to the Calculator class definition + calculator_def = definitions[0] + assert calculator_def.get("uri", "").endswith("main.swift"), "Definition should be in main.swift" + + # The Calculator class is defined starting at line 16 + start_line = calculator_def.get("range", {}).get("start", {}).get("line") + assert start_line == 15, f"Calculator class definition should be at line 16, got {start_line + 1}" + + @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) + def test_goto_definition_user_struct(self, language_server: SolidLanguageServer) -> None: + """Test goto_definition on User struct usage.""" + file_path = os.path.join("src", "main.swift") + + # Find the User usage at line 9: let user = User(name: "Alice", age: 30) + # Position should be at the "User(...)" call + definitions = language_server.request_definition(file_path, 8, 18) # Position at User(...) call + assert isinstance(definitions, list), "Definitions should be a list" + assert len(definitions) > 0, "Should find definition for User struct" + + # Verify the definition points to the User struct definition + user_def = definitions[0] + assert user_def.get("uri", "").endswith("main.swift"), "Definition should be in main.swift" + + # The User struct is defined starting at line 26 + start_line = user_def.get("range", {}).get("start", {}).get("line") + assert start_line == 25, f"User struct definition should be at line 26, got {start_line + 1}" + + @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) + def test_goto_definition_calculator_method(self, language_server: SolidLanguageServer) -> None: + """Test goto_definition on Calculator method usage.""" + file_path = os.path.join("src", "main.swift") + + # Find the add method usage at line 6: let result = calculator.add(5, 3) + # Position should be at the "add" method call + definitions = language_server.request_definition(file_path, 5, 28) # Position at add method call + assert isinstance(definitions, list), "Definitions should be a list" + + # Verify the definition points to the add method definition + add_def = definitions[0] + assert add_def.get("uri", "").endswith("main.swift"), "Definition should be in main.swift" + + # The add method is defined starting at line 17 + start_line = add_def.get("range", {}).get("start", {}).get("line") + assert start_line == 16, f"add method definition should be at line 17, got {start_line + 1}" + + @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) + def test_goto_definition_cross_file(self, language_server: SolidLanguageServer) -> None: + """Test goto_definition across files - Utils struct.""" + utils_file = os.path.join("src", "utils.swift") + + # First, let's check if Utils is used anywhere (it might not be in this simple test) + # We'll test goto_definition on Utils struct itself + symbols = language_server.request_document_symbols(utils_file).get_all_symbols_and_roots() + utils_symbol = next(s for s in symbols[0] if s.get("name") == "Utils") + + sel_start = utils_symbol["selectionRange"]["start"] + definitions = language_server.request_definition(utils_file, sel_start["line"], sel_start["character"]) + assert isinstance(definitions, list), "Definitions should be a list" + + # Should find the Utils struct definition itself + utils_def = definitions[0] + assert utils_def.get("uri", "").endswith("utils.swift"), "Definition should be in utils.swift" + + @pytest.mark.xfail(is_ci, reason="Test is flaky in CI") # See #1040 + @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) + def test_request_references_calculator_class(self, language_server: SolidLanguageServer) -> None: + """Test request_references on the Calculator class.""" + # Get references to the Calculator class in main.swift + file_path = os.path.join("src", "main.swift") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + calculator_symbol = next(s for s in symbols[0] if s.get("name") == "Calculator") + + sel_start = calculator_symbol["selectionRange"]["start"] + references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert isinstance(references, list), "References should be a list" + assert len(references) > 0, "Calculator class should be referenced" + + # Validate that Calculator is referenced in the main function + calculator_refs = [ref for ref in references if ref.get("uri", "").endswith("main.swift")] + assert len(calculator_refs) > 0, "Calculator class should be referenced in main.swift" + + # Check that one reference is at line 5 (let calculator = Calculator()) + line_5_refs = [ref for ref in calculator_refs if ref.get("range", {}).get("start", {}).get("line") == 4] + assert len(line_5_refs) > 0, "Calculator should be referenced at line 5" + + @pytest.mark.xfail(is_ci, reason="Test is flaky in CI") # See #1040 + @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) + def test_request_references_user_struct(self, language_server: SolidLanguageServer) -> None: + """Test request_references on the User struct.""" + # Get references to the User struct in main.swift + file_path = os.path.join("src", "main.swift") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + user_symbol = next(s for s in symbols[0] if s.get("name") == "User") + + sel_start = user_symbol["selectionRange"]["start"] + references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert isinstance(references, list), "References should be a list" + + # Validate that User is referenced in the main function + user_refs = [ref for ref in references if ref.get("uri", "").endswith("main.swift")] + assert len(user_refs) > 0, "User struct should be referenced in main.swift" + + # Check that one reference is at line 9 (let user = User(...)) + line_9_refs = [ref for ref in user_refs if ref.get("range", {}).get("start", {}).get("line") == 8] + assert len(line_9_refs) > 0, "User should be referenced at line 9" + + @pytest.mark.xfail(is_ci, reason="Test is flaky in CI") # See #1040 + @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) + def test_request_references_utils_struct(self, language_server: SolidLanguageServer) -> None: + """Test request_references on the Utils struct.""" + # Get references to the Utils struct in utils.swift + file_path = os.path.join("src", "utils.swift") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + utils_symbol = next((s for s in symbols[0] if s.get("name") == "Utils"), None) + if not utils_symbol or "selectionRange" not in utils_symbol: + raise AssertionError("Utils symbol or its selectionRange not found") + sel_start = utils_symbol["selectionRange"]["start"] + references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert isinstance(references, list), "References should be a list" + assert len(references) > 0, "Utils struct should be referenced" + + # Validate that Utils is referenced in main.swift + utils_refs = [ref for ref in references if ref.get("uri", "").endswith("main.swift")] + assert len(utils_refs) > 0, "Utils struct should be referenced in main.swift" + + # Check that one reference is at line 12 (Utils.calculateArea call) + line_12_refs = [ref for ref in utils_refs if ref.get("range", {}).get("start", {}).get("line") == 11] + assert len(line_12_refs) > 0, "Utils should be referenced at line 12" + + +class TestSwiftProjectBasics: + @pytest.mark.parametrize("project", [Language.SWIFT], indirect=True) + def test_retrieve_content_around_line(self, project: Project) -> None: + """Test retrieve_content_around_line functionality with various scenarios.""" + file_path = os.path.join("src", "main.swift") + + # Scenario 1: Find Calculator class definition + calculator_line = None + for line_num in range(1, 50): # Search first 50 lines + try: + line_content = project.retrieve_content_around_line(file_path, line_num) + if line_content.lines and "class Calculator" in line_content.lines[0].line_content: + calculator_line = line_num + break + except: + continue + + assert calculator_line is not None, "Calculator class not found" + line_calc = project.retrieve_content_around_line(file_path, calculator_line) + assert len(line_calc.lines) == 1 + assert "class Calculator" in line_calc.lines[0].line_content + assert line_calc.lines[0].line_number == calculator_line + assert line_calc.lines[0].match_type == LineType.MATCH + + # Scenario 2: Context above and below Calculator class + with_context_around_calculator = project.retrieve_content_around_line(file_path, calculator_line, 2, 2) + assert len(with_context_around_calculator.lines) == 5 + assert "class Calculator" in with_context_around_calculator.matched_lines[0].line_content + assert with_context_around_calculator.num_matched_lines == 1 + + # Scenario 3: Search for struct definitions + struct_pattern = r"struct\s+\w+" + matches = project.search_project_files_for_pattern(struct_pattern) + assert len(matches) > 0, "Should find struct definitions" + # Should find User struct + user_matches = [m for m in matches if "User" in str(m)] + assert len(user_matches) > 0, "Should find User struct" + + # Scenario 4: Search for class definitions + class_pattern = r"class\s+\w+" + matches = project.search_project_files_for_pattern(class_pattern) + assert len(matches) > 0, "Should find class definitions" + # Should find Calculator and Circle classes + calculator_matches = [m for m in matches if "Calculator" in str(m)] + circle_matches = [m for m in matches if "Circle" in str(m)] + assert len(calculator_matches) > 0, "Should find Calculator class" + assert len(circle_matches) > 0, "Should find Circle class" + + # Scenario 5: Search for enum definitions + enum_pattern = r"enum\s+\w+" + matches = project.search_project_files_for_pattern(enum_pattern) + assert len(matches) > 0, "Should find enum definitions" + # Should find Status enum + status_matches = [m for m in matches if "Status" in str(m)] + assert len(status_matches) > 0, "Should find Status enum" + + @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) + + @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/diagnostics_sample.swift", + (), + min_count=1, + ) diff --git a/test/solidlsp/systemverilog/__init__.py b/test/solidlsp/systemverilog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/solidlsp/systemverilog/test_systemverilog_basic.py b/test/solidlsp/systemverilog/test_systemverilog_basic.py new file mode 100644 index 0000000..43d75d0 --- /dev/null +++ b/test/solidlsp/systemverilog/test_systemverilog_basic.py @@ -0,0 +1,283 @@ +""" +Basic tests for SystemVerilog language server integration (verible-verilog-ls). + +This module tests Language.SYSTEMVERILOG using verible-verilog-ls. +Tests are skipped if the language server is not available. +""" + +from typing import Any + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.conftest import language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + +pytestmark = pytest.mark.skipif( + not language_tests_enabled(Language.SYSTEMVERILOG), reason="SystemVerilog tests are disabled (verible-verilog-ls not available)" +) + + +def _find_symbol_by_name(language_server: SolidLanguageServer, file_path: str, name: str) -> dict[str, Any] | None: + """Find a top-level symbol by name in a file's document symbols.""" + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + return next((s for s in symbols[0] if s.get("name") == name), None) + + +def _get_symbol_selection_start(language_server: SolidLanguageServer, file_path: str, name: str) -> tuple[int, int]: + """Get the (line, character) of a symbol's selectionRange start.""" + symbol = _find_symbol_by_name(language_server, file_path, name) + assert symbol is not None, f"Could not find symbol '{name}' in {file_path}" + assert "selectionRange" in symbol, f"Symbol '{name}' has no selectionRange in {file_path}" + sel_start = symbol["selectionRange"]["start"] + return sel_start["line"], sel_start["character"] + + +@pytest.mark.systemverilog +class TestSystemVerilogSymbols: + """Tests for document symbol extraction.""" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + """Test that symbol tree contains expected modules.""" + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "counter"), "Module 'counter' not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_get_document_symbols(self, language_server: SolidLanguageServer) -> None: + """Test document symbols for counter.sv.""" + symbol = _find_symbol_by_name(language_server, "counter.sv", "counter") + assert symbol is not None, "Expected 'counter' in document symbols" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_find_top_module(self, language_server: SolidLanguageServer) -> None: + """Test that top module is found (cross-file instantiation test).""" + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "top"), "Module 'top' not found in symbol tree" + + +@pytest.mark.systemverilog +class TestSystemVerilogDefinition: + """Tests for go-to-definition functionality.""" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_goto_definition(self, language_server: SolidLanguageServer) -> None: + """Test go to definition from signal usage to its declaration. + + Navigating from 'count' usage in always_ff (line 13) should jump + to the output port declaration (line 7, char 29). + """ + # counter.sv line 13 (0-indexed): " count <= '0;" + # 'count' at char 12 + definitions = language_server.request_definition("counter.sv", 13, 12) + assert len(definitions) >= 1, f"Expected at least 1 definition, got {len(definitions)}" + def_in_counter = [d for d in definitions if "counter.sv" in (d.get("relativePath") or "")] + assert len(def_in_counter) >= 1, f"Expected definition in counter.sv, got: {[d.get('relativePath') for d in definitions]}" + assert def_in_counter[0]["range"]["start"]["line"] == 7, ( + f"Expected definition at line 7 (output port count), got line {def_in_counter[0]['range']['start']['line']}" + ) + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_goto_definition_cross_file(self, language_server: SolidLanguageServer) -> None: + """Test go to definition from module instantiation in top.sv to counter.sv. + + This is the key cross-file test: navigating from an instantiation + (counter in top.sv) to its definition (counter.sv). + """ + # top.sv line 17 (0-indexed: 16): " counter #(.WIDTH(8)) u_counter (" + # "counter" starts at column 4 + definitions = language_server.request_definition("top.sv", 16, 4) + assert len(definitions) >= 1, f"Expected at least 1 definition, got {len(definitions)}" + def_paths = [d.get("relativePath", "") for d in definitions] + assert any("counter.sv" in p for p in def_paths), f"Expected definition in counter.sv, got: {def_paths}" + counter_defs = [d for d in definitions if "counter.sv" in (d.get("relativePath") or "")] + assert counter_defs[0]["range"]["start"]["line"] == 1, ( + f"Expected definition at line 1 (module counter), got line {counter_defs[0]['range']['start']['line']}" + ) + + +@pytest.mark.systemverilog +class TestSystemVerilogReferences: + """Tests for find-references functionality.""" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_find_references(self, language_server: SolidLanguageServer) -> None: + """Test finding within-file references to a port signal. + + The 'count' output port is declared on line 7 and used in the + always_ff block on lines 13 and 15 (twice), giving 3 within-file + references — all inside counter.sv. + """ + # counter.sv line 8 (0-indexed: 7): " output logic [WIDTH-1:0] count" + # 'count' starts at char 29 + references = language_server.request_references("counter.sv", 7, 29) + assert len(references) >= 1, f"Expected at least 1 reference, got {len(references)}" + ref_paths = [r.get("relativePath", "") for r in references] + refs_in_counter = [r for r in references if "counter.sv" in (r.get("relativePath") or "")] + assert len(refs_in_counter) >= 1, f"Expected within-file references in counter.sv, got paths: {ref_paths}" + ref_lines = sorted(r["range"]["start"]["line"] for r in refs_in_counter) + # Line 13: count <= '0; Line 15: count <= count + 1'b1; (two refs) + assert 13 in ref_lines, f"Expected reference at line 13 (count <= '0), got lines: {ref_lines}" + assert 15 in ref_lines, f"Expected reference at line 15 (count <= count + 1'b1), got lines: {ref_lines}" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_find_references_cross_file(self, language_server: SolidLanguageServer) -> None: + """Test that references to counter include its instantiation in top.sv. + + Similar to Rust (lib.rs → main.rs) and C# (Program.cs → Models/Person.cs), + this verifies that cross-file references are found. + """ + line, char = _get_symbol_selection_start(language_server, "counter.sv", "counter") + references = language_server.request_references("counter.sv", line, char) + ref_paths = [ref.get("relativePath", "") for ref in references] + assert any("top.sv" in p for p in ref_paths), f"Expected reference from top.sv, got: {ref_paths}" + refs_in_top = [r for r in references if "top.sv" in (r.get("relativePath") or "")] + # top.sv line 17 (0-indexed: 16): " counter #(.WIDTH(8)) u_counter (" + assert refs_in_top[0]["range"]["start"]["line"] == 16, ( + f"Expected cross-file reference at line 16 (counter instantiation), got line {refs_in_top[0]['range']['start']['line']}" + ) + + +def _extract_hover_text(hover_info: dict[str, Any]) -> str: + """Extract the text content from an LSP hover response.""" + contents = hover_info["contents"] + if isinstance(contents, dict): + return contents.get("value", "") + elif isinstance(contents, str): + return contents + return str(contents) + + +@pytest.mark.systemverilog +class TestSystemVerilogHover: + """Tests for hover information.""" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_hover(self, language_server: SolidLanguageServer) -> None: + """Test hover information (experimental in verible, requires --lsp_enable_hover).""" + line, char = _get_symbol_selection_start(language_server, "counter.sv", "counter") + hover_info = language_server.request_hover("counter.sv", line, char) + assert hover_info is not None, "Hover should return information for counter module" + assert "contents" in hover_info, "Hover should have contents" + hover_text = _extract_hover_text(hover_info) + assert len(hover_text) > 0, "Hover text should not be empty" + assert "counter" in hover_text.lower(), f"Hover should mention 'counter', got: {hover_text}" + assert "module" in hover_text.lower(), f"Hover should identify 'counter' as a module, got: {hover_text}" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_hover_includes_type_information(self, language_server: SolidLanguageServer) -> None: + """Test that hover includes type information for a port signal. + + Hovering on 'count' output port should return its name and type + (logic [WIDTH-1:0]), distinct from module-level hover. + """ + # counter.sv line 8 (0-indexed: 7): " output logic [WIDTH-1:0] count" + # 'count' starts at char 29 + hover_info = language_server.request_hover("counter.sv", 7, 29) + assert hover_info is not None, "Hover should return information for 'count' port" + assert "contents" in hover_info, "Hover should have contents" + hover_text = _extract_hover_text(hover_info) + assert "count" in hover_text.lower(), f"Hover should mention 'count', got: {hover_text}" + assert "logic" in hover_text.lower(), f"Hover should include type 'logic', got: {hover_text}" + + +def _extract_changes(workspace_edit: dict[str, Any]) -> dict[str, list[dict[str, Any]]]: + """Extract file URI → edits mapping from a WorkspaceEdit, handling both formats.""" + changes = workspace_edit.get("changes", {}) + if not changes: + doc_changes = workspace_edit.get("documentChanges", []) + assert len(doc_changes) > 0, "WorkspaceEdit should have 'changes' or 'documentChanges'" + changes = {dc["textDocument"]["uri"]: dc["edits"] for dc in doc_changes if "textDocument" in dc and "edits" in dc} + return changes + + +@pytest.mark.systemverilog +class TestSystemVerilogRename: + """Tests for rename functionality.""" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_rename_signal_within_file(self, language_server: SolidLanguageServer) -> None: + """Test renaming a port signal from its declaration updates within-file occurrences. + + The 'count' output port (line 7, char 29) is used in the always_ff + block on lines 13 and 15. Renaming from the declaration site produces + edits for all occurrences within counter.sv. + """ + workspace_edit = language_server.request_rename_symbol_edit("counter.sv", 7, 29, "cnt") + assert workspace_edit is not None, "Rename should be supported for port signal 'count'" + + changes = _extract_changes(workspace_edit) + counter_edits = [edits for uri, edits in changes.items() if "counter.sv" in uri] + assert len(counter_edits) >= 1, f"Should have edits for counter.sv, got: {list(changes.keys())}" + + edits = counter_edits[0] + assert len(edits) >= 2, f"Expected at least 2 edits (declaration + usage), got {len(edits)}" + edit_lines = sorted(e["range"]["start"]["line"] for e in edits) + assert 7 in edit_lines, f"Expected edit at line 7 (port declaration), got lines: {edit_lines}" + assert 13 in edit_lines, f"Expected edit at line 13 (count <= '0), got lines: {edit_lines}" + assert 15 in edit_lines, f"Expected edit at line 15 (count <= count + 1'b1), got lines: {edit_lines}" + for edit in edits: + assert edit["newText"] == "cnt", f"Expected newText 'cnt', got {edit['newText']}" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_rename_signal_cross_file(self, language_server: SolidLanguageServer) -> None: + """Test renaming a port signal from a usage site includes cross-file edits. + + Renaming 'count' from usage in always_ff (line 13, char 12) should + produce edits in counter.sv (declaration + usages) and also in top.sv + where the port is connected (.count(count) at line 20). + """ + workspace_edit = language_server.request_rename_symbol_edit("counter.sv", 13, 12, "cnt") + assert workspace_edit is not None, "Rename should be supported for signal 'count' from usage site" + + changes = _extract_changes(workspace_edit) + counter_uris = [uri for uri in changes if "counter.sv" in uri] + top_uris = [uri for uri in changes if "top.sv" in uri] + assert len(counter_uris) >= 1, f"Expected edits in counter.sv, got: {list(changes.keys())}" + assert len(top_uris) >= 1, f"Expected cross-file edits in top.sv, got: {list(changes.keys())}" + + for uri, edits in changes.items(): + for edit in edits: + assert edit["newText"] == "cnt", f"Expected 'cnt' in {uri}, got {edit['newText']}" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_rename_module_name(self, language_server: SolidLanguageServer) -> None: + """Test renaming a module name at its declaration. + + The 'counter' module declaration (line 1, char 7) is renamed to + 'my_counter'. Verible renames the identifier at the definition site. + """ + line, char = _get_symbol_selection_start(language_server, "counter.sv", "counter") + workspace_edit = language_server.request_rename_symbol_edit("counter.sv", line, char, "my_counter") + assert workspace_edit is not None, "Rename should be supported for module 'counter'" + + changes = _extract_changes(workspace_edit) + assert len(changes) > 0, "WorkspaceEdit should have changes" + counter_edits = [edits for uri, edits in changes.items() if "counter.sv" in uri] + assert len(counter_edits) >= 1, f"Should have edits for counter.sv, got: {list(changes.keys())}" + + edits = counter_edits[0] + edit_lines = sorted(e["range"]["start"]["line"] for e in edits) + assert 1 in edit_lines, f"Expected edit at line 1 (module declaration), got lines: {edit_lines}" + decl_edits = [e for e in edits if e["range"]["start"]["line"] == 1] + assert decl_edits[0]["range"]["start"]["character"] == 7, ( + f"Expected edit at char 7, got char {decl_edits[0]['range']['start']['character']}" + ) + for uri, file_edits in changes.items(): + for edit in file_edits: + assert edit["newText"] == "my_counter", f"Expected 'my_counter', got {edit['newText']}" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/systemverilog/test_systemverilog_detection.py b/test/solidlsp/systemverilog/test_systemverilog_detection.py new file mode 100644 index 0000000..34a96a3 --- /dev/null +++ b/test/solidlsp/systemverilog/test_systemverilog_detection.py @@ -0,0 +1,332 @@ +""" +Tests for verible-verilog-ls detection logic. + +These tests describe the expected behavior of SystemVerilogLanguageServer.DependencyProvider._get_or_install_core_dependency(): + +1. System PATH should be checked FIRST (prefers user-installed verible) +2. Runtime download should be fallback when not in PATH +3. Version information should be logged when available +4. Version check failures should be handled gracefully +5. Helpful error messages when verible is not available on unsupported platforms + +WHY these tests matter: +- Users install verible via conda, Homebrew, system packages, or GitHub releases +- Detection failing means Serena is unusable for SystemVerilog, even when verible is correctly installed +- Without these tests, the detection logic can silently break for users with system installations +- Version logging helps debug compatibility issues +""" + +import os +import shutil +import subprocess +import tempfile +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from solidlsp.language_servers.systemverilog_server import SystemVerilogLanguageServer +from solidlsp.settings import SolidLSPSettings + +DEFAULT_VERIBLE_VERSION = "v0.0-4051-g9fdb4057" + + +class TestVeribleVerilogLsDetection: + """Unit tests for verible-verilog-ls binary detection logic.""" + + @pytest.mark.systemverilog + def test_detect_from_path_returns_system_verible(self): + """ + GIVEN verible-verilog-ls is in system PATH + WHEN _get_or_install_core_dependency is called + THEN it returns the system path without downloading + + WHY: Users with system-installed verible (via conda, Homebrew, apt) + should use that version instead of downloading. This is faster and + respects user's environment management. + """ + with tempfile.TemporaryDirectory() as temp_dir: + custom_settings = SolidLSPSettings.CustomLSSettings({}) + provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) + + with patch("shutil.which") as mock_which: + mock_which.return_value = "/usr/local/bin/verible-verilog-ls" + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=0, + stdout="Verible v0.0-4051-g9fdb4057 (2024-01-01)\nCommit: 9fdb4057", + stderr="", + ) + result = provider._get_or_install_core_dependency() + + assert result == "/usr/local/bin/verible-verilog-ls" + mock_which.assert_called_once_with("verible-verilog-ls") + mock_run.assert_called_once() + assert mock_run.call_args[0][0] == ["/usr/local/bin/verible-verilog-ls", "--version"] + + @pytest.mark.systemverilog + def test_detect_from_path_logs_version(self): + """ + GIVEN verible-verilog-ls is in PATH with version output + WHEN detected + THEN version info is logged + + WHY: Version information helps debug compatibility issues. + Users and developers need to know which verible version is being used. + """ + with tempfile.TemporaryDirectory() as temp_dir: + custom_settings = SolidLSPSettings.CustomLSSettings({}) + provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) + + with patch("shutil.which", return_value="/usr/bin/verible-verilog-ls"): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="Verible v0.0-4051-g9fdb4057", stderr="") + with patch("solidlsp.language_servers.systemverilog_server.log") as mock_log: + result = provider._get_or_install_core_dependency() + + # Verify version check was called + assert mock_run.call_args[0][0] == ["/usr/bin/verible-verilog-ls", "--version"] + # Verify version was logged + assert mock_log.info.called + log_message = mock_log.info.call_args[0][0] + assert "Verible v0.0-4051" in log_message + assert result == "/usr/bin/verible-verilog-ls" + + @pytest.mark.systemverilog + def test_detect_from_path_handles_version_failure_gracefully(self): + """ + GIVEN verible-verilog-ls is in PATH but --version fails (returncode=1) + WHEN detected + THEN it still returns the system path (graceful degradation) + + WHY: Some verible builds might not support --version or have different flags. + Detection should not fail just because version check fails - the binary + might still work fine for LSP operations. + """ + with tempfile.TemporaryDirectory() as temp_dir: + custom_settings = SolidLSPSettings.CustomLSSettings({}) + provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) + + with patch("shutil.which", return_value="/custom/bin/verible-verilog-ls"): + with patch("subprocess.run") as mock_run: + # Version check fails + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="Unknown option: --version") + result = provider._get_or_install_core_dependency() + + # Should still return the path despite version check failure + assert result == "/custom/bin/verible-verilog-ls" + + @pytest.mark.systemverilog + def test_detect_from_path_handles_version_timeout_gracefully(self): + """ + GIVEN verible-verilog-ls is in PATH but --version times out + WHEN detected + THEN it still returns the system path (graceful degradation) + + WHY: Version check has a timeout to avoid hanging. If it times out, + we should still use the detected binary. + """ + with tempfile.TemporaryDirectory() as temp_dir: + custom_settings = SolidLSPSettings.CustomLSSettings({}) + provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) + + with patch("shutil.which", return_value="/opt/verible/bin/verible-verilog-ls"): + with patch("subprocess.run") as mock_run: + # Version check times out + mock_run.side_effect = subprocess.TimeoutExpired(cmd=["verible-verilog-ls", "--version"], timeout=5) + result = provider._get_or_install_core_dependency() + + # Should still return the path despite timeout + assert result == "/opt/verible/bin/verible-verilog-ls" + + @pytest.mark.systemverilog + def test_error_message_when_not_found_anywhere(self): + """ + GIVEN verible is NOT in PATH AND platform is unsupported + WHEN _get_or_install_core_dependency is called + THEN raises FileNotFoundError with helpful installation instructions + + WHY: Users need clear guidance on how to install verible when it's missing. + Error message should mention conda, Homebrew, and GitHub releases. + """ + with tempfile.TemporaryDirectory() as temp_dir: + custom_settings = SolidLSPSettings.CustomLSSettings({}) + provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) + + with patch("shutil.which", return_value=None): + # Mock RuntimeDependencyCollection to raise RuntimeError for unsupported platform + with patch("solidlsp.language_servers.systemverilog_server.RuntimeDependencyCollection") as mock_deps_class: + mock_deps = Mock() + mock_deps.get_single_dep_for_current_platform.side_effect = RuntimeError("Unsupported platform") + mock_deps_class.return_value = mock_deps + + with pytest.raises(FileNotFoundError) as exc_info: + provider._get_or_install_core_dependency() + + error_message = str(exc_info.value) + # Error should mention installation methods + assert "conda" in error_message.lower() + assert "Homebrew" in error_message or "brew" in error_message.lower() + assert "GitHub" in error_message or "github.com" in error_message.lower() + assert "verible" in error_message.lower() + + @pytest.mark.systemverilog + def test_downloads_when_not_in_path(self): + """ + GIVEN verible is NOT in PATH AND platform IS supported AND binary exists after download + WHEN _get_or_install_core_dependency is called + THEN returns the downloaded executable path + + WHY: When verible is not installed system-wide and platform is supported, + Serena should auto-download it. This enables zero-setup experience. + """ + with tempfile.TemporaryDirectory() as temp_dir: + custom_settings = SolidLSPSettings.CustomLSSettings({}) + provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) + + expected_path = os.path.join(temp_dir, "verible-ls", f"verible-{DEFAULT_VERIBLE_VERSION}", "bin", "verible-verilog-ls") + + with patch("shutil.which", return_value=None): + with patch("solidlsp.language_servers.systemverilog_server.RuntimeDependencyCollection") as mock_deps_class: + # Create mock dependency and collection + mock_dep = Mock() + mock_dep.url = "https://github.com/chipsalliance/verible/releases/download/v0.0-4051/verible.tar.gz" + + mock_deps = Mock() + mock_deps.get_single_dep_for_current_platform.return_value = mock_dep + mock_deps.binary_path.return_value = expected_path + mock_deps.install.return_value = expected_path + + mock_deps_class.return_value = mock_deps + + with patch("os.path.exists") as mock_exists: + # Before download: binary doesn't exist yet → after download: binary exists + mock_exists.side_effect = [False, True] + + with patch("os.chmod"): + result = provider._get_or_install_core_dependency() + + assert result == expected_path + mock_deps.install.assert_called_once() + + @pytest.mark.systemverilog + def test_detection_prefers_path_over_download(self): + """ + GIVEN verible is in PATH AND download would also work + WHEN _get_or_install_core_dependency is called + THEN PATH version is used (download never attempted) + + WHY: System-installed verible should always take precedence. + This respects user's environment and avoids unnecessary downloads. + """ + with tempfile.TemporaryDirectory() as temp_dir: + custom_settings = SolidLSPSettings.CustomLSSettings({}) + provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) + + with patch("shutil.which", return_value="/usr/bin/verible-verilog-ls"): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="Verible v0.0-4051", stderr="") + + with patch("solidlsp.language_servers.systemverilog_server.RuntimeDependencyCollection") as mock_deps_class: + result = provider._get_or_install_core_dependency() + + # RuntimeDependencyCollection should never be instantiated + mock_deps_class.assert_not_called() + + assert result == "/usr/bin/verible-verilog-ls" + + @pytest.mark.systemverilog + def test_download_fails_if_binary_not_found_after_install(self): + """ + GIVEN verible is NOT in PATH AND platform IS supported + WHEN download completes BUT binary still doesn't exist at expected path + THEN raises FileNotFoundError + + WHY: If download/extraction fails silently, we should catch it and report clearly. + """ + with tempfile.TemporaryDirectory() as temp_dir: + custom_settings = SolidLSPSettings.CustomLSSettings({}) + provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) + + expected_path = os.path.join(temp_dir, "verible-ls", f"verible-{DEFAULT_VERIBLE_VERSION}", "bin", "verible-verilog-ls") + + with patch("shutil.which", return_value=None): + with patch("solidlsp.language_servers.systemverilog_server.RuntimeDependencyCollection") as mock_deps_class: + mock_dep = Mock() + mock_deps = Mock() + mock_deps.get_single_dep_for_current_platform.return_value = mock_dep + mock_deps.binary_path.return_value = expected_path + mock_deps.install.return_value = expected_path + mock_deps_class.return_value = mock_deps + + # Binary never appears after install + with patch("os.path.exists", return_value=False): + with pytest.raises(FileNotFoundError) as exc_info: + provider._get_or_install_core_dependency() + + error_message = str(exc_info.value) + assert "verible-verilog-ls not found" in error_message + assert expected_path in error_message + + @pytest.mark.systemverilog + def test_uses_already_downloaded_binary_without_reinstalling(self): + """ + GIVEN verible is NOT in PATH AND platform IS supported + AND binary already exists at download location + WHEN _get_or_install_core_dependency is called + THEN returns existing path without downloading again + + WHY: Avoid redundant downloads if verible was already downloaded in previous session. + This speeds up subsequent runs. + """ + with tempfile.TemporaryDirectory() as temp_dir: + custom_settings = SolidLSPSettings.CustomLSSettings({}) + provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) + + expected_path = os.path.join(temp_dir, "verible-ls", f"verible-{DEFAULT_VERIBLE_VERSION}", "bin", "verible-verilog-ls") + + with patch("shutil.which", return_value=None): + with patch("solidlsp.language_servers.systemverilog_server.RuntimeDependencyCollection") as mock_deps_class: + mock_dep = Mock() + mock_deps = Mock() + mock_deps.get_single_dep_for_current_platform.return_value = mock_dep + mock_deps.binary_path.return_value = expected_path + mock_deps_class.return_value = mock_deps + + # Binary already exists + with patch("os.path.exists", return_value=True): + with patch("os.chmod"): + result = provider._get_or_install_core_dependency() + + # Should NOT call install since binary already exists + mock_deps.install.assert_not_called() + assert result == expected_path + + +class TestVeribleVerilogLsDetectionIntegration: + """ + Integration tests that verify detection works on the current system. + These tests are skipped if verible-verilog-ls is not installed. + """ + + @pytest.mark.systemverilog + def test_integration_finds_installed_verible(self): + """ + GIVEN verible-verilog-ls is installed on this system (via any method) + WHEN _get_or_install_core_dependency is called + THEN it returns a valid executable path + + This test verifies the detection logic works end-to-end on the current system. + """ + # Skip if verible-verilog-ls is not installed + if not shutil.which("verible-verilog-ls"): + pytest.skip("verible-verilog-ls not installed on this system") + + with tempfile.TemporaryDirectory() as temp_dir: + custom_settings = SolidLSPSettings.CustomLSSettings({}) + provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) + + result = provider._get_or_install_core_dependency() + + assert result is not None + assert os.path.isfile(result) + assert os.access(result, os.X_OK) diff --git a/test/solidlsp/systemverilog/test_systemverilog_diagnostics.py b/test/solidlsp/systemverilog/test_systemverilog_diagnostics.py new file mode 100644 index 0000000..3de588c --- /dev/null +++ b/test/solidlsp/systemverilog/test_systemverilog_diagnostics.py @@ -0,0 +1,22 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled +from test.solidlsp.util.diagnostics import assert_file_diagnostics + +pytestmark = pytest.mark.skipif( + not language_tests_enabled(Language.SYSTEMVERILOG), reason="SystemVerilog tests are disabled (verible-verilog-ls not available)" +) + + +@pytest.mark.systemverilog +class TestSystemverilogDiagnostics: + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.sv", + (), + min_count=1, + ) diff --git a/test/solidlsp/terraform/test_terraform_basic.py b/test/solidlsp/terraform/test_terraform_basic.py new file mode 100644 index 0000000..1a91f0c --- /dev/null +++ b/test/solidlsp/terraform/test_terraform_basic.py @@ -0,0 +1,72 @@ +""" +Basic integration tests for the Terraform language server functionality. + +These tests validate the functionality of the language server APIs +like request_references using the test repository. +""" + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from test.conftest import language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.skipif(not language_tests_enabled(Language.TERRAFORM), reason="Terraform tests are disabled (terraform CLI not available)") +@pytest.mark.terraform +class TestLanguageServerBasics: + """Test basic functionality of the Terraform language server.""" + + @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True) + def test_basic_definition(self, language_server: SolidLanguageServer) -> None: + """Test basic definition lookup functionality.""" + # Simple test to verify the language server is working + file_path = "main.tf" + # Just try to get document symbols - this should work without hanging + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + assert len(symbols) > 0, "Should find at least some symbols in main.tf" + + @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True) + def test_request_references_aws_instance(self, language_server: SolidLanguageServer) -> None: + """Test request_references on an aws_instance resource.""" + # Get references to an aws_instance resource in main.tf + file_path = "main.tf" + # Find aws_instance resources + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + aws_instance_symbol = next((s for s in symbols[0] if s.get("name") == 'resource "aws_instance" "web_server"'), None) + if not aws_instance_symbol or "selectionRange" not in aws_instance_symbol: + raise AssertionError("aws_instance symbol or its selectionRange not found") + sel_start = aws_instance_symbol["selectionRange"]["start"] + references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert len(references) >= 1, "aws_instance should be referenced at least once" + + @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True) + def test_request_references_variable(self, language_server: SolidLanguageServer) -> None: + """Test request_references on a variable.""" + # Get references to a variable in variables.tf + file_path = "variables.tf" + # Find variable definitions + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + var_symbol = next((s for s in symbols[0] if s.get("name") == 'variable "instance_type"'), None) + if not var_symbol or "selectionRange" not in var_symbol: + raise AssertionError("variable symbol or its selectionRange not found") + sel_start = var_symbol["selectionRange"]["start"] + references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert len(references) >= 1, "variable should be referenced at least once" + + @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if s["kind"] == SymbolKind.Class: + continue + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/terraform/test_terraform_diagnostics.py b/test/solidlsp/terraform/test_terraform_diagnostics.py new file mode 100644 index 0000000..318cc7f --- /dev/null +++ b/test/solidlsp/terraform/test_terraform_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.terraform +class TestTerraformDiagnostics: + @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.tf", + (), + min_count=1, + ) diff --git a/test/solidlsp/test_is_ignored_path_missing.py b/test/solidlsp/test_is_ignored_path_missing.py new file mode 100644 index 0000000..56afc5e --- /dev/null +++ b/test/solidlsp/test_is_ignored_path_missing.py @@ -0,0 +1,196 @@ +"""Unit tests: missing-path handling in ``SolidLanguageServer``. + +``is_ignored_path`` must not raise on missing paths, and symbol locations resolving to +non-existent files must be skipped. Language servers may report locations of generated +files that are not present on disk, e.g. JDTLS reporting a Lombok-generated +``LombokModel$LombokModelBuilder.class`` under ``target/classes``; the ignore check must +classify such paths instead of raising ``FileNotFoundError``. + +No language markers: these use a local test double and run in catch-all. +""" + +from __future__ import annotations + +from pathlib import Path + +import pathspec +import pytest + +from solidlsp import SolidLanguageServer, ls_types +from solidlsp.ls_config import Language +from solidlsp.ls_utils import PathUtils + + +class _IgnoredPathServer(SolidLanguageServer): + """Minimal test double exposing only :meth:`is_ignored_path` dependencies.""" + + def __init__( + self, + root: Path, + language: Language, + *, + ignored_dirnames: tuple[str, ...] = (), + ignore_lines: tuple[str, ...] = (), + ) -> None: + self.repository_root_path = str(root) + self.language = language + self._ignored_dirnames = frozenset(ignored_dirnames) + self._ignore_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, ignore_lines) + + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in self._ignored_dirnames + + def _start_server(self) -> None: + raise AssertionError("The test double must not start a language server") + + def _create_base_initialize_params(self) -> dict[str, object]: + raise AssertionError("The test double must not build initialize params") + + +def test_missing_lombok_class_under_target_is_ignored_not_raised(tmp_path: Path) -> None: + """JDTLS-style missing build artifact under target/classes -- must be ignored, never raise.""" + ls = _IgnoredPathServer(tmp_path, Language.JAVA) + missing = "target/classes/test_repo/LombokModel$LombokModelBuilder.class" + + assert not (tmp_path / missing).exists() + assert ls.is_ignored_path(missing) is True + + +def test_missing_unsupported_extension_is_ignored(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON) + missing = "src/foo.pyc" + assert not (tmp_path / missing).exists() + assert ls.is_ignored_path(missing) is True + + +def test_missing_source_file_not_in_ignored_dir_is_not_ignored(tmp_path: Path) -> None: + """A missing source path classifies as not-ignored.""" + ls = _IgnoredPathServer(tmp_path, Language.PYTHON) + missing = "src/app.py" + assert not (tmp_path / missing).exists() + assert ls.is_ignored_path(missing) is False + + +def test_missing_source_file_under_ignored_dirname_is_ignored(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON, ignored_dirnames=("generated",)) + missing = "generated/app.py" + assert not (tmp_path / missing).exists() + assert ls.is_ignored_path(missing) is True + + +def test_missing_directory_with_ignored_dirname_leaf_is_ignored(tmp_path: Path) -> None: + """The leaf of a missing suffixless path may denote a directory and is checked against ignored dirnames.""" + ls = _IgnoredPathServer(tmp_path, Language.PYTHON, ignored_dirnames=("generated",)) + assert ls.is_ignored_path("generated", ignore_unsupported_files=False) is True + assert ls.is_ignored_path("src/generated", ignore_unsupported_files=False) is True + assert ls.is_ignored_path(".venv", ignore_unsupported_files=False) is True + + +def test_missing_extensionless_path_is_treated_as_directory(tmp_path: Path) -> None: + """A missing suffixless path is not subject to the unsupported-extension rule for files.""" + ls = _IgnoredPathServer(tmp_path, Language.PYTHON) + assert ls.is_ignored_path("newpkg") is False + + +def test_missing_ignored_by_pathspec_is_ignored(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON, ignore_lines=("generated.py",)) + missing = "generated.py" + assert not (tmp_path / missing).exists() + assert ls.is_ignored_path(missing) is True + + +def test_missing_unsupported_extension_can_be_allowed(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON) + missing = "src/foo.pyc" + assert not (tmp_path / missing).exists() + assert ls.is_ignored_path(missing, ignore_unsupported_files=False) is False + + +def test_existing_path_under_ignored_dirname_is_ignored(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON, ignored_dirnames=("generated",)) + source_file = tmp_path / "generated" / "app.py" + source_file.parent.mkdir(parents=True) + source_file.write_text("x = 1\n") + assert ls.is_ignored_path("generated/app.py") is True + + +def test_existing_source_file_is_not_ignored(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON) + src = tmp_path / "pkg" / "mod.py" + src.parent.mkdir(parents=True) + src.write_text("x = 1\n") + assert ls.is_ignored_path("pkg/mod.py") is False + + +@pytest.mark.parametrize( + "rel", + [ + "target/classes/test_repo/LombokModel$LombokModelBuilder.class", + "build/classes/Foo.class", + "out/production/Foo.class", + ], +) +def test_java_build_output_paths_never_raise(tmp_path: Path, rel: str) -> None: + """Compiled-class paths classify as ignored via the unsupported-extension rule, present or not + (build dirnames are deliberately not hard-ignored for Java). + """ + ls = _IgnoredPathServer(tmp_path, Language.JAVA) + # neither present nor absent should raise + assert ls.is_ignored_path(rel) is True + p = tmp_path / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(b"") + assert ls.is_ignored_path(rel) is True + + +class _StubSymbolLocationRequest(SolidLanguageServer.SymbolLocationRequest): + """Minimal concrete subclass exposing only :meth:`convert_location_item` dependencies.""" + + def send_request(self) -> object | None: + raise AssertionError("The test double must not send LSP requests") + + def normalize_response(self, response: object | None) -> list[ls_types.Location]: + raise AssertionError("The test double must not normalize LSP responses") + + +def _location_request(ls: SolidLanguageServer) -> SolidLanguageServer.SymbolLocationRequest: + return _StubSymbolLocationRequest(ls, "src/app.py", 0, 0, request_name="test_request") + + +def _location_item(path: Path) -> dict: + zero = {"line": 0, "character": 0} + return {"uri": PathUtils.path_to_uri(str(path)), "range": {"start": zero, "end": zero}} + + +def test_location_at_missing_path_is_skipped(tmp_path: Path) -> None: + """Locations whose absolute path is not on disk (e.g. LS-reported build artifacts) are dropped.""" + ls = _IgnoredPathServer(tmp_path, Language.JAVA) + missing = tmp_path / "target" / "classes" / "LombokModel$LombokModelBuilder.class" + assert not missing.exists() + assert _location_request(ls).convert_location_item(_location_item(missing)) is None + + +def test_location_at_missing_source_path_is_skipped(tmp_path: Path) -> None: + """A missing path that is not ignored is dropped by the existence check alone.""" + ls = _IgnoredPathServer(tmp_path, Language.PYTHON) + assert ls.is_ignored_path("pkg/mod.py") is False + assert _location_request(ls).convert_location_item(_location_item(tmp_path / "pkg" / "mod.py")) is None + + +def test_location_at_existing_ignored_path_is_skipped(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON, ignored_dirnames=("generated",)) + source_file = tmp_path / "generated" / "app.py" + source_file.parent.mkdir(parents=True) + source_file.write_text("x = 1\n") + assert _location_request(ls).convert_location_item(_location_item(source_file)) is None + + +def test_location_at_existing_source_path_is_converted(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON) + source_file = tmp_path / "pkg" / "mod.py" + source_file.parent.mkdir(parents=True) + source_file.write_text("x = 1\n") + location = _location_request(ls).convert_location_item(_location_item(source_file)) + assert location is not None + assert Path(location["absolutePath"]) == source_file + assert Path(location["relativePath"]) == Path("pkg/mod.py") diff --git a/test/solidlsp/test_ls_common.py b/test/solidlsp/test_ls_common.py new file mode 100644 index 0000000..fab8f21 --- /dev/null +++ b/test/solidlsp/test_ls_common.py @@ -0,0 +1,78 @@ +import os +from collections.abc import Sequence + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.conftest import PYTHON_LANGUAGE_BACKENDS, start_ls_context + + +class TestLanguageServerCommonFunctionality: + """Test common functionality of SolidLanguageServer base implementation (not language-specific behaviour).""" + + @pytest.mark.parametrize("language_server", PYTHON_LANGUAGE_BACKENDS, indirect=True) + def test_open_file_cache_invalidate(self, language_server: SolidLanguageServer) -> None: + """ + Tests that the file buffer cache is invalidated when the file is changed on disk. + """ + file_path = os.path.join(language_server.repository_root_path, "test_open_file.py") + test_string1 = "# foo" + test_string2 = "# bar" + + with open(file_path, "w") as f: + f.write(test_string1) + + try: + with language_server.open_file(file_path) as fb: + assert fb.contents == test_string1 + + # apply external change to file + with open(file_path, "w") as f: + f.write(test_string2) + + # Explicitly bump mtime into the future so the cache sees a change. + # Relying on natural mtime advancement is flaky because many filesystems + # (ext4, tmpfs) have only 1-second mtime granularity, and both writes + # can land in the same second. + stat = os.stat(file_path) + os.utime(file_path, (stat.st_atime, stat.st_mtime + 2)) + + # check that the file buffer has been invalidated and reloaded + assert fb.contents == test_string2 + + finally: + os.remove(file_path) + + def test_workspace_folders_affect_full_symbol_tree(self): + """ + Tests that workspace folder configurations are respected when requesting the full symbol tree. + """ + + def check(langsrv: SolidLanguageServer, present: Sequence[str] = (), absent: Sequence[str] = ()) -> None: + symbols = langsrv.request_full_symbol_tree() + for name in present: + assert SymbolUtils.symbol_tree_contains_name(symbols, name) + for name in absent: + assert not SymbolUtils.symbol_tree_contains_name(symbols, name) + + symbols_in_subfolder_test_repo = ["OuterClass", "UserService"] + symbols_in_subfolder_scripts = ["parse_args", "create_sample_users"] + all_symbols = symbols_in_subfolder_test_repo + symbols_in_subfolder_scripts + + with start_ls_context(language=Language.PYTHON, workspace_folders=["."]) as ls: + ls.request_full_symbol_tree() + check(ls, present=all_symbols) + + with start_ls_context(language=Language.PYTHON, workspace_folders=["./test_repo"]) as ls: + ls.request_full_symbol_tree() + check(ls, present=symbols_in_subfolder_test_repo, absent=symbols_in_subfolder_scripts) + + with start_ls_context(language=Language.PYTHON, workspace_folders=["./scripts"]) as ls: + ls.request_full_symbol_tree() + check(ls, present=symbols_in_subfolder_scripts, absent=symbols_in_subfolder_test_repo) + + # test that explicit requests for symbols in other workspace folders are rejected + with pytest.raises(ValueError, match="outside"): + ls.request_full_symbol_tree(within_relative_path="./test_repo") diff --git a/test/solidlsp/test_ls_config.py b/test/solidlsp/test_ls_config.py new file mode 100644 index 0000000..680bb39 --- /dev/null +++ b/test/solidlsp/test_ls_config.py @@ -0,0 +1,46 @@ +from solidlsp.ls_config import FilenameMatcher + + +class TestFilenameMatcherCaseSensitivity: + """Regression tests for FilenameMatcher case-sensitivity handling. + + ``is_relevant_filename`` and ``string_contains_relevant_filename`` must apply the same + case-folding rule: fold to lower case only when ``case_sensitive=False``. A previously inverted + check in ``string_contains_relevant_filename`` folded the input when ``case_sensitive=True`` + (and left it unfolded when ``case_sensitive=False``), so both modes could mismatch. + """ + + def test_case_insensitive_matches_uppercase_input(self) -> None: + matcher = FilenameMatcher(".py", ".pyi", case_sensitive=False) + # Uppercase / mixed-case input must match a lower-case extension when case-insensitive. + assert matcher.is_relevant_filename("FOO.PY") + assert matcher.is_relevant_filename("Foo.PyI") + assert matcher.string_contains_relevant_filename("opened FOO.PY for editing") + assert matcher.string_contains_relevant_filename("see Foo.PyI:10") + + def test_case_sensitive_respects_extension_case(self) -> None: + # R uses case-significant extensions: .R and .r are distinct. + matcher = FilenameMatcher(".R", case_sensitive=True) + assert matcher.is_relevant_filename("script.R") + assert not matcher.is_relevant_filename("script.r") + # The same must hold for the substring variant (previously inverted). + assert matcher.string_contains_relevant_filename("edited script.R just now") + assert not matcher.string_contains_relevant_filename("edited script.r just now") + + def test_two_methods_agree_on_case_folding(self) -> None: + """The two matcher methods must never disagree on a filename that ends the string.""" + for case_sensitive in (True, False): + for extensions in ((".py",), (".R", ".Rmd"), (".ts", ".tsx")): + matcher = FilenameMatcher(*extensions, case_sensitive=case_sensitive) + for candidate in ("main.py", "Main.PY", "app.R", "app.r", "x.ts", "X.TSX"): + assert matcher.is_relevant_filename(candidate) == matcher.string_contains_relevant_filename(candidate), ( + f"disagreement for {candidate!r} with extensions={extensions} case_sensitive={case_sensitive}" + ) + + def test_string_contains_requires_complete_extension(self) -> None: + """A registered extension must be a *complete* extension, not a prefix of a longer word.""" + matcher = FilenameMatcher(".py", case_sensitive=False) + assert matcher.string_contains_relevant_filename("run main.py now") + assert matcher.string_contains_relevant_filename("main.py") + # ".py" embedded in ".python" is not a complete extension occurrence. + assert not matcher.string_contains_relevant_filename("file.python") diff --git a/test/solidlsp/test_lsp_protocol_handler_server.py b/test/solidlsp/test_lsp_protocol_handler_server.py new file mode 100644 index 0000000..f8ce02c --- /dev/null +++ b/test/solidlsp/test_lsp_protocol_handler_server.py @@ -0,0 +1,282 @@ +""" +Tests for JSON-RPC 2.0 params field handling in LSP protocol. + +These tests verify the correct handling of the params field in LSP requests and notifications, +specifically ensuring: +- Void-type methods (shutdown, exit) omit params field entirely +- Methods with explicit params include them unchanged +- Methods with None params receive params: {} for Delphi/FPC compatibility + +Reference: JSON-RPC 2.0 spec - params field is optional but must be object/array when present. +""" + +from typing import Any + +import pytest + +from solidlsp.lsp_protocol_handler.server import make_notification, make_request + +# ============================================================================= +# Shared Assertion Helpers (DRY extraction per AI Panel recommendation) +# ============================================================================= + + +def assert_jsonrpc_structure( + result: dict[str, Any], + expected_method: str, + expected_keys: set[str], + *, + expected_id: Any | None = None, +) -> None: + """Verify JSON-RPC 2.0 structural requirements with 5-point error messages. + + Args: + result: The dict returned by make_request/make_notification + expected_method: The method name that should be in the result + expected_keys: Exact set of keys that should be present + expected_id: If provided, verify the id field matches (for requests) + + """ + # Verify jsonrpc field + assert "jsonrpc" in result, ( + f"STRUCTURE ERROR: Missing required 'jsonrpc' field.\n" + f"Expected: jsonrpc='2.0'\n" + f"Actual keys: {list(result.keys())}\n" + f"GUIDANCE: All JSON-RPC 2.0 messages must include jsonrpc field." + ) + assert result["jsonrpc"] == "2.0", ( + f"STRUCTURE ERROR: Invalid jsonrpc version.\n" + f"Expected: '2.0'\n" + f"Actual: {result['jsonrpc']!r}\n" + f"GUIDANCE: JSON-RPC 2.0 requires jsonrpc='2.0' exactly." + ) + + # Verify method field + assert "method" in result, ( + f"STRUCTURE ERROR: Missing required 'method' field.\n" + f"Expected: method='{expected_method}'\n" + f"Actual keys: {list(result.keys())}\n" + f"GUIDANCE: All requests/notifications must include method field." + ) + assert result["method"] == expected_method, ( + f"STRUCTURE ERROR: Method mismatch.\n" + f"Expected: '{expected_method}'\n" + f"Actual: {result['method']!r}\n" + f"GUIDANCE: Method field must match the requested method name." + ) + + # Verify id field if expected (requests only) + if expected_id is not None: + assert "id" in result, ( + f"STRUCTURE ERROR: Missing required 'id' field for request.\n" + f"Expected: id={expected_id!r}\n" + f"Actual keys: {list(result.keys())}\n" + f"GUIDANCE: JSON-RPC 2.0 requests must include id field." + ) + assert result["id"] == expected_id, ( + f"STRUCTURE ERROR: Request ID mismatch.\n" + f"Expected: {expected_id!r}\n" + f"Actual: {result['id']!r}\n" + f"GUIDANCE: Request ID must be preserved exactly as provided." + ) + + # Verify exact key set + actual_keys = set(result.keys()) + if actual_keys != expected_keys: + extra = sorted(actual_keys - expected_keys) + missing = sorted(expected_keys - actual_keys) + pytest.fail( + f"STRUCTURE ERROR: Key set mismatch for method '{expected_method}'.\n" + f"Expected keys: {sorted(expected_keys)}\n" + f"Actual keys: {sorted(actual_keys)}\n" + f"Extra keys: {extra}\n" + f"Missing keys: {missing}\n" + f"GUIDANCE: Verify key construction logic for Void-type vs normal methods." + ) + + +def assert_params_omitted(result: dict[str, Any], method: str, req_id: str, input_params: Any = None) -> None: + """Assert that params field is NOT present (for Void-type methods). + + Args: + result: The dict returned by make_request/make_notification + method: Method name for error message context + req_id: Requirement ID (e.g., 'REQ-1', 'REQ-AI-PANEL-GAP') + input_params: If provided, shows what params were passed (for explicit params tests) + + """ + if "params" in result: + input_note = f"\nInput params: {input_params}" if input_params is not None else "" + pytest.fail( + f"{req_id} VIOLATED: {method} method MUST omit params field entirely.{input_note}\n" + f"Expected: No 'params' key in result\n" + f"Actual: params={result.get('params')!r}\n" + f"Actual keys: {list(result.keys())}\n" + f"REASON: HLS/rust-analyzer Void types reject any params field (even empty object).\n" + f"GUIDANCE: Void-type constraint takes precedence - implementation must omit params entirely." + ) + + +def assert_params_equal(result: dict[str, Any], expected_params: Any, req_id: str) -> None: + """Assert that params field equals expected value. + + Args: + result: The dict returned by make_request/make_notification + expected_params: The exact params value expected + req_id: Requirement ID for error message context + + """ + if "params" not in result: + pytest.fail( + f"{req_id} VIOLATED: params field missing.\n" + f"Expected: params={expected_params!r}\n" + f"Actual keys: {list(result.keys())}\n" + f"GUIDANCE: Non-Void methods must include params field." + ) + if result["params"] != expected_params: + pytest.fail( + f"{req_id} VIOLATED: params value mismatch.\n" + f"Expected: {expected_params!r}\n" + f"Actual: {result['params']!r}\n" + f"GUIDANCE: Params must be included exactly as provided (or {{}} for None)." + ) + + +class TestMakeNotificationParamsHandling: + """Test make_notification() params field handling per JSON-RPC 2.0 spec.""" + + def test_shutdown_method_omits_params_entirely(self) -> None: + """REQ-1: Void-type method 'shutdown' MUST omit params field entirely.""" + result = make_notification("shutdown", None) + assert_jsonrpc_structure(result, "shutdown", {"jsonrpc", "method"}) + assert_params_omitted(result, "shutdown", "REQ-1") + + def test_exit_method_omits_params_entirely(self) -> None: + """REQ-1: Void-type method 'exit' MUST omit params field entirely.""" + result = make_notification("exit", None) + assert_jsonrpc_structure(result, "exit", {"jsonrpc", "method"}) + assert_params_omitted(result, "exit", "REQ-1") + + def test_notification_with_explicit_params_dict(self) -> None: + """REQ-2: Methods with explicit params MUST include them unchanged.""" + test_params = {"uri": "file:///test.py", "languageId": "python"} + result = make_notification("textDocument/didOpen", test_params) + assert_jsonrpc_structure(result, "textDocument/didOpen", {"jsonrpc", "method", "params"}) + assert_params_equal(result, test_params, "REQ-2") + + def test_notification_with_explicit_params_list(self) -> None: + """REQ-2: Methods with explicit params (list) MUST include them unchanged.""" + test_params = ["arg1", "arg2", "arg3"] + result = make_notification("custom/method", test_params) + assert_jsonrpc_structure(result, "custom/method", {"jsonrpc", "method", "params"}) + assert_params_equal(result, test_params, "REQ-2") + + def test_notification_with_none_params_sends_empty_dict(self) -> None: + """REQ-3: Methods with None params MUST send params: {} (Delphi/FPC compat).""" + result = make_notification("textDocument/didChange", None) + assert_jsonrpc_structure(result, "textDocument/didChange", {"jsonrpc", "method", "params"}) + assert_params_equal(result, {}, "REQ-3") + + def test_notification_with_empty_dict_params(self) -> None: + """REQ-2: Explicit empty dict params MUST be included unchanged.""" + result = make_notification("custom/notify", {}) + assert_jsonrpc_structure(result, "custom/notify", {"jsonrpc", "method", "params"}) + assert_params_equal(result, {}, "REQ-2") + + +class TestMakeRequestParamsHandling: + """Test make_request() params field handling per JSON-RPC 2.0 spec.""" + + def test_shutdown_request_omits_params_entirely(self) -> None: + """REQ-1: Void-type method 'shutdown' MUST omit params field entirely (requests).""" + result = make_request("shutdown", request_id=1, params=None) + assert_jsonrpc_structure(result, "shutdown", {"jsonrpc", "method", "id"}, expected_id=1) + assert_params_omitted(result, "shutdown", "REQ-1") + + def test_request_with_explicit_params_dict(self) -> None: + """REQ-2: Requests with explicit params MUST include them unchanged.""" + test_params = {"textDocument": {"uri": "file:///test.py"}, "position": {"line": 10, "character": 5}} + result = make_request("textDocument/hover", request_id=42, params=test_params) + assert_jsonrpc_structure(result, "textDocument/hover", {"jsonrpc", "method", "id", "params"}, expected_id=42) + assert_params_equal(result, test_params, "REQ-2") + + def test_request_with_none_params_sends_empty_dict(self) -> None: + """REQ-3: Requests with None params MUST send params: {} (Delphi/FPC compat).""" + result = make_request("workspace/configuration", request_id=100, params=None) + assert_jsonrpc_structure(result, "workspace/configuration", {"jsonrpc", "method", "id", "params"}, expected_id=100) + assert_params_equal(result, {}, "REQ-3") + + def test_request_id_preservation(self) -> None: + """Verify request_id is correctly included in result (string ID).""" + test_id = "unique-request-123" + result = make_request("custom/request", request_id=test_id, params={"key": "value"}) + assert_jsonrpc_structure(result, "custom/request", {"jsonrpc", "method", "id", "params"}, expected_id=test_id) + + def test_request_with_explicit_params_list(self) -> None: + """REQ-2: Requests with explicit params (list) MUST include them unchanged.""" + test_params = [1, 2, 3] + result = make_request("custom/sum", request_id=99, params=test_params) + assert_jsonrpc_structure(result, "custom/sum", {"jsonrpc", "method", "id", "params"}, expected_id=99) + assert_params_equal(result, test_params, "REQ-2") + + +class TestVoidMethodsExhaustive: + """Test all methods that should be treated as Void-type (no params).""" + + def test_shutdown_request_ignores_explicit_params_dict(self) -> None: + """REQ-AI-PANEL-GAP: shutdown MUST omit params even when caller explicitly provides params.""" + explicit_params = {"key": "value", "another": "param"} + result = make_request("shutdown", request_id=1, params=explicit_params) + assert_jsonrpc_structure(result, "shutdown", {"jsonrpc", "method", "id"}, expected_id=1) + assert_params_omitted(result, "shutdown", "REQ-AI-PANEL-GAP", input_params=explicit_params) + + def test_exit_notification_ignores_explicit_params(self) -> None: + """REQ-AI-PANEL-GAP: exit MUST omit params even when caller explicitly provides params.""" + explicit_params = {"unexpected": "params"} + result = make_notification("exit", explicit_params) + assert_jsonrpc_structure(result, "exit", {"jsonrpc", "method"}) + assert_params_omitted(result, "exit", "REQ-AI-PANEL-GAP", input_params=explicit_params) + + def test_only_shutdown_and_exit_are_void_methods(self) -> None: + """REQ-BOUNDARY: Verify EXACTLY shutdown/exit are Void-type - no more, no less.""" + # Positive verification: shutdown and exit MUST omit params + shutdown_notif = make_notification("shutdown", None) + exit_notif = make_notification("exit", None) + shutdown_req = make_request("shutdown", 1, None) + + assert "params" not in shutdown_notif, "shutdown notification should omit params" + assert "params" not in exit_notif, "exit notification should omit params" + assert "params" not in shutdown_req, "shutdown request should omit params" + + # Negative verification: other methods MUST include params (even when None -> {}) + non_void_methods = [ + "initialize", + "initialized", + "textDocument/didOpen", + "textDocument/didChange", + "textDocument/didClose", + "workspace/didChangeConfiguration", + "workspace/didChangeWatchedFiles", + ] + + for method in non_void_methods: + result_notif = make_notification(method, None) + result_req = make_request(method, 1, None) + + if "params" not in result_notif: + pytest.fail( + f"BOUNDARY VIOLATION: '{method}' notification treated as Void-type.\n" + f"Expected: params field present (should be {{}})\n" + f"Actual keys: {list(result_notif.keys())}\n" + f"GUIDANCE: Only 'shutdown' and 'exit' should omit params field." + ) + assert_params_equal(result_notif, {}, f"REQ-3 ({method} notification)") + + if "params" not in result_req: + pytest.fail( + f"BOUNDARY VIOLATION: '{method}' request treated as Void-type.\n" + f"Expected: params field present (should be {{}})\n" + f"Actual keys: {list(result_req.keys())}\n" + f"GUIDANCE: Only 'shutdown' and 'exit' should omit params field." + ) + assert_params_equal(result_req, {}, f"REQ-3 ({method} request)") diff --git a/test/solidlsp/test_rename_didopen.py b/test/solidlsp/test_rename_didopen.py new file mode 100644 index 0000000..f2902d8 --- /dev/null +++ b/test/solidlsp/test_rename_didopen.py @@ -0,0 +1,45 @@ +from unittest.mock import MagicMock + +from solidlsp.ls import SolidLanguageServer + + +class DummyLanguageServer(SolidLanguageServer): + def _start_server(self) -> None: + raise AssertionError("Not used in this test") + + def _create_base_initialize_params(self) -> dict: + return {} + + +def test_request_rename_symbol_edit_opens_file_before_rename(tmp_path) -> None: + (tmp_path / "index.ts").write_text("export const x = 1;\n", encoding="utf-8") + + events: list[str] = [] + + notify = MagicMock() + notify.did_open_text_document.side_effect = lambda *_args, **_kwargs: events.append("didOpen") + notify.did_close_text_document.side_effect = lambda *_args, **_kwargs: events.append("didClose") + + send = MagicMock() + send.rename.side_effect = lambda *_args, **_kwargs: events.append("rename") + + server = MagicMock() + server.notify = notify + server.send = send + + language_server = object.__new__(DummyLanguageServer) + language_server.repository_root_path = str(tmp_path) + language_server.server_started = True + language_server.open_file_buffers = {} + language_server._encoding = "utf-8" + language_server.language_id = "typescript" + language_server.server = server + + result = language_server.request_rename_symbol_edit( + relative_file_path="index.ts", + line=0, + column=0, + new_name="y", + ) + assert result is None + assert events == ["didOpen", "rename", "didClose"] diff --git a/test/solidlsp/test_typescript_timeout_policy.py b/test/solidlsp/test_typescript_timeout_policy.py new file mode 100644 index 0000000..f88ae17 --- /dev/null +++ b/test/solidlsp/test_typescript_timeout_policy.py @@ -0,0 +1,269 @@ +"""Regression tests for the TypeScript/Svelte timeout policy split. + +The base TypeScript server keeps its historical permissive behavior on readiness and +indexing timeouts (log and proceed), while the Svelte companion TS server is strict +(raise instead of serving requests from a cold or partially indexed program). These +tests pin that policy and the settings plumbing directly, without spawning language +server processes, so a future refactor cannot silently revert either side. +""" + +import threading +import time + +import pytest + +from solidlsp.language_servers.svelte_language_server import ( + SvelteCompanionPreparationError, + SvelteLanguageServer, + SvelteTypeScriptServer, +) +from solidlsp.language_servers.typescript_language_server import TypeScriptLanguageServer +from solidlsp.settings import SolidLSPSettings + + +def _bare_ts_server(cls: type[TypeScriptLanguageServer], custom_settings: dict | None = None) -> TypeScriptLanguageServer: + """Create an instance without running __init__ (no process, no repo scan), setting only the + state the timeout machinery touches; same technique as test_rename_didopen.py. + """ + server = object.__new__(cls) + server.server_ready = threading.Event() + server._progress_lock = threading.Lock() + server._active_progress_tokens = set() + server._indexing_complete = threading.Event() + server._indexing_complete.set() # mirrors __init__: initially no active work + server._custom_settings = SolidLSPSettings.CustomLSSettings(custom_settings) + return server + + +class TestBaseTypeScriptTimeoutPolicy: + """The base server must stay permissive: plain TypeScript setups are unchanged.""" + + def test_server_ready_timeout_proceeds(self) -> None: + server = _bare_ts_server(TypeScriptLanguageServer) + assert not server.server_ready.is_set() + + server._handle_server_ready_timeout(10.0) # must not raise + + assert server.server_ready.is_set() + + def test_indexing_timeout_proceeds(self) -> None: + server = _bare_ts_server(TypeScriptLanguageServer) + server.expect_indexing() + server._active_progress_tokens.add("indexing-1") + + server._handle_project_indexing_timeout(30.0) # must not raise + + def test_timeout_defaults(self) -> None: + server = _bare_ts_server(TypeScriptLanguageServer) + assert server._get_server_ready_timeout() == 10.0 + assert server._get_indexing_timeout() == 30.0 + + def test_timeouts_configurable_via_ls_specific_settings(self) -> None: + server = _bare_ts_server(TypeScriptLanguageServer, {"server_ready_timeout": 1.5, "indexing_timeout": 2.5}) + assert server._get_server_ready_timeout() == 1.5 + assert server._get_indexing_timeout() == 2.5 + + +class TestSvelteCompanionTimeoutPolicy: + """The Svelte companion must be strict: raise instead of serving from a cold/partial program.""" + + def test_server_ready_timeout_raises(self) -> None: + server = _bare_ts_server(SvelteTypeScriptServer) + + with pytest.raises(TimeoutError, match="did not become ready within 30s"): + server._handle_server_ready_timeout(30.0) + + assert not server.server_ready.is_set() # a strict server must not fake readiness + + def test_indexing_timeout_raises_with_diagnostic_state(self) -> None: + server = _bare_ts_server(SvelteTypeScriptServer) + server.expect_indexing() + server._active_progress_tokens.add("initializing-js-ts-features") + + with pytest.raises(TimeoutError) as exc_info: + server._handle_project_indexing_timeout(120.0) + + message = str(exc_info.value) + assert "did not complete within 120s" in message + assert "complete=False" in message + assert "initializing-js-ts-features" in message + + def test_companion_timeout_defaults_are_raised(self) -> None: + server = _bare_ts_server(SvelteTypeScriptServer) + assert server._get_server_ready_timeout() == 30.0 + assert server._get_indexing_timeout() == 120.0 + + +class TestWaitForIndexingStartOrCompletion: + def test_returns_false_when_active_progress_never_completes(self) -> None: + server = _bare_ts_server(TypeScriptLanguageServer) + server.expect_indexing() + server._active_progress_tokens.add("stuck-progress") + + assert server._wait_for_indexing_start_or_completion(timeout=0.1, start_grace=0.0) is False + + def test_treats_absent_progress_as_ready(self) -> None: + server = _bare_ts_server(TypeScriptLanguageServer) + server.expect_indexing() + + assert server._wait_for_indexing_start_or_completion(timeout=30.0, start_grace=0.0) is True + assert server._indexing_complete.is_set() + + def test_returns_immediately_when_no_indexing_expected(self) -> None: + # _indexing_complete is initially set; a long grace must not block in that case + server = _bare_ts_server(TypeScriptLanguageServer) + + start = time.monotonic() + assert server._wait_for_indexing_start_or_completion(timeout=30.0, start_grace=30.0) is True + # must return via the early is_set() check, not sit out the 30s grace; the path is + # pure in-memory, so a 10s bound cannot flake on a slow runner + assert time.monotonic() - start < 10.0 + + def test_returns_true_when_progress_completes(self) -> None: + server = _bare_ts_server(TypeScriptLanguageServer) + server.expect_indexing() + server._active_progress_tokens.add("indexing-1") + + def finish_indexing() -> None: + with server._progress_lock: + server._active_progress_tokens.clear() + server._indexing_complete.set() + + timer = threading.Timer(0.05, finish_indexing) + timer.start() + try: + # both timer orderings pass: an early fire takes the absent-progress branch, + # which sets the event itself, while a late fire wakes Event.wait; ordering + # cannot flake this test. It fails only if the spawned thread does not run + # within the 30s ceiling, which means the runner is hung. + assert server._wait_for_indexing_start_or_completion(timeout=30.0, start_grace=0.0) is True + finally: + timer.cancel() + + +class _FakeSolidLSPSettings: + """Minimal settings stand-in: returns a fixed TypeScript indexing_timeout so the guard tests can + prove the svelte-key value takes precedence over the typescript-key value. + """ + + def __init__(self, ts_indexing_timeout: float) -> None: + self._ts_indexing_timeout = ts_indexing_timeout + + def get_ls_specific_settings(self, language: object) -> "SolidLSPSettings.CustomLSSettings": + return SolidLSPSettings.CustomLSSettings({"indexing_timeout": self._ts_indexing_timeout}) + + +class _FakeCompanionTSServer: + """Hand-written stand-in for the companion TS server that records calls, so the guard tests assert + observable behavior (raised errors, message shape, which timeout is used) instead of mock interactions. + """ + + class _FileBuffer: + def __init__(self, uri: str) -> None: + self.uri = uri + self.ref_count = 0 + + def __enter__(self) -> "_FakeCompanionTSServer._FileBuffer": + return self + + def __exit__(self, *exc: object) -> bool: + return False + + def __init__(self, *, wait_result: bool = True, open_error: Exception | None = None) -> None: + self.calls: list[str] = [] + self.wait_timeout: float | None = None + self._wait_result = wait_result + self._open_error = open_error + + def expect_indexing(self) -> None: + self.calls.append("expect_indexing") + + def open_file(self, relative_path: str) -> "_FakeCompanionTSServer._FileBuffer": + self.calls.append("open_file") + if self._open_error is not None: + raise self._open_error + return self._FileBuffer(uri=f"file:///{relative_path}") + + def _wait_for_indexing_start_or_completion(self, timeout: float) -> bool: + self.calls.append("wait") + self.wait_timeout = timeout + return self._wait_result + + def describe_indexing_state(self) -> str: + return "complete=False, active_progress_tokens=stuck-progress" + + +class TestSvelteLanguageServerCompanionGuard: + """SvelteLanguageServer._ensure_svelte_files_indexed_on_ts_server must fail loudly, not degrade.""" + + def _bare_svelte_ls(self, ts_server: _FakeCompanionTSServer, repo_path: str) -> SvelteLanguageServer: + ls = object.__new__(SvelteLanguageServer) + ls.repo_path = repo_path + ls._svelte_files_indexed = False + ls._indexed_svelte_file_uris = [] + ls._custom_settings = SolidLSPSettings.CustomLSSettings({"indexing_timeout": 0.01}) + # conflicting typescript-key value: the timeout assertions below prove the svelte key wins + ls._solidlsp_settings = _FakeSolidLSPSettings(ts_indexing_timeout=999.0) + ls._ts_server = ts_server + return ls + + def test_raises_when_companion_indexing_times_out(self, tmp_path) -> None: + (tmp_path / "App.svelte").touch() + ts_server = _FakeCompanionTSServer(wait_result=False) + ls = self._bare_svelte_ls(ts_server, str(tmp_path)) + + with pytest.raises(TimeoutError, match="did not finish indexing 1 .svelte files"): + ls._ensure_svelte_files_indexed_on_ts_server() + + # the svelte-key indexing_timeout (0.01), not the typescript-key (999.0), must reach the wait + assert ts_server.wait_timeout == 0.01 + + def test_succeeds_and_arms_progress_tracking_before_opening_files(self, tmp_path) -> None: + (tmp_path / "App.svelte").touch() + (tmp_path / "node_modules").mkdir() + (tmp_path / "node_modules" / "skip.svelte").touch() + (tmp_path / ".hidden").mkdir() + (tmp_path / ".hidden" / "skip.svelte").touch() + ts_server = _FakeCompanionTSServer(wait_result=True) + ls = self._bare_svelte_ls(ts_server, str(tmp_path)) + + ls._ensure_svelte_files_indexed_on_ts_server() + + assert ts_server.calls.count("open_file") == 1 + # a second call is a no-op — the observable consequence of the indexed flag + calls_after_indexing = list(ts_server.calls) + ls._ensure_svelte_files_indexed_on_ts_server() + assert ts_server.calls == calls_after_indexing + # expect_indexing must be armed BEFORE any file is opened, or early progress is lost to a race + assert ts_server.calls[0] == "expect_indexing" + assert ts_server.calls.index("expect_indexing") < ts_server.calls.index("open_file") + + def test_collects_failed_opens_and_raises_preparation_error(self, tmp_path) -> None: + (tmp_path / "B.svelte").touch() + (tmp_path / "A.svelte").touch() + ts_server = _FakeCompanionTSServer(open_error=RuntimeError("didOpen failed")) + ls = self._bare_svelte_ls(ts_server, str(tmp_path)) + + with pytest.raises(SvelteCompanionPreparationError, match="2 Svelte file") as exc_info: + ls._ensure_svelte_files_indexed_on_ts_server() + + assert "A.svelte, B.svelte" in str(exc_info.value) + # the first underlying error must be chained so the cause survives without DEBUG logs + assert isinstance(exc_info.value.__cause__, RuntimeError) + # a failed preparation must short-circuit before the indexing wait + assert "wait" not in ts_server.calls + + def test_preparation_error_caps_file_listing(self, tmp_path) -> None: + for i in reversed(range(15)): + (tmp_path / f"{i:02d}.svelte").touch() + ts_server = _FakeCompanionTSServer(open_error=RuntimeError("didOpen failed")) + ls = self._bare_svelte_ls(ts_server, str(tmp_path)) + + with pytest.raises(SvelteCompanionPreparationError) as exc_info: + ls._ensure_svelte_files_indexed_on_ts_server() + + message = str(exc_info.value) + assert "15 Svelte file(s)" in message + assert "09.svelte" in message # the 10th entry of the sorted listing is still shown + assert "10.svelte" not in message # the 11th is capped + assert "and 5 more" in message diff --git a/test/solidlsp/toml/__init__.py b/test/solidlsp/toml/__init__.py new file mode 100644 index 0000000..00ae616 --- /dev/null +++ b/test/solidlsp/toml/__init__.py @@ -0,0 +1 @@ +"""TOML language server tests.""" diff --git a/test/solidlsp/toml/test_toml_basic.py b/test/solidlsp/toml/test_toml_basic.py new file mode 100644 index 0000000..522f840 --- /dev/null +++ b/test/solidlsp/toml/test_toml_basic.py @@ -0,0 +1,249 @@ +""" +Basic integration tests for the TOML language server functionality. + +These tests validate the functionality of the Taplo language server APIs +like request_document_symbols using the TOML test repository. +""" + +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.toml +class TestTomlLanguageServerBasics: + """Test basic functionality of the TOML language server (Taplo).""" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_toml_language_server_initialization(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that TOML language server can be initialized successfully.""" + assert language_server is not None + assert language_server.language == Language.TOML + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_toml_cargo_file_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test document symbols detection in Cargo.toml with specific symbol verification.""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + assert all_symbols is not None, "Should return symbols for Cargo.toml" + assert len(all_symbols) > 0, f"Should find symbols in Cargo.toml, found {len(all_symbols)}" + + # Verify specific top-level table names are detected + symbol_names = [sym.get("name") for sym in all_symbols] + assert "package" in symbol_names, "Should detect 'package' table in Cargo.toml" + assert "dependencies" in symbol_names, "Should detect 'dependencies' table in Cargo.toml" + assert "dev-dependencies" in symbol_names, "Should detect 'dev-dependencies' table in Cargo.toml" + assert "features" in symbol_names, "Should detect 'features' table in Cargo.toml" + assert "workspace" in symbol_names, "Should detect 'workspace' table in Cargo.toml" + + # Verify nested symbols exist (keys under 'package') + assert "name" in symbol_names, "Should detect nested 'name' key" + assert "version" in symbol_names, "Should detect nested 'version' key" + assert "edition" in symbol_names, "Should detect nested 'edition' key" + + # Check symbol kind for tables - Taplo uses kind 19 (object) for TOML tables + package_symbol = next((s for s in all_symbols if s.get("name") == "package"), None) + assert package_symbol is not None, "Should find 'package' symbol" + assert package_symbol.get("kind") == 19, "Top-level table should have kind 19 (object)" + + dependencies_symbol = next((s for s in all_symbols if s.get("name") == "dependencies"), None) + assert dependencies_symbol is not None, "Should find 'dependencies' symbol" + assert dependencies_symbol.get("kind") == 19, "'dependencies' table should have kind 19 (object)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_toml_pyproject_file_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test document symbols detection in pyproject.toml.""" + all_symbols, root_symbols = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() + + assert all_symbols is not None, "Should return symbols for pyproject.toml" + assert len(all_symbols) > 0, f"Should find symbols in pyproject.toml, found {len(all_symbols)}" + + # Verify specific top-level table names + symbol_names = [sym.get("name") for sym in all_symbols] + assert "project" in symbol_names, "Should detect 'project' table" + assert "build-system" in symbol_names, "Should detect 'build-system' table" + + # Verify tool sections (nested tables) + # These could appear as 'tool' or 'tool.ruff' depending on Taplo's parsing + has_tool_section = any("tool" in name for name in symbol_names if name) + assert has_tool_section, "Should detect tool sections" + + # Verify nested keys under project + assert "name" in symbol_names, "Should detect 'name' under project" + assert "version" in symbol_names, "Should detect 'version' under project" + assert "requires-python" in symbol_names or "dependencies" in symbol_names, "Should detect project dependencies" + + # Check symbol kind for tables - Taplo uses kind 19 (object) for TOML tables + project_symbol = next((s for s in all_symbols if s.get("name") == "project"), None) + assert project_symbol is not None, "Should find 'project' symbol" + assert project_symbol.get("kind") == 19, "'project' table should have kind 19 (object)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_toml_symbol_kinds(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that TOML symbols have appropriate LSP kinds for different value types.""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + assert all_symbols is not None + assert len(all_symbols) > 0 + + # Check boolean symbol kind (lto = true at line 22) + # LSP kind 17 = boolean + lto_symbol = next((s for s in all_symbols if s.get("name") == "lto"), None) + assert lto_symbol is not None, "Should find 'lto' boolean symbol" + assert lto_symbol.get("kind") == 17, "'lto' should have kind 17 (boolean)" + + # Check number symbol kind (opt-level = 3 at line 23) + # LSP kind 16 = number + opt_level_symbol = next((s for s in all_symbols if s.get("name") == "opt-level"), None) + assert opt_level_symbol is not None, "Should find 'opt-level' number symbol" + assert opt_level_symbol.get("kind") == 16, "'opt-level' should have kind 16 (number)" + + # Check string symbol kind (name = "test_project" at line 2) + # LSP kind 15 = string + name_symbols = [s for s in all_symbols if s.get("name") == "name"] + assert len(name_symbols) > 0, "Should find 'name' symbols" + # At least one should be a string + string_name_symbol = next((s for s in name_symbols if s.get("kind") == 15), None) + assert string_name_symbol is not None, "Should find 'name' with kind 15 (string)" + + # Check array symbol kind (default = ["feature1"] at line 17) + # LSP kind 18 = array + default_symbol = next((s for s in all_symbols if s.get("name") == "default"), None) + assert default_symbol is not None, "Should find 'default' array symbol" + assert default_symbol.get("kind") == 18, "'default' should have kind 18 (array)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_toml_symbols_with_body(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test request_document_symbols with body extraction.""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + assert all_symbols is not None, "Should return symbols for Cargo.toml" + assert len(all_symbols) > 0, "Should have symbols" + + # Find the 'package' symbol and verify its body + package_symbol = next((s for s in all_symbols if s.get("name") == "package"), None) + assert package_symbol is not None, "Should find 'package' symbol" + + # Check that body exists and contains expected content + # Note: Taplo includes the section header in the body + assert "body" in package_symbol, "'package' symbol should have body" + package_body = package_symbol["body"].get_text() + assert 'name = "test_project"' in package_body, "Body should contain 'name' field" + assert 'version = "0.1.0"' in package_body, "Body should contain 'version' field" + assert 'edition = "2021"' in package_body, "Body should contain 'edition' field" + + # Find the dependencies symbol and check its body + deps_symbol = next((s for s in all_symbols if s.get("name") == "dependencies"), None) + assert deps_symbol is not None, "Should find 'dependencies' symbol" + assert "body" in deps_symbol, "'dependencies' symbol should have body" + deps_body = deps_symbol["body"].get_text() + assert "serde" in deps_body, "Body should contain serde dependency" + assert "tokio" in deps_body, "Body should contain tokio dependency" + + # Find the top-level [features] section (not the nested 'features' in serde dependency) + # The [features] section should be kind 19 (object) and at line 15 (0-indexed) + features_symbols = [s for s in all_symbols if s.get("name") == "features"] + # Find the top-level one - should be kind 19 (object) with children + features_symbol = next( + (s for s in features_symbols if s.get("kind") == 19 and s.get("children")), + None, + ) + assert features_symbol is not None, "Should find top-level 'features' table symbol" + assert "body" in features_symbol, "'features' symbol should have body" + features_body = features_symbol["body"].get_text() + assert "default" in features_body, "Body should contain 'default' feature" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_toml_symbol_ranges(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that symbols have proper range information.""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + assert all_symbols is not None + assert len(all_symbols) > 0 + + # Check the 'package' symbol range - should start at line 0 (0-indexed, actual line 1) + package_symbol = next((s for s in all_symbols if s.get("name") == "package"), None) + assert package_symbol is not None, "Should find 'package' symbol" + assert "range" in package_symbol, "'package' symbol should have range" + + package_range = package_symbol["range"] + assert "start" in package_range, "Range should have start" + assert "end" in package_range, "Range should have end" + assert package_range["start"]["line"] == 0, "'package' should start at line 0 (0-indexed, actual line 1)" + # Package block spans from line 1 to line 7 in file (1-indexed) + # In 0-indexed LSP coordinates: line 0 (start) to line 6 or 7 (end) + assert package_range["end"]["line"] >= 6, "'package' should end at or after line 6 (0-indexed)" + + # Check a nested symbol range - 'name' under package at line 2 (1-indexed), line 1 (0-indexed) + name_symbols = [s for s in all_symbols if s.get("name") == "name"] + assert len(name_symbols) > 0, "Should find 'name' symbols" + # Find the one under 'package' (should be at line 1 in 0-indexed) + package_name = next((s for s in name_symbols if s["range"]["start"]["line"] == 1), None) + assert package_name is not None, "Should find 'name' under 'package'" + + # Check the dependencies range - starts at line 9 (1-indexed), line 8 (0-indexed) + deps_symbol = next((s for s in all_symbols if s.get("name") == "dependencies"), None) + assert deps_symbol is not None, "Should find 'dependencies' symbol" + deps_range = deps_symbol["range"] + assert deps_range["start"]["line"] == 8, "'dependencies' should start at line 8 (0-indexed, actual line 9)" + + # Check that range includes line and character positions + assert "line" in package_range["start"], "Start should have line" + assert "character" in package_range["start"], "Start should have character" + assert "line" in package_range["end"], "End should have line" + assert "character" in package_range["end"], "End should have character" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_toml_nested_table_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test detection of nested table symbols like profile.release and tool.ruff.""" + # Test Cargo.toml for profile.release + cargo_symbols, _ = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + assert cargo_symbols is not None + symbol_names = [sym.get("name") for sym in cargo_symbols] + + # Should detect profile.release or profile section + has_profile = any("profile" in name for name in symbol_names if name) + assert has_profile, "Should detect profile section in Cargo.toml" + + # Test pyproject.toml for tool sections + pyproject_symbols, _ = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() + + assert pyproject_symbols is not None + pyproject_names = [sym.get("name") for sym in pyproject_symbols] + + # Should detect tool.ruff, tool.mypy sections + has_ruff = any("ruff" in name for name in pyproject_names if name) + has_mypy = any("mypy" in name for name in pyproject_names if name) + assert has_ruff or has_mypy, "Should detect tool sections in pyproject.toml" + + # Verify pyproject has expected boolean: strict = true + strict_symbol = next((s for s in pyproject_symbols if s.get("name") == "strict"), None) + if strict_symbol: + assert strict_symbol.get("kind") == 17, "'strict' should have kind 17 (boolean)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/toml/test_toml_diagnostics.py b/test/solidlsp/toml/test_toml_diagnostics.py new file mode 100644 index 0000000..dde2fc8 --- /dev/null +++ b/test/solidlsp/toml/test_toml_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.toml +class TestTomlDiagnostics: + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.toml", + (), + min_count=1, + ) diff --git a/test/solidlsp/toml/test_toml_edge_cases.py b/test/solidlsp/toml/test_toml_edge_cases.py new file mode 100644 index 0000000..faea142 --- /dev/null +++ b/test/solidlsp/toml/test_toml_edge_cases.py @@ -0,0 +1,273 @@ +""" +Tests for TOML language server edge cases and advanced features. + +These tests cover: +- Inline tables +- Multiline strings +- Arrays of tables +- Nested tables +- Various TOML data types +""" + +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + +pytestmark = pytest.mark.toml + + +class TestTomlEdgeCases: + """Test TOML language server handling of edge cases and advanced features.""" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_inline_table_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that inline tables are properly detected.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + assert all_symbols is not None + assert len(all_symbols) > 0 + + symbol_names = [sym.get("name") for sym in all_symbols] + + # The inline table 'endpoint' should be detected + assert "endpoint" in symbol_names, "Should detect 'endpoint' inline table" + + # Find the endpoint symbol and check its properties + endpoint_symbol = next((s for s in all_symbols if s.get("name") == "endpoint"), None) + assert endpoint_symbol is not None + # Inline tables should be kind 19 (object) + assert endpoint_symbol.get("kind") == 19, "Inline table should have kind 19 (object)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_nested_table_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that deeply nested tables are properly detected.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect nested tables like server.ssl and database.pool + has_ssl = any("ssl" in str(name).lower() for name in symbol_names if name) + has_pool = any("pool" in str(name).lower() for name in symbol_names if name) + + assert has_ssl, f"Should detect 'server.ssl' nested table, got: {symbol_names}" + assert has_pool, f"Should detect 'database.pool' nested table, got: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_array_of_tables_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that [[array_of_tables]] syntax is properly detected.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect [[endpoints]] array of tables + assert "endpoints" in symbol_names, f"Should detect '[[endpoints]]' array of tables, got: {symbol_names}" + + # Find the endpoints symbol + endpoints_symbol = next((s for s in all_symbols if s.get("name") == "endpoints"), None) + assert endpoints_symbol is not None + + # Array of tables should be kind 18 (array) + assert endpoints_symbol.get("kind") == 18, "Array of tables should have kind 18 (array)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_multiline_string_handling(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that multiline strings are handled correctly.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect connection_string and multiline fields + assert "connection_string" in symbol_names, "Should detect 'connection_string' with multiline value" + assert "multiline" in symbol_names, "Should detect 'multiline' literal string" + + # Find connection_string and verify it's a string type + conn_symbol = next((s for s in all_symbols if s.get("name") == "connection_string"), None) + assert conn_symbol is not None + # String type should be kind 15 + assert conn_symbol.get("kind") == 15, "Multiline string should have kind 15 (string)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_array_value_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that array values are properly detected.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect 'outputs' and 'methods' arrays + assert "outputs" in symbol_names, "Should detect 'outputs' array" + assert "methods" in symbol_names, "Should detect 'methods' array" + + # Find outputs array and verify kind + outputs_symbol = next((s for s in all_symbols if s.get("name") == "outputs"), None) + assert outputs_symbol is not None + # Arrays should have kind 18 + assert outputs_symbol.get("kind") == 18, "'outputs' should have kind 18 (array)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_float_value_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that float values are properly detected.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect 'timeout' which has a float value (30.5) + assert "timeout" in symbol_names, "Should detect 'timeout' float value" + + # Find timeout and verify it's a number + timeout_symbol = next((s for s in all_symbols if s.get("name") == "timeout"), None) + assert timeout_symbol is not None + # Numbers should have kind 16 + assert timeout_symbol.get("kind") == 16, "'timeout' should have kind 16 (number)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_datetime_value_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that datetime values are detected.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect metadata section with datetime values + assert "metadata" in symbol_names, "Should detect 'metadata' section" + assert "created" in symbol_names, "Should detect 'created' datetime field" + assert "updated" in symbol_names, "Should detect 'updated' datetime field" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_symbol_body_with_inline_table(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that symbol bodies include inline table content.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + # Find the endpoint symbol with body + endpoint_symbol = next((s for s in all_symbols if s.get("name") == "endpoint"), None) + assert endpoint_symbol is not None + + if "body" in endpoint_symbol: + body = endpoint_symbol["body"].get_text() + # Body should contain the inline table syntax + assert "url" in body or "version" in body, f"Body should contain inline table contents, got: {body}" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_symbol_ranges_in_config(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that symbol ranges are correct in config.toml.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + # Find the server symbol + server_symbol = next((s for s in all_symbols if s.get("name") == "server"), None) + assert server_symbol is not None + assert "range" in server_symbol + + # Server should start near the beginning (line 2 is [server], 0-indexed: line 2) + server_range = server_symbol["range"] + assert server_range["start"]["line"] >= 0, "Server should start at or near the beginning" + assert server_range["end"]["line"] > server_range["start"]["line"], "Server block should span multiple lines" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_comment_handling(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that comments don't interfere with symbol detection.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # File has comments but symbols should still be detected correctly + expected_sections = {"server", "database", "logging", "endpoints", "metadata", "messages"} + found_sections = expected_sections.intersection(set(symbol_names)) + + assert len(found_sections) >= 4, f"Should find most sections despite comments, found: {found_sections}" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_special_characters_in_strings(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that strings with escape sequences are handled.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect the messages section with special strings + assert "messages" in symbol_names, "Should detect 'messages' section" + assert "with_escapes" in symbol_names, "Should detect 'with_escapes' field" + assert "welcome" in symbol_names, "Should detect 'welcome' field" + + +class TestTomlDependencyTables: + """Test handling of dependency-style tables common in Cargo.toml and pyproject.toml.""" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_complex_dependency_inline_table(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test detection of complex inline table dependencies like serde = { version = "1.0", features = ["derive"] }.""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect serde and tokio dependencies + assert "serde" in symbol_names, "Should detect 'serde' dependency" + assert "tokio" in symbol_names, "Should detect 'tokio' dependency" + + # Find serde symbol + serde_symbol = next((s for s in all_symbols if s.get("name") == "serde"), None) + assert serde_symbol is not None + + # Dependency with inline table should be kind 19 (object) + assert serde_symbol.get("kind") == 19, "Complex dependency should have kind 19 (object)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_simple_dependency_string(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test detection of simple string dependencies like proptest = "1.0".""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect proptest dev-dependency + assert "proptest" in symbol_names, "Should detect 'proptest' dependency" + + # Find proptest symbol + proptest_symbol = next((s for s in all_symbols if s.get("name") == "proptest"), None) + assert proptest_symbol is not None + + # Simple string dependency should be kind 15 (string) + assert proptest_symbol.get("kind") == 15, "Simple string dependency should have kind 15 (string)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_pyproject_dependencies_array(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test detection of pyproject.toml dependencies array.""" + all_symbols, root_symbols = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect dependencies array + assert "dependencies" in symbol_names, "Should detect 'dependencies' array" + + # Find dependencies symbol + deps_symbol = next((s for s in all_symbols if s.get("name") == "dependencies"), None) + assert deps_symbol is not None + + # Dependencies array should be kind 18 (array) + assert deps_symbol.get("kind") == 18, "Dependencies array should have kind 18 (array)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_optional_dependencies_table(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test detection of optional-dependencies in pyproject.toml.""" + all_symbols, root_symbols = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect optional-dependencies or its nested form + has_optional_deps = any("optional" in str(name).lower() for name in symbol_names if name) + has_dev = "dev" in symbol_names + + assert has_optional_deps or has_dev, f"Should detect optional-dependencies or dev group, got: {symbol_names}" diff --git a/test/solidlsp/toml/test_toml_ignored_dirs.py b/test/solidlsp/toml/test_toml_ignored_dirs.py new file mode 100644 index 0000000..11d987f --- /dev/null +++ b/test/solidlsp/toml/test_toml_ignored_dirs.py @@ -0,0 +1,62 @@ +""" +Tests for TOML language server directory ignoring functionality. + +These tests validate that the Taplo language server correctly ignores +TOML-specific directories like target, .cargo, and node_modules. +""" + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + +pytestmark = pytest.mark.toml + + +@pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) +class TestTomlIgnoredDirectories: + """Test TOML-specific directory ignoring behavior.""" + + def test_default_ignored_directories(self, language_server: SolidLanguageServer) -> None: + """Test that default TOML directories are ignored.""" + # Test that TOML/Rust/Node-specific directories are ignored by default + assert language_server.is_ignored_dirname("target"), "target should be ignored" + assert language_server.is_ignored_dirname(".cargo"), ".cargo should be ignored" + assert language_server.is_ignored_dirname("node_modules"), "node_modules should be ignored" + + # Infrastructure directories ignored by base class + assert language_server.is_ignored_dirname(".git"), ".git should be ignored" + assert language_server.is_ignored_dirname(".venv"), ".venv should be ignored" + + def test_important_directories_not_ignored(self, language_server: SolidLanguageServer) -> None: + """Test that important directories are not ignored.""" + # Common project directories should not be ignored + assert not language_server.is_ignored_dirname("src"), "src should not be ignored" + assert not language_server.is_ignored_dirname("crates"), "crates should not be ignored" + assert not language_server.is_ignored_dirname("lib"), "lib should not be ignored" + assert not language_server.is_ignored_dirname("tests"), "tests should not be ignored" + assert not language_server.is_ignored_dirname("config"), "config should not be ignored" + + def test_cargo_related_directories(self, language_server: SolidLanguageServer) -> None: + """Test Cargo/Rust-related directory handling.""" + # Rust build directories should be ignored + assert language_server.is_ignored_dirname("target"), "target (Rust build) should be ignored" + assert language_server.is_ignored_dirname(".cargo"), ".cargo should be ignored" + + # But important Rust directories should not be ignored + assert not language_server.is_ignored_dirname("benches"), "benches should not be ignored" + assert not language_server.is_ignored_dirname("examples"), "examples should not be ignored" + + def test_various_cache_directories(self, language_server: SolidLanguageServer) -> None: + """Test various cache and temporary directories are ignored.""" + # Cache directories ignored by base class + assert language_server.is_ignored_dirname(".cache"), ".cache should be ignored" + + # IDE internals + assert language_server.is_ignored_dirname(".idea"), ".idea should be ignored" + + # .vscode is intentionally NOT ignored — it contains user-facing config + assert not language_server.is_ignored_dirname(".vscode"), ".vscode should not be ignored" + + # Note: __pycache__ is NOT ignored by TOML server (only Python servers ignore it) + assert not language_server.is_ignored_dirname("__pycache__"), "__pycache__ is not TOML-specific" diff --git a/test/solidlsp/toml/test_toml_symbol_retrieval.py b/test/solidlsp/toml/test_toml_symbol_retrieval.py new file mode 100644 index 0000000..5538a4c --- /dev/null +++ b/test/solidlsp/toml/test_toml_symbol_retrieval.py @@ -0,0 +1,191 @@ +""" +Tests for TOML language server symbol retrieval functionality. + +These tests focus on advanced symbol operations: +- request_containing_symbol +- request_document_overview +- request_full_symbol_tree +- request_dir_overview +""" + +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + +pytestmark = pytest.mark.toml + + +class TestTomlSymbolRetrieval: + """Test advanced symbol retrieval functionality for TOML files.""" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_request_containing_symbol_behavior(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test request_containing_symbol behavior for TOML files. + + Note: Taplo LSP doesn't support definition/containing symbol lookups for TOML files + since TOML is a configuration format, not code. This test verifies the behavior. + """ + # Line 2 (0-indexed: 1) is inside the [package] table + containing_symbol = language_server.request_containing_symbol("Cargo.toml", 1, 5) + + # Taplo doesn't support containing symbol lookup - returns None + # This is expected behavior for a configuration file format + assert containing_symbol is None, "TOML LSP doesn't support containing symbol lookup" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_request_document_overview_cargo(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test request_document_overview for Cargo.toml.""" + overview = language_server.request_document_overview("Cargo.toml") + + assert overview is not None + assert len(overview) > 0 + + # Get symbol names from overview + symbol_names = {symbol.get("name") for symbol in overview if "name" in symbol} + + # Verify expected top-level tables appear + expected_tables = {"package", "dependencies", "dev-dependencies", "features", "workspace"} + assert expected_tables.issubset(symbol_names), f"Missing expected tables in overview: {expected_tables - symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_request_document_overview_pyproject(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test request_document_overview for pyproject.toml.""" + overview = language_server.request_document_overview("pyproject.toml") + + assert overview is not None + assert len(overview) > 0 + + # Get symbol names from overview + symbol_names = {symbol.get("name") for symbol in overview if "name" in symbol} + + # Verify expected top-level tables appear + assert "project" in symbol_names, "Should detect 'project' table" + assert "build-system" in symbol_names, "Should detect 'build-system' table" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_request_full_symbol_tree(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test request_full_symbol_tree returns TOML files.""" + symbol_tree = language_server.request_full_symbol_tree() + + assert symbol_tree is not None + assert len(symbol_tree) > 0 + + # The root should be test_repo + root = symbol_tree[0] + assert root["name"] == "test_repo" + assert "children" in root + + # Children should include TOML files + child_names = {child["name"] for child in root.get("children", [])} + # Note: File names are stripped of extension in some cases + assert "Cargo" in child_names or "Cargo.toml" in child_names or any("cargo" in name.lower() for name in child_names), ( + f"Should find Cargo.toml in tree, got: {child_names}" + ) + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_request_dir_overview(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test request_dir_overview returns symbols for TOML files.""" + overview = language_server.request_dir_overview(".") + + assert overview is not None + assert len(overview) > 0 + + # Should have entries for both Cargo.toml and pyproject.toml + file_paths = list(overview.keys()) + assert any("Cargo.toml" in path for path in file_paths), f"Should find Cargo.toml in overview, got: {file_paths}" + assert any("pyproject.toml" in path for path in file_paths), f"Should find pyproject.toml in overview, got: {file_paths}" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_symbol_hierarchy_in_cargo(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that symbol hierarchy is properly preserved in Cargo.toml.""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + # Find the 'package' table + package_symbol = next((s for s in root_symbols if s.get("name") == "package"), None) + assert package_symbol is not None, "Should find 'package' as root symbol" + + # Verify it has children (nested keys) + assert "children" in package_symbol, "'package' should have children" + child_names = {child.get("name") for child in package_symbol.get("children", [])} + + # Package should have name, version, edition at minimum + assert "name" in child_names, "'package' should have 'name' child" + assert "version" in child_names, "'package' should have 'version' child" + assert "edition" in child_names, "'package' should have 'edition' child" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_symbol_hierarchy_in_pyproject(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that symbol hierarchy is properly preserved in pyproject.toml.""" + all_symbols, root_symbols = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() + + # Find the 'project' table + project_symbol = next((s for s in root_symbols if s.get("name") == "project"), None) + assert project_symbol is not None, "Should find 'project' as root symbol" + + # Verify it has children + assert "children" in project_symbol, "'project' should have children" + child_names = {child.get("name") for child in project_symbol.get("children", [])} + + # Project should have name, version, dependencies at minimum + assert "name" in child_names, "'project' should have 'name' child" + assert "version" in child_names, "'project' should have 'version' child" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_tool_section_hierarchy(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that tool sections in pyproject.toml are properly structured.""" + all_symbols, root_symbols = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() + + # Get all symbol names + all_names = [s.get("name") for s in all_symbols] + + # Should detect tool.ruff, tool.mypy, or tool.pytest + has_ruff = any("ruff" in name.lower() for name in all_names if name) + has_mypy = any("mypy" in name.lower() for name in all_names if name) + has_pytest = any("pytest" in name.lower() for name in all_names if name) + + assert has_ruff or has_mypy or has_pytest, f"Should detect tool sections, got names: {all_names}" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_array_of_tables_symbol(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that [[bin]] array of tables is detected.""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + # Get all symbol names + all_names = [s.get("name") for s in all_symbols] + + # Should detect bin array of tables + has_bin = "bin" in all_names + assert has_bin, f"Should detect [[bin]] array of tables, got names: {all_names}" + + # Find the bin symbol and verify its structure + bin_symbol = next((s for s in all_symbols if s.get("name") == "bin"), None) + assert bin_symbol is not None, "Should find bin symbol" + + # Array of tables should be kind 18 (array) + assert bin_symbol.get("kind") == 18, "[[bin]] should have kind 18 (array)" + + # Children of array of tables are indexed by position ('0', '1', etc.) + if "children" in bin_symbol: + bin_children = bin_symbol.get("children", []) + assert len(bin_children) > 0, "[[bin]] should have at least one child element" + # First child is index '0' + first_child = bin_children[0] + assert first_child.get("name") == "0", f"First array element should be named '0', got: {first_child.get('name')}" + + # The '0' element should contain name and path as grandchildren + if "children" in first_child: + grandchild_names = {gc.get("name") for gc in first_child.get("children", [])} + assert "name" in grandchild_names, f"[[bin]] element should have 'name' field, got: {grandchild_names}" + assert "path" in grandchild_names, f"[[bin]] element should have 'path' field, got: {grandchild_names}" diff --git a/test/solidlsp/typescript/test_typescript_basic.py b/test/solidlsp/typescript/test_typescript_basic.py new file mode 100644 index 0000000..1c9ed1a --- /dev/null +++ b/test/solidlsp/typescript/test_typescript_basic.py @@ -0,0 +1,106 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.typescript +class TestTypescriptLanguageServer: + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "DemoClass"), "DemoClass not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "helperFunction"), "helperFunction not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "printValue"), "printValue method not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) + def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("index.ts") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + helper_symbol = None + for sym in symbols[0]: + if sym.get("name") == "helperFunction": + helper_symbol = sym + break + assert helper_symbol is not None, "Could not find 'helperFunction' symbol in index.ts" + sel_start = helper_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert any("index.ts" in ref.get("relativePath", "") for ref in refs), ( + "index.ts should reference helperFunction (tried all positions in selectionRange)" + ) + + if language_has_verified_implementation_support(Language.TYPESCRIPT): + + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.TYPESCRIPT) + pos = find_identifier_position(repo_path / "formatters.ts", "formatGreeting") + assert pos is not None, "Could not find Greeter.formatGreeting in fixture" + + implementations = language_server.request_implementation("formatters.ts", *pos) + assert implementations, "Expected at least one implementation of Greeter.formatGreeting" + assert any("formatters.ts" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected ConsoleGreeter.formatGreeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.TYPESCRIPT) + pos = find_identifier_position(repo_path / "formatters.ts", "formatGreeting") + assert pos is not None, "Could not find Greeter.formatGreeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols("formatters.ts", *pos) + assert implementing_symbols, "Expected implementing symbols for Greeter.formatGreeting" + assert any( + symbol.get("name") == "formatGreeting" and "formatters.ts" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected ConsoleGreeter.formatGreeting symbol, got: {implementing_symbols}" + + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) + def test_tsx_symbol_range_not_truncated_by_jsx(self, language_server: SolidLanguageServer) -> None: + # Regression: when the language id is sent as "typescript" instead of + # "typescriptreact" for .tsx files, tsserver parses JSX as syntax + # errors and recovers by truncating the enclosing symbol's range at + # the first multi-line JSX expression. find_symbol then returns a + # body that ends mid-component and hides everything below. + file_path = "jsx_component.tsx" + roots = language_server.request_document_symbols(file_path).root_symbols + + jsx_component = next((s for s in roots if s.get("name") == "JsxComponent"), None) + assert jsx_component is not None, "JsxComponent not found at root level of jsx_component.tsx" + + end_line = jsx_component["location"]["range"]["end"]["line"] + # JsxComponent's body extends to line 38 (0-based 37) in the fixture; + # the truncation bug cut it at the first multi-line JSX (~line 21). + # Use a generous lower bound so the test survives small fixture edits + # that don't affect the regression behaviour we care about. + assert end_line >= 30, ( + f"JsxComponent symbol range truncated at line {end_line + 1} (1-based); " + f"expected end at or past line 31 (1-based). " + f"This indicates the .tsx file was opened with the wrong languageId." + ) + + # The trailing helper must be visible as a top-level symbol — it lives + # past the truncation point and disappears entirely when the bug is + # active because tsserver stops emitting symbols after the parse error. + assert any(s.get("name") == "trailingHelper" for s in roots), ( + "trailingHelper missing from jsx_component.tsx root symbols; tsserver likely stopped parsing at the first JSX expression." + ) + + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/typescript/test_typescript_cross_package.py b/test/solidlsp/typescript/test_typescript_cross_package.py new file mode 100644 index 0000000..65582fb --- /dev/null +++ b/test/solidlsp/typescript/test_typescript_cross_package.py @@ -0,0 +1,99 @@ +"""Tests for cross-package TypeScript references using additional_workspace_folders.""" + +from pathlib import Path + +import pytest + +from solidlsp.ls_config import Language +from test.conftest import start_ls_context + +CROSS_PKG_DIR = Path(__file__).parent.parent.parent / "resources" / "repos" / "typescript" +PACKAGE_A = str(CROSS_PKG_DIR / "cross_package_a") +PACKAGE_B = str(CROSS_PKG_DIR / "cross_package_b") + + +@pytest.mark.typescript +class TestCrossPackageReferences: + """Verify that find_referencing_symbols works across package boundaries + when additional_workspace_folders is configured. + """ + + def test_cross_package_find_references(self) -> None: + """Starting from package_a, with package_b as additional workspace, + references in package_b should be discovered. + """ + with start_ls_context( + Language.TYPESCRIPT, + repo_path=PACKAGE_A, + additional_workspace_folders=[PACKAGE_B], + ) as ls: + symbols = ls.request_document_symbols("shared_utils.ts").get_all_symbols_and_roots() + shared_fn = None + for sym in symbols[0]: + if sym.get("name") == "sharedUtilityFunction": + shared_fn = sym + break + assert shared_fn is not None, "Could not find 'sharedUtilityFunction' in shared_utils.ts" + + sel_start = shared_fn["selectionRange"]["start"] + refs = ls.request_references("shared_utils.ts", sel_start["line"], sel_start["character"]) + + ref_paths = [r.get("relativePath", "") for r in refs] + cross_package_refs = [p for p in ref_paths if "cross_package_b" in p or "consumer.ts" in p] + assert len(cross_package_refs) > 0, ( + f"Expected cross-package reference from package_b/consumer.ts, but only found refs in: {ref_paths}" + ) + + def test_cross_package_referencing_symbols(self) -> None: + """Test the higher-level request_referencing_symbols across packages.""" + with start_ls_context( + Language.TYPESCRIPT, + repo_path=PACKAGE_A, + additional_workspace_folders=[PACKAGE_B], + ) as ls: + symbols = ls.request_document_symbols("shared_utils.ts").get_all_symbols_and_roots() + shared_class = None + for sym in symbols[0]: + if sym.get("name") == "SharedClass": + shared_class = sym + break + assert shared_class is not None, "Could not find 'SharedClass' in shared_utils.ts" + + sel_start = shared_class["selectionRange"]["start"] + ref_symbols = ls.request_referencing_symbols( + "shared_utils.ts", + sel_start["line"], + sel_start["character"], + include_imports=True, + include_file_symbols=True, + ) + + ref_files = [ + r.symbol["location"]["relativePath"] + for r in ref_symbols + if "location" in r.symbol and "relativePath" in r.symbol["location"] + ] + cross_refs = [p for p in ref_files if "cross_package_b" in p or "consumer.ts" in p] + assert len(cross_refs) > 0, f"Expected cross-package referencing symbol from package_b, but only found refs in: {ref_files}" + + def test_without_additional_workspace_no_cross_refs(self) -> None: + """Baseline: without additional_workspace_folders, cross-package refs should NOT appear.""" + with start_ls_context( + Language.TYPESCRIPT, + repo_path=PACKAGE_A, + ) as ls: + symbols = ls.request_document_symbols("shared_utils.ts").get_all_symbols_and_roots() + shared_fn = None + for sym in symbols[0]: + if sym.get("name") == "sharedUtilityFunction": + shared_fn = sym + break + assert shared_fn is not None + + sel_start = shared_fn["selectionRange"]["start"] + refs = ls.request_references("shared_utils.ts", sel_start["line"], sel_start["character"]) + ref_paths = [r.get("relativePath", "") for r in refs] + cross_package_refs = [p for p in ref_paths if "cross_package_b" in p or "consumer.ts" in p] + assert len(cross_package_refs) == 0, ( + f"Without additional_workspace_folders, should NOT find cross-package refs, but found: {cross_package_refs}" + ) diff --git a/test/solidlsp/typescript/test_typescript_diagnostics.py b/test/solidlsp/typescript/test_typescript_diagnostics.py new file mode 100644 index 0000000..5e879e1 --- /dev/null +++ b/test/solidlsp/typescript/test_typescript_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.typescript +class TestTypeScriptDiagnostics: + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.ts", + ("missingGreeting", "missingConsumerValue"), + min_count=2, + ) diff --git a/test/solidlsp/util/diagnostics.py b/test/solidlsp/util/diagnostics.py new file mode 100644 index 0000000..dee6608 --- /dev/null +++ b/test/solidlsp/util/diagnostics.py @@ -0,0 +1,19 @@ +from collections.abc import Sequence + +from solidlsp import SolidLanguageServer + + +def assert_file_diagnostics( + language_server: SolidLanguageServer, + relative_file_path: str, + expected_message_fragments: Sequence[str], + min_count: int = 1, +) -> None: + diagnostics = language_server.request_text_document_diagnostics(relative_file_path, min_severity=1) + + assert isinstance(diagnostics, list), diagnostics + assert len(diagnostics) >= min_count, diagnostics + + diagnostic_messages = [diagnostic["message"] for diagnostic in diagnostics] + for fragment in expected_message_fragments: + assert any(fragment in message for message in diagnostic_messages), diagnostic_messages diff --git a/test/solidlsp/util/test_ls_utils.py b/test/solidlsp/util/test_ls_utils.py new file mode 100644 index 0000000..cad603c --- /dev/null +++ b/test/solidlsp/util/test_ls_utils.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path +from unittest.mock import patch + +from solidlsp.ls_utils import FileUtils + + +class _FakeResponse: + def __init__(self, payload: bytes, final_url: str) -> None: + self.status_code = 200 + self.headers = {"content-encoding": "gzip"} + self.url = final_url + self._payload = payload + + def iter_content(self, chunk_size: int = 1): + for offset in range(0, len(self._payload), chunk_size): + yield self._payload[offset : offset + chunk_size] + + def close(self) -> None: + return None + + +def test_download_file_verified_writes_decoded_response_body(tmp_path: Path) -> None: + """Gzip-encoded transfer bodies should be written as decoded payload bytes.""" + payload = b"PK\x03\x04zip-content" + target_path = tmp_path / "downloaded.vsix" + final_url = "https://marketplace.visualstudio.com/example.vsix" + + with patch( + "solidlsp.ls_utils.requests.get", + return_value=_FakeResponse(payload, final_url), + ): + FileUtils.download_file_verified( + "https://marketplace.visualstudio.com/example.vsix", + str(target_path), + expected_sha256=hashlib.sha256(payload).hexdigest(), + allowed_hosts=("marketplace.visualstudio.com",), + ) + + assert target_path.read_bytes() == payload diff --git a/test/solidlsp/util/test_zip.py b/test/solidlsp/util/test_zip.py new file mode 100644 index 0000000..5fa545b --- /dev/null +++ b/test/solidlsp/util/test_zip.py @@ -0,0 +1,99 @@ +import sys +import zipfile +from pathlib import Path + +import pytest + +from solidlsp.util.zip import SafeZipExtractor + + +@pytest.fixture +def temp_zip_file(tmp_path: Path) -> Path: + """Create a temporary ZIP file for testing.""" + zip_path = tmp_path / "test.zip" + with zipfile.ZipFile(zip_path, "w") as zipf: + zipf.writestr("file1.txt", "Hello World 1") + zipf.writestr("file2.txt", "Hello World 2") + zipf.writestr("folder/file3.txt", "Hello World 3") + return zip_path + + +def test_extract_all_success(temp_zip_file: Path, tmp_path: Path) -> None: + """All files should extract without error.""" + dest_dir = tmp_path / "extracted" + extractor = SafeZipExtractor(temp_zip_file, dest_dir, verbose=False) + extractor.extract_all() + + assert (dest_dir / "file1.txt").read_text() == "Hello World 1" + assert (dest_dir / "file2.txt").read_text() == "Hello World 2" + assert (dest_dir / "folder" / "file3.txt").read_text() == "Hello World 3" + + +def test_include_patterns(temp_zip_file: Path, tmp_path: Path) -> None: + """Only files matching include_patterns should be extracted.""" + dest_dir = tmp_path / "extracted" + extractor = SafeZipExtractor(temp_zip_file, dest_dir, verbose=False, include_patterns=["*.txt"]) + extractor.extract_all() + + assert (dest_dir / "file1.txt").exists() + assert (dest_dir / "file2.txt").exists() + assert (dest_dir / "folder" / "file3.txt").exists() + + +def test_exclude_patterns(temp_zip_file: Path, tmp_path: Path) -> None: + """Files matching exclude_patterns should be skipped.""" + dest_dir = tmp_path / "extracted" + extractor = SafeZipExtractor(temp_zip_file, dest_dir, verbose=False, exclude_patterns=["file2.txt"]) + extractor.extract_all() + + assert (dest_dir / "file1.txt").exists() + assert not (dest_dir / "file2.txt").exists() + assert (dest_dir / "folder" / "file3.txt").exists() + + +def test_include_and_exclude_patterns(temp_zip_file: Path, tmp_path: Path) -> None: + """Exclude should override include if both match.""" + dest_dir = tmp_path / "extracted" + extractor = SafeZipExtractor( + temp_zip_file, + dest_dir, + verbose=False, + include_patterns=["*.txt"], + exclude_patterns=["file1.txt"], + ) + extractor.extract_all() + + assert not (dest_dir / "file1.txt").exists() + assert (dest_dir / "file2.txt").exists() + assert (dest_dir / "folder" / "file3.txt").exists() + + +def test_skip_on_error(monkeypatch, temp_zip_file: Path, tmp_path: Path) -> None: + """Should skip a file that raises an error and continue extracting others.""" + dest_dir = tmp_path / "extracted" + + original_open = zipfile.ZipFile.open + + def failing_open(self, member, *args, **kwargs): + if member.filename == "file2.txt": + raise OSError("Simulated failure") + return original_open(self, member, *args, **kwargs) + + # Patch the method on the class, not on an instance + monkeypatch.setattr(zipfile.ZipFile, "open", failing_open) + + extractor = SafeZipExtractor(temp_zip_file, dest_dir, verbose=False) + extractor.extract_all() + + assert (dest_dir / "file1.txt").exists() + assert not (dest_dir / "file2.txt").exists() + assert (dest_dir / "folder" / "file3.txt").exists() + + +@pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows-only test") +def test_long_path_normalization(temp_zip_file: Path, tmp_path: Path) -> None: + r"""Ensure _normalize_path adds \\?\\ prefix on Windows.""" + dest_dir = tmp_path / ("a" * 250) # Simulate long path + extractor = SafeZipExtractor(temp_zip_file, dest_dir, verbose=False) + norm_path = extractor._normalize_path(dest_dir / "file.txt") + assert str(norm_path).startswith("\\\\?\\") diff --git a/test/solidlsp/vue/__init__.py b/test/solidlsp/vue/__init__.py new file mode 100644 index 0000000..a587a13 --- /dev/null +++ b/test/solidlsp/vue/__init__.py @@ -0,0 +1,15 @@ +"""Vue language server tests.""" + +import shutil + + +def _test_npm_available() -> str: + """Test if npm is available and return error reason if not.""" + # Check if npm is installed + if not shutil.which("npm"): + return "npm is not installed or not in PATH" + return "" # No error, npm is available + + +NPM_UNAVAILABLE_REASON = _test_npm_available() +NPM_UNAVAILABLE = bool(NPM_UNAVAILABLE_REASON) diff --git a/test/solidlsp/vue/test_vue_basic.py b/test/solidlsp/vue/test_vue_basic.py new file mode 100644 index 0000000..d3e0535 --- /dev/null +++ b/test/solidlsp/vue/test_vue_basic.py @@ -0,0 +1,385 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.vue +class TestVueLanguageServer: + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_vue_files_in_symbol_tree(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "App"), "App not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "CalculatorButton"), "CalculatorButton not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "CalculatorInput"), "CalculatorInput not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "CalculatorDisplay"), "CalculatorDisplay not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: + store_file = os.path.join("src", "stores", "calculator.ts") + symbols = language_server.request_document_symbols(store_file).get_all_symbols_and_roots() + + # Find useCalculatorStore function + store_symbol = None + for sym in symbols[0]: + if sym.get("name") == "useCalculatorStore": + store_symbol = sym + break + + assert store_symbol is not None, "useCalculatorStore function not found" + + # Get references + sel_start = store_symbol["selectionRange"]["start"] + refs = language_server.request_references(store_file, sel_start["line"], sel_start["character"]) + + # Should have multiple references: definition + usage in App.vue, CalculatorInput.vue, CalculatorDisplay.vue + assert len(refs) >= 4, f"useCalculatorStore should have at least 4 references (definition + 3 usages), got {len(refs)}" + + # Verify we have references from .vue files + vue_refs = [ref for ref in refs if ".vue" in ref.get("relativePath", "")] + assert len(vue_refs) >= 3, f"Should have at least 3 Vue component references, got {len(vue_refs)}" + + +@pytest.mark.vue +class TestVueDualLspArchitecture: + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_typescript_server_coordination(self, language_server: SolidLanguageServer) -> None: + ts_file = os.path.join("src", "stores", "calculator.ts") + ts_symbols = language_server.request_document_symbols(ts_file).get_all_symbols_and_roots() + ts_symbol_names = [s.get("name") for s in ts_symbols[0]] + + assert len(ts_symbols[0]) >= 5, f"TypeScript server should return multiple symbols for calculator.ts, got {len(ts_symbols[0])}" + assert "useCalculatorStore" in ts_symbol_names, "TypeScript server should extract store function" + + # Verify Vue server can parse .vue files + vue_file = os.path.join("src", "App.vue") + vue_symbols = language_server.request_document_symbols(vue_file).get_all_symbols_and_roots() + vue_symbol_names = [s.get("name") for s in vue_symbols[0]] + + assert len(vue_symbols[0]) >= 15, f"Vue server should return at least 15 symbols for App.vue, got {len(vue_symbols[0])}" + assert "appTitle" in vue_symbol_names, "Vue server should extract ref declarations from script setup" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_cross_file_references_vue_to_typescript(self, language_server: SolidLanguageServer) -> None: + store_file = os.path.join("src", "stores", "calculator.ts") + store_symbols = language_server.request_document_symbols(store_file).get_all_symbols_and_roots() + + store_symbol = None + for sym in store_symbols[0]: + if sym.get("name") == "useCalculatorStore": + store_symbol = sym + break + + if not store_symbol or "selectionRange" not in store_symbol: + pytest.skip("useCalculatorStore symbol not found - test fixture may need updating") + + # Request references for this symbol + sel_start = store_symbol["selectionRange"]["start"] + refs = language_server.request_references(store_file, sel_start["line"], sel_start["character"]) + + # Verify we found references: definition + usage in App.vue, CalculatorInput.vue, CalculatorDisplay.vue + assert len(refs) >= 4, f"useCalculatorStore should have at least 4 references (definition + 3 usages), found {len(refs)} references" + + # Verify references include .vue files (components that import the store) + vue_refs = [ref for ref in refs if ".vue" in ref.get("uri", "")] + assert len(vue_refs) >= 3, ( + f"Should find at least 3 references in Vue components, found {len(vue_refs)}: {[ref.get('uri', '') for ref in vue_refs]}" + ) + + # Verify specific components that use the store + expected_vue_files = ["App.vue", "CalculatorInput.vue", "CalculatorDisplay.vue"] + found_components = [] + for expected_file in expected_vue_files: + matching_refs = [ref for ref in vue_refs if expected_file in ref.get("uri", "")] + if matching_refs: + found_components.append(expected_file) + + assert len(found_components) > 0, ( + f"Should find references in at least one component that uses the store. " + f"Expected any of {expected_vue_files}, found references in: {[ref.get('uri', '') for ref in vue_refs]}" + ) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_cross_file_references_typescript_to_vue(self, language_server: SolidLanguageServer) -> None: + types_file = os.path.join("src", "types", "index.ts") + types_symbols = language_server.request_document_symbols(types_file).get_all_symbols_and_roots() + types_symbol_names = [s.get("name") for s in types_symbols[0]] + + # Operation type is used in calculator.ts and CalculatorInput.vue + assert "Operation" in types_symbol_names, "Operation type should exist in types file" + + operation_symbol = None + for sym in types_symbols[0]: + if sym.get("name") == "Operation": + operation_symbol = sym + break + + if not operation_symbol or "selectionRange" not in operation_symbol: + pytest.skip("Operation type symbol not found - test fixture may need updating") + + # Request references for the Operation type + sel_start = operation_symbol["selectionRange"]["start"] + refs = language_server.request_references(types_file, sel_start["line"], sel_start["character"]) + + # Verify we found references: definition + usage in calculator.ts and Vue files + assert len(refs) >= 2, f"Operation type should have at least 2 references (definition + usages), found {len(refs)} references" + + # The Operation type should be referenced in both .ts files (calculator.ts) and potentially .vue files + all_ref_uris = [ref.get("uri", "") for ref in refs] + has_ts_refs = any(".ts" in uri and "types" not in uri for uri in all_ref_uris) + + assert has_ts_refs, ( + f"Operation type should be referenced in TypeScript files like calculator.ts. Found references in: {all_ref_uris}" + ) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_reference_deduplication(self, language_server: SolidLanguageServer) -> None: + store_file = os.path.join("src", "stores", "calculator.ts") + store_symbols = language_server.request_document_symbols(store_file).get_all_symbols_and_roots() + + # Find a commonly-used symbol (useCalculatorStore) + store_symbol = None + for sym in store_symbols[0]: + if sym.get("name") == "useCalculatorStore": + store_symbol = sym + break + + if not store_symbol or "selectionRange" not in store_symbol: + pytest.skip("useCalculatorStore symbol not found - test fixture may need updating") + + # Request references + sel_start = store_symbol["selectionRange"]["start"] + refs = language_server.request_references(store_file, sel_start["line"], sel_start["character"]) + + # Check for duplicate references (same file, line, and character) + seen_locations = set() + duplicates = [] + + for ref in refs: + # Create a unique key for this reference location + uri = ref.get("uri", "") + if "range" in ref: + line = ref["range"]["start"]["line"] + character = ref["range"]["start"]["character"] + location_key = (uri, line, character) + + if location_key in seen_locations: + duplicates.append(location_key) + else: + seen_locations.add(location_key) + + assert len(duplicates) == 0, ( + f"Found {len(duplicates)} duplicate reference locations. " + f"The dual-LSP architecture should deduplicate references from both servers. " + f"Duplicates: {duplicates}" + ) + + +@pytest.mark.vue +class TestVueEdgeCases: + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_symbol_tree_structure(self, language_server: SolidLanguageServer) -> None: + full_tree = language_server.request_full_symbol_tree() + + # Helper to extract all file paths from symbol tree + def extract_paths_from_tree(symbols, paths=None): + """Recursively extract file paths from symbol tree.""" + if paths is None: + paths = [] + + if isinstance(symbols, list): + for symbol in symbols: + extract_paths_from_tree(symbol, paths) + elif isinstance(symbols, dict): + # Check if this symbol has a location + if "location" in symbols and "uri" in symbols["location"]: + uri = symbols["location"]["uri"] + # Extract the path after file:// + if uri.startswith("file://"): + file_path = uri[7:] # Remove "file://" + paths.append(file_path) + + # Recurse into children + if "children" in symbols: + extract_paths_from_tree(symbols["children"], paths) + + return paths + + all_paths = extract_paths_from_tree(full_tree) + + # Verify we have files from expected directories + # Note: Symbol tree may include duplicate paths (one per symbol in file) + components_files = list({p for p in all_paths if "components" in p and ".vue" in p}) + stores_files = list({p for p in all_paths if "stores" in p and ".ts" in p}) + composables_files = list({p for p in all_paths if "composables" in p and ".ts" in p}) + + assert len(components_files) == 3, ( + f"Symbol tree should include exactly 3 unique Vue components (CalculatorButton, CalculatorInput, CalculatorDisplay). " + f"Found {len(components_files)} unique component files: {[p.split('/')[-1] for p in sorted(components_files)]}" + ) + + assert len(stores_files) == 1, ( + f"Symbol tree should include exactly 1 unique store file (calculator.ts). " + f"Found {len(stores_files)} unique store files: {[p.split('/')[-1] for p in sorted(stores_files)]}" + ) + + assert len(composables_files) == 2, ( + f"Symbol tree should include exactly 2 unique composable files (useFormatter.ts, useTheme.ts). " + f"Found {len(composables_files)} unique composable files: {[p.split('/')[-1] for p in sorted(composables_files)]}" + ) + + # Verify specific expected files exist in the tree + expected_files = [ + "CalculatorButton.vue", + "CalculatorInput.vue", + "CalculatorDisplay.vue", + "App.vue", + "calculator.ts", + "useFormatter.ts", + "useTheme.ts", + ] + + for expected_file in expected_files: + matching_files = [p for p in all_paths if expected_file in p] + assert len(matching_files) > 0, f"Expected file '{expected_file}' should be in symbol tree. All paths: {all_paths}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_document_overview(self, language_server: SolidLanguageServer) -> None: + app_file = os.path.join("src", "App.vue") + overview = language_server.request_document_overview(app_file) + + # Overview should return a list of top-level symbols + assert isinstance(overview, list), f"Overview should be a list, got: {type(overview)}" + assert len(overview) >= 1, f"App.vue should have at least 1 top-level symbol in overview, got {len(overview)}" + + # Extract symbol names from overview + symbol_names = [s.get("name") for s in overview if isinstance(s, dict)] + + # Vue LSP returns SFC structure (template/script/style sections) for .vue files + # This is expected behavior - overview shows the file's high-level structure + assert len(symbol_names) >= 1, ( + f"Should have at least 1 symbol name in overview (e.g., 'App' or SFC section), got {len(symbol_names)}: {symbol_names}" + ) + + # Test overview for a TypeScript file + store_file = os.path.join("src", "stores", "calculator.ts") + store_overview = language_server.request_document_overview(store_file) + + assert isinstance(store_overview, list), f"Store overview should be a list, got: {type(store_overview)}" + assert len(store_overview) >= 1, f"calculator.ts should have at least 1 top-level symbol in overview, got {len(store_overview)}" + + store_symbol_names = [s.get("name") for s in store_overview if isinstance(s, dict)] + assert "useCalculatorStore" in store_symbol_names, ( + f"useCalculatorStore should be in store file overview. Found {len(store_symbol_names)} symbols: {store_symbol_names}" + ) + + # Test overview for another Vue component + button_file = os.path.join("src", "components", "CalculatorButton.vue") + button_overview = language_server.request_document_overview(button_file) + + assert isinstance(button_overview, list), f"Button overview should be a list, got: {type(button_overview)}" + assert len(button_overview) >= 1, ( + f"CalculatorButton.vue should have at least 1 top-level symbol in overview, got {len(button_overview)}" + ) + + # For Vue files, overview provides SFC structure which is useful for navigation + # The detailed symbols are available via request_document_symbols + button_symbol_names = [s.get("name") for s in button_overview if isinstance(s, dict)] + assert len(button_symbol_names) >= 1, ( + f"CalculatorButton.vue should have at least 1 symbol in overview (e.g., 'CalculatorButton' or SFC section). " + f"Found {len(button_symbol_names)} symbols: {button_symbol_names}" + ) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_directory_overview(self, language_server: SolidLanguageServer) -> None: + components_dir = os.path.join("src", "components") + dir_overview = language_server.request_dir_overview(components_dir) + + # Directory overview should be a dict mapping file paths to symbol lists + assert isinstance(dir_overview, dict), f"Directory overview should be a dict, got: {type(dir_overview)}" + assert len(dir_overview) == 3, f"src/components directory should have exactly 3 files in overview, got {len(dir_overview)}" + + # Verify all component files are included + expected_components = ["CalculatorButton.vue", "CalculatorInput.vue", "CalculatorDisplay.vue"] + + for expected_component in expected_components: + # Find files that match this component name + matching_files = [path for path in dir_overview.keys() if expected_component in path] + assert len(matching_files) == 1, ( + f"Component '{expected_component}' should appear exactly once in directory overview. " + f"Found {len(matching_files)} matches. All files: {list(dir_overview.keys())}" + ) + + # Verify the matched file has symbols + file_path = matching_files[0] + symbols = dir_overview[file_path] + assert isinstance(symbols, list), f"Symbols for {file_path} should be a list, got {type(symbols)}" + assert len(symbols) >= 1, f"Component {expected_component} should have at least 1 symbol in overview, got {len(symbols)}" + + # Test overview for stores directory + stores_dir = os.path.join("src", "stores") + stores_overview = language_server.request_dir_overview(stores_dir) + + assert isinstance(stores_overview, dict), f"Stores overview should be a dict, got: {type(stores_overview)}" + assert len(stores_overview) == 1, ( + f"src/stores directory should have exactly 1 file (calculator.ts) in overview, got {len(stores_overview)}" + ) + + # Verify calculator.ts is included + calculator_files = [path for path in stores_overview.keys() if "calculator.ts" in path] + assert len(calculator_files) == 1, ( + f"calculator.ts should appear exactly once in stores directory overview. " + f"Found {len(calculator_files)} matches. All files: {list(stores_overview.keys())}" + ) + + # Verify the store file has symbols + store_path = calculator_files[0] + store_symbols = stores_overview[store_path] + store_symbol_names = [s.get("name") for s in store_symbols if isinstance(s, dict)] + assert "useCalculatorStore" in store_symbol_names, ( + f"calculator.ts should have useCalculatorStore in overview. Found {len(store_symbol_names)} symbols: {store_symbol_names}" + ) + + # Test overview for composables directory + composables_dir = os.path.join("src", "composables") + composables_overview = language_server.request_dir_overview(composables_dir) + + assert isinstance(composables_overview, dict), f"Composables overview should be a dict, got: {type(composables_overview)}" + assert len(composables_overview) == 2, ( + f"src/composables directory should have exactly 2 files in overview, got {len(composables_overview)}" + ) + + # Verify composable files are included + expected_composables = ["useFormatter.ts", "useTheme.ts"] + for expected_composable in expected_composables: + matching_files = [path for path in composables_overview.keys() if expected_composable in path] + assert len(matching_files) == 1, ( + f"Composable '{expected_composable}' should appear exactly once in directory overview. " + f"Found {len(matching_files)} matches. All files: {list(composables_overview.keys())}" + ) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + is_callback = s["name"].endswith(" callback") + is_vue_selector = "." in s["name"] or ":" in s["name"] + is_vue_block = s["name"] in {"script setup", "style scoped"} + if has_malformed_name( + s, + whitespace_allowed=is_callback or is_vue_selector or is_vue_block, + period_allowed=is_vue_selector, + colon_allowed=is_vue_selector, + parenthesis_allowed=is_callback or is_vue_selector, + ): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/vue/test_vue_diagnostics.py b/test/solidlsp/vue/test_vue_diagnostics.py new file mode 100644 index 0000000..08ac2fe --- /dev/null +++ b/test/solidlsp/vue/test_vue_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.vue +class TestVueDiagnostics: + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/DiagnosticsSample.vue", + (), + min_count=1, + ) diff --git a/test/solidlsp/vue/test_vue_error_cases.py b/test/solidlsp/vue/test_vue_error_cases.py new file mode 100644 index 0000000..3a6aac8 --- /dev/null +++ b/test/solidlsp/vue/test_vue_error_cases.py @@ -0,0 +1,365 @@ +import os +import sys + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + +pytestmark = pytest.mark.vue + +IS_WINDOWS = sys.platform == "win32" + + +class TypeScriptServerBehavior: + """Platform-specific TypeScript language server behavior for invalid positions. + + On Windows: TS server returns empty results for invalid positions + On macOS/Linux: TS server raises exceptions with "Bad line number" or "Debug Failure" + """ + + @staticmethod + def raises_on_invalid_position() -> bool: + return not IS_WINDOWS + + @staticmethod + def returns_empty_on_invalid_position() -> bool: + return IS_WINDOWS + + +class TestVueInvalidPositions: + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_negative_line_number(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + result = language_server.request_containing_symbol(file_path, -1, 0) + + assert result is None or result == {}, f"Negative line number should return None or empty dict, got: {result}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_negative_character_number(self, language_server: SolidLanguageServer) -> None: + """Test requesting containing symbol with negative character number. + + Expected behavior: Should return None or empty dict, not crash. + """ + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Request containing symbol at invalid negative character + result = language_server.request_containing_symbol(file_path, 10, -1) + + # Should handle gracefully - return None or empty dict + assert result is None or result == {}, f"Negative character number should return None or empty dict, got: {result}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_line_number_beyond_file_length(self, language_server: SolidLanguageServer) -> None: + """Test requesting containing symbol beyond file length. + + Expected behavior: Raises IndexError when trying to access line beyond file bounds. + This happens in the wrapper code before even reaching the language server. + """ + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Request containing symbol at line 99999 (way beyond file length) + # The wrapper code will raise an IndexError when checking if the line is empty + with pytest.raises(IndexError) as exc_info: + language_server.request_containing_symbol(file_path, 99999, 0) + + # Verify it's an index error for list access + assert "list index out of range" in str(exc_info.value), f"Expected 'list index out of range' error, got: {exc_info.value}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_character_number_beyond_line_length(self, language_server: SolidLanguageServer) -> None: + """Test requesting containing symbol beyond line length. + + Expected behavior: Should return None or empty dict, not crash. + """ + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Request containing symbol at character 99999 (way beyond line length) + result = language_server.request_containing_symbol(file_path, 10, 99999) + + # Should handle gracefully - return None or empty dict + assert result is None or result == {}, f"Character beyond line length should return None or empty dict, got: {result}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_references_at_negative_line(self, language_server: SolidLanguageServer) -> None: + """Test requesting references with negative line number.""" + from solidlsp.ls_exceptions import SolidLSPException + + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + if TypeScriptServerBehavior.returns_empty_on_invalid_position(): + result = language_server.request_references(file_path, -1, 0) + assert result == [], f"Expected empty list on Windows, got: {result}" + else: + with pytest.raises(SolidLSPException) as exc_info: + language_server.request_references(file_path, -1, 0) + assert "Bad line number" in str(exc_info.value) or "Debug Failure" in str(exc_info.value) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_definition_at_invalid_position(self, language_server: SolidLanguageServer) -> None: + """Test requesting definition at invalid position.""" + from solidlsp.ls_exceptions import SolidLSPException + + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + if TypeScriptServerBehavior.returns_empty_on_invalid_position(): + result = language_server.request_definition(file_path, -1, 0) + assert result == [], f"Expected empty list on Windows, got: {result}" + else: + with pytest.raises(SolidLSPException) as exc_info: + language_server.request_definition(file_path, -1, 0) + assert "Bad line number" in str(exc_info.value) or "Debug Failure" in str(exc_info.value) + + +class TestVueNonExistentFiles: + """Tests for handling non-existent files.""" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_requesting_on_nonexistent_file(self, language_server: SolidLanguageServer) -> None: + """Test requesting references from non-existent file. + + Expected behavior: Should raise FileNotFoundError or return empty list. + """ + nonexistent_file = os.path.join("src", "components", "NonExistent.vue") + + with pytest.raises(FileNotFoundError): + language_server.request_references(nonexistent_file, 10, 10) + with pytest.raises(FileNotFoundError): + language_server.request_definition(nonexistent_file, 10, 10) + with pytest.raises(FileNotFoundError): + language_server.request_document_symbols(nonexistent_file) + + +class TestVueUndefinedSymbols: + """Tests for handling undefined or unreferenced symbols.""" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_references_for_unreferenced_symbol(self, language_server: SolidLanguageServer) -> None: + """Test requesting references for a symbol that has no references. + + Expected behavior: Should return empty list (only the definition itself if include_self=True). + """ + # Find a symbol that likely has no external references + file_path = os.path.join("src", "components", "CalculatorButton.vue") + + # Get document symbols + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + # Find pressCount - this is exposed but may not be referenced elsewhere + press_count_symbol = next((s for s in symbols[0] if s.get("name") == "pressCount"), None) + + if not press_count_symbol or "selectionRange" not in press_count_symbol: + pytest.skip("pressCount symbol not found - test fixture may need updating") + + # Request references without include_self + sel_start = press_count_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + + # Should return a list (may be empty or contain only definition) + assert isinstance(refs, list), f"request_references should return a list, got {type(refs)}" + + # For an unreferenced symbol, should have 0-1 references (0 without include_self, 1 with) + # The exact count depends on the language server implementation + assert len(refs) <= 5, ( + f"pressCount should have few or no external references. " + f"Got {len(refs)} references. This is not necessarily an error, just documenting behavior." + ) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_containing_symbol_at_whitespace_only_line(self, language_server: SolidLanguageServer) -> None: + """Test requesting containing symbol at a whitespace-only line. + + Expected behavior: Should return None, empty dict, or the parent symbol. + """ + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Try position at line 1 (typically a blank line or template start in Vue SFCs) + result = language_server.request_containing_symbol(file_path, 1, 0) + + # Should handle gracefully - return None, empty dict, or a valid parent symbol + assert result is None or result == {} or isinstance(result, dict), ( + f"Whitespace line should return None, empty dict, or valid symbol. Got: {result}" + ) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_definition_at_keyword_position(self, language_server: SolidLanguageServer) -> None: + """Test requesting definition at language keyword position. + + Expected behavior: Should return empty list or handle gracefully. + """ + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Try to get definition at a keyword like "const", "import", etc. + # Line 2 typically has "import" statement - try position on "import" keyword + result = language_server.request_definition(file_path, 2, 0) + + # Should handle gracefully - return empty list or valid definitions + assert isinstance(result, list), f"request_definition should return a list, got {type(result)}" + + +class TestVueEdgeCasePositions: + """Tests for edge case positions (0,0 and file boundaries).""" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_containing_symbol_at_file_start(self, language_server: SolidLanguageServer) -> None: + """Test requesting containing symbol at position (0,0). + + Expected behavior: Should return None, empty dict, or a valid symbol. + This position typically corresponds to the start of the file (e.g., <template> tag). + """ + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Request containing symbol at position 0,0 (file start) + result = language_server.request_containing_symbol(file_path, 0, 0) + + # Should handle gracefully + assert result is None or result == {} or isinstance(result, dict), ( + f"Position 0,0 should return None, empty dict, or valid symbol. Got: {result}" + ) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_references_at_file_start(self, language_server: SolidLanguageServer) -> None: + """Test requesting references at position (0,0). + + Expected behavior: Should return None or empty list. + """ + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Request references at position 0,0 (file start) + result = language_server.request_references(file_path, 0, 0) + + # Should handle gracefully + assert result is None or isinstance(result, list), f"Position 0,0 should return None or list. Got: {type(result)}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_definition_at_file_start(self, language_server: SolidLanguageServer) -> None: + """Test requesting definition at position (0,0). + + Expected behavior: Should return empty list. + """ + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Request definition at position 0,0 (file start) + result = language_server.request_definition(file_path, 0, 0) + + # Should handle gracefully + assert isinstance(result, list), f"request_definition should return a list. Got: {type(result)}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_containing_symbol_in_template_section(self, language_server: SolidLanguageServer) -> None: + """Test requesting containing symbol in the template section. + + Expected behavior: Template positions typically have no containing symbol (return None or empty). + The Vue language server may not track template symbols the same way as script symbols. + """ + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Position likely in template section (early in file, before <script setup>) + # Exact line depends on file structure, but line 5-10 is often template + result = language_server.request_containing_symbol(file_path, 5, 10) + + # Should handle gracefully - template doesn't have containing symbols in the same way + assert result is None or result == {} or isinstance(result, dict), ( + f"Template position should return None, empty dict, or valid symbol. Got: {result}" + ) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_zero_character_positions(self, language_server: SolidLanguageServer) -> None: + """Test requesting symbols at character position 0 (start of lines). + + Expected behavior: Should handle gracefully, may or may not find symbols. + """ + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Test multiple lines at character 0 + for line in [0, 10, 20, 30]: + result = language_server.request_containing_symbol(file_path, line, 0) + + # Should handle gracefully + assert result is None or result == {} or isinstance(result, dict), ( + f"Line {line}, character 0 should return None, empty dict, or valid symbol. Got: {result}" + ) + + +class TestVueTypescriptFileErrors: + """Tests for error handling in TypeScript files within Vue projects.""" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_typescript_file_invalid_position(self, language_server: SolidLanguageServer) -> None: + """Test requesting symbols from TypeScript file at invalid position. + + Expected behavior: Should handle gracefully. + """ + file_path = os.path.join("src", "stores", "calculator.ts") + + # Request containing symbol at invalid position + result = language_server.request_containing_symbol(file_path, -1, -1) + + # Should handle gracefully + assert result is None or result == {}, f"Invalid position in .ts file should return None or empty dict. Got: {result}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_typescript_file_beyond_bounds(self, language_server: SolidLanguageServer) -> None: + """Test requesting symbols from TypeScript file beyond file bounds. + + Expected behavior: Raises IndexError when trying to access line beyond file bounds. + """ + file_path = os.path.join("src", "stores", "calculator.ts") + + # Request containing symbol beyond file bounds + # The wrapper code will raise an IndexError when checking if the line is empty + with pytest.raises(IndexError) as exc_info: + language_server.request_containing_symbol(file_path, 99999, 99999) + + # Verify it's an index error for list access + assert "list index out of range" in str(exc_info.value), f"Expected 'list index out of range' error, got: {exc_info.value}" + + +class TestVueReferenceEdgeCases: + """Tests for edge cases in reference finding.""" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_referencing_symbols_at_invalid_position(self, language_server: SolidLanguageServer) -> None: + """Test requesting referencing symbols at invalid position.""" + from solidlsp.ls_exceptions import SolidLSPException + + file_path = os.path.join("src", "stores", "calculator.ts") + + if TypeScriptServerBehavior.returns_empty_on_invalid_position(): + result = list(language_server.request_referencing_symbols(file_path, -1, -1, include_self=False)) + assert result == [], f"Expected empty list on Windows, got: {result}" + else: + with pytest.raises(SolidLSPException) as exc_info: + list(language_server.request_referencing_symbols(file_path, -1, -1, include_self=False)) + assert "Bad line number" in str(exc_info.value) or "Debug Failure" in str(exc_info.value) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_defining_symbol_at_invalid_position(self, language_server: SolidLanguageServer) -> None: + """Test requesting defining symbol at invalid position.""" + from solidlsp.ls_exceptions import SolidLSPException + + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + if TypeScriptServerBehavior.returns_empty_on_invalid_position(): + result = language_server.request_defining_symbol(file_path, -1, -1) + assert result is None, f"Expected None on Windows, got: {result}" + else: + with pytest.raises(SolidLSPException) as exc_info: + language_server.request_defining_symbol(file_path, -1, -1) + assert "Bad line number" in str(exc_info.value) or "Debug Failure" in str(exc_info.value) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_referencing_symbols_beyond_file_bounds(self, language_server: SolidLanguageServer) -> None: + """Test requesting referencing symbols beyond file bounds.""" + from solidlsp.ls_exceptions import SolidLSPException + + file_path = os.path.join("src", "stores", "calculator.ts") + + if TypeScriptServerBehavior.returns_empty_on_invalid_position(): + result = list(language_server.request_referencing_symbols(file_path, 99999, 99999, include_self=False)) + assert result == [], f"Expected empty list on Windows, got: {result}" + else: + with pytest.raises(SolidLSPException) as exc_info: + list(language_server.request_referencing_symbols(file_path, 99999, 99999, include_self=False)) + assert "Bad line number" in str(exc_info.value) or "Debug Failure" in str(exc_info.value) diff --git a/test/solidlsp/vue/test_vue_rename.py b/test/solidlsp/vue/test_vue_rename.py new file mode 100644 index 0000000..01ffbe2 --- /dev/null +++ b/test/solidlsp/vue/test_vue_rename.py @@ -0,0 +1,241 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + +pytestmark = pytest.mark.vue + + +class TestVueRename: + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_rename_function_within_single_file(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + handle_digit_symbol = next((s for s in symbols[0] if s.get("name") == "handleDigit"), None) + + if not handle_digit_symbol or "selectionRange" not in handle_digit_symbol: + pytest.skip("handleDigit symbol not found - test fixture may need updating") + + sel_start = handle_digit_symbol["selectionRange"]["start"] + + workspace_edit = language_server.request_rename_symbol_edit(file_path, sel_start["line"], sel_start["character"], "processDigit") + + assert workspace_edit is not None, "Should return WorkspaceEdit for rename operation" + + has_changes = "changes" in workspace_edit and workspace_edit["changes"] + has_document_changes = "documentChanges" in workspace_edit and workspace_edit["documentChanges"] + + assert has_changes or has_document_changes, "WorkspaceEdit should contain either 'changes' or 'documentChanges'" + + if has_changes: + changes = workspace_edit["changes"] + assert len(changes) > 0, "Should have at least one file with changes" + + calculator_input_files = [uri for uri in changes.keys() if "CalculatorInput.vue" in uri] + assert len(calculator_input_files) > 0, f"Should have edits for CalculatorInput.vue. Found edits for: {list(changes.keys())}" + + file_edits = changes[calculator_input_files[0]] + assert len(file_edits) > 0, "Should have at least one TextEdit for the renamed symbol" + + for edit in file_edits: + assert "range" in edit, "TextEdit should have a range" + assert "newText" in edit, "TextEdit should have newText" + assert edit["newText"] == "processDigit", f"newText should be 'processDigit', got {edit['newText']}" + + assert "start" in edit["range"], "Range should have start position" + assert "end" in edit["range"], "Range should have end position" + assert "line" in edit["range"]["start"], "Start position should have line number" + assert "character" in edit["range"]["start"], "Start position should have character offset" + + elif has_document_changes: + document_changes = workspace_edit["documentChanges"] + assert isinstance(document_changes, list), "documentChanges should be a list" + assert len(document_changes) > 0, "Should have at least one document change" + + calculator_input_changes = [dc for dc in document_changes if "CalculatorInput.vue" in dc.get("textDocument", {}).get("uri", "")] + assert len(calculator_input_changes) > 0, "Should have edits for CalculatorInput.vue" + + for change in calculator_input_changes: + assert "textDocument" in change, "Document change should have textDocument" + assert "edits" in change, "Document change should have edits" + + edits = change["edits"] + assert len(edits) > 0, "Should have at least one TextEdit for the renamed symbol" + + for edit in edits: + assert "range" in edit, "TextEdit should have a range" + assert "newText" in edit, "TextEdit should have newText" + assert edit["newText"] == "processDigit", f"newText should be 'processDigit', got {edit['newText']}" + + assert "start" in edit["range"], "Range should have start position" + assert "end" in edit["range"], "Range should have end position" + assert "line" in edit["range"]["start"], "Start position should have line number" + assert "character" in edit["range"]["start"], "Start position should have character offset" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_rename_composable_function_cross_file(self, language_server: SolidLanguageServer) -> None: + composable_file = os.path.join("src", "composables", "useFormatter.ts") + + symbols = language_server.request_document_symbols(composable_file).get_all_symbols_and_roots() + use_formatter_symbol = next((s for s in symbols[0] if s.get("name") == "useFormatter"), None) + + if not use_formatter_symbol or "selectionRange" not in use_formatter_symbol: + pytest.skip("useFormatter symbol not found - test fixture may need updating") + + sel_start = use_formatter_symbol["selectionRange"]["start"] + + workspace_edit = language_server.request_rename_symbol_edit( + composable_file, sel_start["line"], sel_start["character"], "useNumberFormatter" + ) + + assert workspace_edit is not None, "Should return WorkspaceEdit for cross-file rename" + + has_changes = "changes" in workspace_edit and workspace_edit["changes"] + has_document_changes = "documentChanges" in workspace_edit and workspace_edit["documentChanges"] + + assert has_changes or has_document_changes, "WorkspaceEdit should contain either 'changes' or 'documentChanges'" + + if has_changes: + changes = workspace_edit["changes"] + assert len(changes) > 0, "Should have at least one file with changes" + + composable_files = [uri for uri in changes.keys() if "useFormatter.ts" in uri] + assert len(composable_files) > 0, f"Should have edits for useFormatter.ts (definition). Found edits for: {list(changes.keys())}" + + for uri, edits in changes.items(): + assert len(edits) > 0, f"File {uri} should have at least one edit" + + for edit in edits: + assert "range" in edit, f"TextEdit in {uri} should have a range" + assert "newText" in edit, f"TextEdit in {uri} should have newText" + assert edit["newText"] == "useNumberFormatter", f"newText should be 'useNumberFormatter', got {edit['newText']}" + assert "start" in edit["range"], f"Range in {uri} should have start position" + assert "end" in edit["range"], f"Range in {uri} should have end position" + + elif has_document_changes: + document_changes = workspace_edit["documentChanges"] + assert isinstance(document_changes, list), "documentChanges should be a list" + assert len(document_changes) > 0, "Should have at least one document change" + + composable_changes = [dc for dc in document_changes if "useFormatter.ts" in dc.get("textDocument", {}).get("uri", "")] + assert len(composable_changes) > 0, ( + f"Should have edits for useFormatter.ts (definition). Found changes for: {[dc.get('textDocument', {}).get('uri', '') for dc in document_changes]}" + ) + + for change in document_changes: + assert "textDocument" in change, "Document change should have textDocument" + assert "edits" in change, "Document change should have edits" + + uri = change["textDocument"]["uri"] + edits = change["edits"] + assert len(edits) > 0, f"File {uri} should have at least one edit" + + for edit in edits: + assert "range" in edit, f"TextEdit in {uri} should have a range" + assert "newText" in edit, f"TextEdit in {uri} should have newText" + assert edit["newText"] == "useNumberFormatter", f"newText should be 'useNumberFormatter', got {edit['newText']}" + assert "start" in edit["range"], f"Range in {uri} should have start position" + assert "end" in edit["range"], f"Range in {uri} should have end position" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_rename_verifies_correct_file_paths_and_ranges(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "App.vue") + + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + app_title_symbol = next((s for s in symbols[0] if s.get("name") == "appTitle"), None) + + if not app_title_symbol or "selectionRange" not in app_title_symbol: + pytest.skip("appTitle symbol not found - test fixture may need updating") + + sel_start = app_title_symbol["selectionRange"]["start"] + + workspace_edit = language_server.request_rename_symbol_edit( + file_path, sel_start["line"], sel_start["character"], "applicationTitle" + ) + + assert workspace_edit is not None, "Should return WorkspaceEdit for rename operation" + assert isinstance(workspace_edit, dict), "WorkspaceEdit should be a dictionary" + + has_changes = "changes" in workspace_edit and workspace_edit["changes"] + has_document_changes = "documentChanges" in workspace_edit and workspace_edit["documentChanges"] + + assert has_changes or has_document_changes, "WorkspaceEdit must have 'changes' or 'documentChanges'" + + if has_changes: + changes = workspace_edit["changes"] + + assert isinstance(changes, dict), "changes should be a dict mapping URIs to TextEdit lists" + + assert len(changes) > 0, "Should have edits for at least one file" + + for uri, edits in changes.items(): + assert isinstance(uri, str), f"URI should be a string, got {type(uri)}" + assert uri.startswith("file://"), f"URI should start with 'file://', got {uri}" + + assert isinstance(edits, list), f"Edits for {uri} should be a list, got {type(edits)}" + assert len(edits) > 0, f"Should have at least one edit for {uri}" + + for idx, edit in enumerate(edits): + assert isinstance(edit, dict), f"Edit {idx} in {uri} should be a dict, got {type(edit)}" + + assert "range" in edit, f"Edit {idx} in {uri} missing 'range'" + assert "newText" in edit, f"Edit {idx} in {uri} missing 'newText'" + + range_obj = edit["range"] + assert "start" in range_obj, f"Edit {idx} range in {uri} missing 'start'" + assert "end" in range_obj, f"Edit {idx} range in {uri} missing 'end'" + + for pos_name in ["start", "end"]: + pos = range_obj[pos_name] + assert "line" in pos, f"Edit {idx} range {pos_name} in {uri} missing 'line'" + assert "character" in pos, f"Edit {idx} range {pos_name} in {uri} missing 'character'" + assert isinstance(pos["line"], int), f"Line should be int, got {type(pos['line'])}" + assert isinstance(pos["character"], int), f"Character should be int, got {type(pos['character'])}" + assert pos["line"] >= 0, f"Line number should be >= 0, got {pos['line']}" + assert pos["character"] >= 0, f"Character offset should be >= 0, got {pos['character']}" + + assert isinstance(edit["newText"], str), f"newText should be string, got {type(edit['newText'])}" + assert edit["newText"] == "applicationTitle", f"newText should be 'applicationTitle', got {edit['newText']}" + + elif has_document_changes: + document_changes = workspace_edit["documentChanges"] + assert isinstance(document_changes, list), "documentChanges should be a list" + assert len(document_changes) > 0, "Should have at least one document change" + + for change in document_changes: + assert isinstance(change, dict), "Each document change should be a dict" + assert "textDocument" in change, "Document change should have textDocument" + assert "edits" in change, "Document change should have edits" + + text_doc = change["textDocument"] + assert "uri" in text_doc, "textDocument should have uri" + assert text_doc["uri"].startswith("file://"), f"URI should start with 'file://', got {text_doc['uri']}" + + edits = change["edits"] + assert isinstance(edits, list), "edits should be a list" + assert len(edits) > 0, "Should have at least one edit" + + for idx, edit in enumerate(edits): + assert isinstance(edit, dict), f"Edit {idx} in {text_doc['uri']} should be a dict, got {type(edit)}" + + assert "range" in edit, f"Edit {idx} in {text_doc['uri']} missing 'range'" + assert "newText" in edit, f"Edit {idx} in {text_doc['uri']} missing 'newText'" + + range_obj = edit["range"] + assert "start" in range_obj, f"Edit {idx} range in {text_doc['uri']} missing 'start'" + assert "end" in range_obj, f"Edit {idx} range in {text_doc['uri']} missing 'end'" + + for pos_name in ["start", "end"]: + pos = range_obj[pos_name] + assert "line" in pos, f"Edit {idx} range {pos_name} in {text_doc['uri']} missing 'line'" + assert "character" in pos, f"Edit {idx} range {pos_name} in {text_doc['uri']} missing 'character'" + assert isinstance(pos["line"], int), f"Line should be int, got {type(pos['line'])}" + assert isinstance(pos["character"], int), f"Character should be int, got {type(pos['character'])}" + assert pos["line"] >= 0, f"Line number should be >= 0, got {pos['line']}" + assert pos["character"] >= 0, f"Character offset should be >= 0, got {pos['character']}" + + assert isinstance(edit["newText"], str), f"newText should be string, got {type(edit['newText'])}" + assert edit["newText"] == "applicationTitle", f"newText should be 'applicationTitle', got {edit['newText']}" diff --git a/test/solidlsp/vue/test_vue_symbol_retrieval.py b/test/solidlsp/vue/test_vue_symbol_retrieval.py new file mode 100644 index 0000000..04a22e7 --- /dev/null +++ b/test/solidlsp/vue/test_vue_symbol_retrieval.py @@ -0,0 +1,249 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind + +pytestmark = pytest.mark.vue + + +class TestVueSymbolRetrieval: + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_request_containing_symbol_script_setup_function(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # First, get the document symbols to find the handleDigit function + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + handle_digit_symbol = next((s for s in symbols[0] if s.get("name") == "handleDigit"), None) + + if not handle_digit_symbol or "range" not in handle_digit_symbol: + pytest.skip("handleDigit symbol not found - test fixture may need updating") + + # Get a position inside the handleDigit function body + # We'll use a line a few lines after the function start + func_start_line = handle_digit_symbol["range"]["start"]["line"] + position_inside_func = func_start_line + 1 + position_character = 4 + + # Request the containing symbol for this position + containing_symbol = language_server.request_containing_symbol( + file_path, position_inside_func, position_character, include_body=True + ) + + # Verify we found the correct containing symbol + assert containing_symbol is not None, "Should find containing symbol inside handleDigit function" + assert containing_symbol["name"] == "handleDigit", f"Expected handleDigit, got {containing_symbol.get('name')}" + assert containing_symbol["kind"] in [ + SymbolKind.Function, + SymbolKind.Method, + SymbolKind.Variable, + ], f"Expected function-like kind, got {containing_symbol.get('kind')}" + + # Verify the body is included if available + if "body" in containing_symbol: + assert "handleDigit" in containing_symbol["body"].get_text(), "Function body should contain function name" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_request_containing_symbol_computed_property(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Find the formattedDisplay computed property + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + formatted_display_symbol = next((s for s in symbols[0] if s.get("name") == "formattedDisplay"), None) + + if not formatted_display_symbol or "range" not in formatted_display_symbol: + pytest.skip("formattedDisplay computed property not found - test fixture may need updating") + + # Get a position inside the computed property body + computed_start_line = formatted_display_symbol["range"]["start"]["line"] + position_inside_computed = computed_start_line + 1 + position_character = 4 + + # Request the containing symbol for this position + containing_symbol = language_server.request_containing_symbol( + file_path, position_inside_computed, position_character, include_body=True + ) + + # Verify we found the correct containing symbol + # The language server returns the arrow function inside computed() rather than + # the variable name. This is technically correct from LSP's perspective. + assert containing_symbol is not None, "Should find containing symbol inside computed property" + assert containing_symbol["name"] in [ + "formattedDisplay", + "computed() callback", + ], f"Expected formattedDisplay or computed() callback, got {containing_symbol.get('name')}" + assert containing_symbol["kind"] in [ + SymbolKind.Property, + SymbolKind.Variable, + SymbolKind.Function, + ], f"Expected property/variable/function kind for computed, got {containing_symbol.get('kind')}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_request_containing_symbol_no_containing_symbol(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Position in the import statements at the top of the script setup + # Line 1-6 contain imports in CalculatorInput.vue + import_line = 2 + import_character = 10 + + # Request containing symbol for a position in the imports + containing_symbol = language_server.request_containing_symbol(file_path, import_line, import_character) + + # Should return None or empty dictionary for positions without containing symbol + assert containing_symbol is None or containing_symbol == {}, ( + f"Expected None or empty dict for import position, got {containing_symbol}" + ) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_request_referencing_symbols_store_function(self, language_server: SolidLanguageServer) -> None: + store_file = os.path.join("src", "stores", "calculator.ts") + + # Find the 'add' action in the calculator store + symbols = language_server.request_document_symbols(store_file).get_all_symbols_and_roots() + add_symbol = next((s for s in symbols[0] if s.get("name") == "add"), None) + + if not add_symbol or "selectionRange" not in add_symbol: + pytest.skip("add action not found in calculator store - test fixture may need updating") + + # Request referencing symbols for the add action (include_self=True to get at least the definition) + sel_start = add_symbol["selectionRange"]["start"] + ref_symbols = [ + ref.symbol + for ref in language_server.request_referencing_symbols(store_file, sel_start["line"], sel_start["character"], include_self=True) + ] + + assert isinstance(ref_symbols, list), f"request_referencing_symbols should return a list, got {type(ref_symbols)}" + + for symbol in ref_symbols: + assert "name" in symbol, "Referencing symbol should have a name" + assert "kind" in symbol, "Referencing symbol should have a kind" + + vue_refs = [ + symbol for symbol in ref_symbols if "location" in symbol and "uri" in symbol["location"] and ".vue" in symbol["location"]["uri"] + ] + + if len(vue_refs) > 0: + calculator_input_refs = [ + ref + for ref in vue_refs + if "location" in ref and "uri" in ref["location"] and "CalculatorInput.vue" in ref["location"]["uri"] + ] + for ref in calculator_input_refs: + assert "name" in ref, "Reference should have name" + assert "location" in ref, "Reference should have location" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_request_referencing_symbols_composable(self, language_server: SolidLanguageServer) -> None: + composable_file = os.path.join("src", "composables", "useFormatter.ts") + + # Find the useFormatter composable function + symbols = language_server.request_document_symbols(composable_file).get_all_symbols_and_roots() + use_formatter_symbol = next((s for s in symbols[0] if s.get("name") == "useFormatter"), None) + + if not use_formatter_symbol or "selectionRange" not in use_formatter_symbol: + pytest.skip("useFormatter composable not found - test fixture may need updating") + + # Request referencing symbols for the composable + sel_start = use_formatter_symbol["selectionRange"]["start"] + ref_symbols = [ + ref.symbol for ref in language_server.request_referencing_symbols(composable_file, sel_start["line"], sel_start["character"]) + ] + + # Verify we found references - useFormatter is imported and used in CalculatorInput.vue + assert len(ref_symbols) >= 1, ( + f"useFormatter should have at least 1 reference (used in CalculatorInput.vue), found {len(ref_symbols)} references" + ) + + # Check for references in Vue components + vue_refs = [ + symbol for symbol in ref_symbols if "location" in symbol and "uri" in symbol["location"] and ".vue" in symbol["location"]["uri"] + ] + + # CalculatorInput.vue imports and uses useFormatter + assert len(vue_refs) >= 1, f"Should find at least 1 Vue component reference to useFormatter, found {len(vue_refs)}" + + # Verify we found reference in CalculatorInput.vue specifically + has_calculator_input_ref = any( + "CalculatorInput.vue" in ref["location"]["uri"] for ref in vue_refs if "location" in ref and "uri" in ref["location"] + ) + assert has_calculator_input_ref, ( + f"Should find reference to useFormatter in CalculatorInput.vue. " + f"Found references in: {[ref['location']['uri'] for ref in vue_refs if 'location' in ref and 'uri' in ref['location']]}" + ) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_vue_component_cross_references(self, language_server: SolidLanguageServer) -> None: + input_file = os.path.join("src", "components", "CalculatorInput.vue") + button_file = os.path.join("src", "components", "CalculatorButton.vue") + + definitions = language_server.request_definition(input_file, 4, 10) + + assert len(definitions) == 1, f"Should find exactly 1 definition for CalculatorButton import, got {len(definitions)}" + assert "CalculatorButton.vue" in definitions[0]["relativePath"], ( + f"Definition should point to CalculatorButton.vue, got {definitions[0]['relativePath']}" + ) + + refs = language_server.request_references(input_file, 4, 10) + + assert len(refs) >= 2, ( + f"Should find at least 2 references to CalculatorButton (import + template usages). " + f"In CalculatorInput.vue, CalculatorButton is imported and used ~7 times in template. Found {len(refs)} references" + ) + + button_symbols = language_server.request_document_symbols(button_file).get_all_symbols_and_roots() + symbol_names = [s.get("name") for s in button_symbols[0]] + + assert "Props" in symbol_names, "CalculatorButton.vue should have Props interface" + assert "handleClick" in symbol_names, "CalculatorButton.vue should have handleClick function" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_request_defining_symbol_import_resolution(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Find the import position for useCalculatorStore + # In CalculatorInput.vue (0-indexed lines): + # Line 2: import { useCalculatorStore } from '@/stores/calculator' + # Line 8: const store = useCalculatorStore() + # We'll request definition at the position of "useCalculatorStore" in the usage line + defining_symbol = language_server.request_defining_symbol(file_path, 8, 18) + + if defining_symbol is None: + # Some language servers may not support go-to-definition at usage sites + # Try at line 2 (import statement) instead + defining_symbol = language_server.request_defining_symbol(file_path, 2, 18) + + # Verify we found a defining symbol + assert defining_symbol is not None, "Should find defining symbol for useCalculatorStore" + assert "name" in defining_symbol, "Defining symbol should have a name" + assert defining_symbol.get("name") in [ + "useCalculatorStore", + "calculator", + ], f"Expected useCalculatorStore or calculator, got {defining_symbol.get('name')}" + + # Verify it points to the store file + if "location" in defining_symbol and "uri" in defining_symbol["location"]: + assert "calculator.ts" in defining_symbol["location"]["uri"], ( + f"Should point to calculator.ts, got {defining_symbol['location']['uri']}" + ) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_request_defining_symbol_component_import(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + definitions = language_server.request_definition(file_path, 4, 10) + + assert len(definitions) > 0, "Should find definition for CalculatorButton import" + + definition = definitions[0] + assert definition["relativePath"] is not None, "Definition should have a relative path" + assert "CalculatorButton.vue" in definition["relativePath"], ( + f"Should point to CalculatorButton.vue, got {definition['relativePath']}" + ) + + assert definition["range"]["start"]["line"] == 0, "Definition should point to start of .vue file" + + defining_symbol = language_server.request_defining_symbol(file_path, 4, 10) + assert defining_symbol is None or "name" in defining_symbol, "If defining_symbol is found, it should have a name" diff --git a/test/solidlsp/yaml_ls/__init__.py b/test/solidlsp/yaml_ls/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/solidlsp/yaml_ls/test_yaml_basic.py b/test/solidlsp/yaml_ls/test_yaml_basic.py new file mode 100644 index 0000000..da60e48 --- /dev/null +++ b/test/solidlsp/yaml_ls/test_yaml_basic.py @@ -0,0 +1,191 @@ +""" +Basic integration tests for the YAML language server functionality. + +These tests validate the functionality of the language server APIs +like request_document_symbols using the YAML test repository. +""" + +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.yaml +class TestYAMLLanguageServerBasics: + """Test basic functionality of the YAML language server.""" + + @pytest.mark.parametrize("language_server", [Language.YAML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.YAML], indirect=True) + def test_yaml_language_server_initialization(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that YAML language server can be initialized successfully.""" + assert language_server is not None + assert language_server.language == Language.YAML + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.YAML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.YAML], indirect=True) + def test_yaml_config_file_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test document symbols detection in config.yaml with specific symbol verification.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.yaml").get_all_symbols_and_roots() + + assert all_symbols is not None, "Should return symbols for config.yaml" + assert len(all_symbols) > 0, f"Should find symbols in config.yaml, found {len(all_symbols)}" + + # Verify specific top-level keys are detected + symbol_names = [sym.get("name") for sym in all_symbols] + assert "app" in symbol_names, "Should detect 'app' key in config.yaml" + assert "database" in symbol_names, "Should detect 'database' key in config.yaml" + assert "logging" in symbol_names, "Should detect 'logging' key in config.yaml" + assert "features" in symbol_names, "Should detect 'features' key in config.yaml" + + # Verify nested symbols exist (child keys under 'app') + assert "name" in symbol_names, "Should detect nested 'name' key" + assert "port" in symbol_names, "Should detect nested 'port' key" + assert "debug" in symbol_names, "Should detect nested 'debug' key" + + # Check symbol kinds are appropriate (LSP kinds: 2=module/namespace, 15=string, 16=number, 17=boolean) + app_symbol = next((s for s in all_symbols if s.get("name") == "app"), None) + assert app_symbol is not None, "Should find 'app' symbol" + assert app_symbol.get("kind") == 2, "Top-level object should have kind 2 (module/namespace)" + + port_symbol = next((s for s in all_symbols if s.get("name") == "port"), None) + assert port_symbol is not None, "Should find 'port' symbol" + assert port_symbol.get("kind") == 16, "'port' should have kind 16 (number)" + + debug_symbol = next((s for s in all_symbols if s.get("name") == "debug"), None) + assert debug_symbol is not None, "Should find 'debug' symbol" + assert debug_symbol.get("kind") == 17, "'debug' should have kind 17 (boolean)" + + @pytest.mark.parametrize("language_server", [Language.YAML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.YAML], indirect=True) + def test_yaml_services_file_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test symbol detection in services.yml Docker Compose file.""" + all_symbols, root_symbols = language_server.request_document_symbols("services.yml").get_all_symbols_and_roots() + + assert all_symbols is not None, "Should return symbols for services.yml" + assert len(all_symbols) > 0, f"Should find symbols in services.yml, found {len(all_symbols)}" + + # Verify specific top-level keys from Docker Compose file + symbol_names = [sym.get("name") for sym in all_symbols] + assert "version" in symbol_names, "Should detect 'version' key" + assert "services" in symbol_names, "Should detect 'services' key" + assert "networks" in symbol_names, "Should detect 'networks' key" + assert "volumes" in symbol_names, "Should detect 'volumes' key" + + # Verify service names + assert "web" in symbol_names, "Should detect 'web' service" + assert "api" in symbol_names, "Should detect 'api' service" + assert "database" in symbol_names, "Should detect 'database' service" + + # Check that arrays are properly detected + ports_symbols = [s for s in all_symbols if s.get("name") == "ports"] + assert len(ports_symbols) > 0, "Should find 'ports' arrays in services" + # Arrays should have kind 18 + for ports_sym in ports_symbols: + assert ports_sym.get("kind") == 18, "'ports' should have kind 18 (array)" + + @pytest.mark.parametrize("language_server", [Language.YAML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.YAML], indirect=True) + def test_yaml_data_file_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test symbol detection in data.yaml file with array structures.""" + all_symbols, root_symbols = language_server.request_document_symbols("data.yaml").get_all_symbols_and_roots() + + assert all_symbols is not None, "Should return symbols for data.yaml" + assert len(all_symbols) > 0, f"Should find symbols in data.yaml, found {len(all_symbols)}" + + # Verify top-level keys + symbol_names = [sym.get("name") for sym in all_symbols] + assert "users" in symbol_names, "Should detect 'users' array" + assert "projects" in symbol_names, "Should detect 'projects' array" + + # Verify array elements (indexed by position) + # data.yaml has user entries and project entries + assert "id" in symbol_names, "Should detect 'id' fields in array elements" + assert "name" in symbol_names, "Should detect 'name' fields" + assert "email" in symbol_names, "Should detect 'email' fields" + assert "roles" in symbol_names, "Should detect 'roles' arrays" + + @pytest.mark.parametrize("language_server", [Language.YAML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.YAML], indirect=True) + def test_yaml_symbols_with_body(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test request_document_symbols with body extraction.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.yaml").get_all_symbols_and_roots() + + assert all_symbols is not None, "Should return symbols for config.yaml" + assert len(all_symbols) > 0, "Should have symbols" + + # Find the 'app' symbol and verify its body + app_symbol = next((s for s in all_symbols if s.get("name") == "app"), None) + assert app_symbol is not None, "Should find 'app' symbol" + + # Check that body exists and contains expected content + assert "body" in app_symbol, "'app' symbol should have body" + app_body = app_symbol["body"].get_text() + assert "app:" in app_body, "Body should start with 'app:'" + assert "name: test-application" in app_body, "Body should contain 'name' field" + assert "version: 1.0.0" in app_body, "Body should contain 'version' field" + assert "port: 8080" in app_body, "Body should contain 'port' field" + assert "debug: true" in app_body, "Body should contain 'debug' field" + + # Find a simple string value symbol and verify its body + name_symbols = [s for s in all_symbols if s.get("name") == "name" and "body" in s] + assert len(name_symbols) > 0, "Should find 'name' symbols with bodies" + # At least one should contain "test-application" + assert any("test-application" in s["body"].get_text() for s in name_symbols), "Should find name with test-application" + + # Find the database symbol and check its body + database_symbol = next((s for s in all_symbols if s.get("name") == "database"), None) + assert database_symbol is not None, "Should find 'database' symbol" + assert "body" in database_symbol, "'database' symbol should have body" + db_body = database_symbol["body"].get_text() + assert "database:" in db_body, "Body should start with 'database:'" + assert "host: localhost" in db_body, "Body should contain host configuration" + assert "port: 5432" in db_body, "Body should contain port configuration" + + @pytest.mark.parametrize("language_server", [Language.YAML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.YAML], indirect=True) + def test_yaml_symbol_ranges(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that symbols have proper range information.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.yaml").get_all_symbols_and_roots() + + assert all_symbols is not None + assert len(all_symbols) > 0 + + # Check the 'app' symbol range + app_symbol = next((s for s in all_symbols if s.get("name") == "app"), None) + assert app_symbol is not None, "Should find 'app' symbol" + assert "range" in app_symbol, "'app' symbol should have range" + + app_range = app_symbol["range"] + assert "start" in app_range, "Range should have start" + assert "end" in app_range, "Range should have end" + assert app_range["start"]["line"] == 1, "'app' should start at line 1 (0-indexed, actual line 2)" + # The app block spans from line 2 to line 7 in the file (1-indexed) + # In 0-indexed LSP coordinates: line 1 (start) to line 6 (end) + assert app_range["end"]["line"] == 6, "'app' should end at line 6 (0-indexed)" + + # Check a nested symbol range + port_symbols = [s for s in all_symbols if s.get("name") == "port"] + assert len(port_symbols) > 0, "Should find 'port' symbols" + # Find the one under 'app' (should be at line 4 in 0-indexed, actual line 5) + app_port = next((s for s in port_symbols if s["range"]["start"]["line"] == 4), None) + assert app_port is not None, "Should find 'port' under 'app'" + assert app_port["range"]["start"]["character"] == 2, "'port' should be indented 2 spaces" + + @pytest.mark.parametrize("language_server", [Language.YAML], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/yaml_ls/test_yaml_diagnostics.py b/test/solidlsp/yaml_ls/test_yaml_diagnostics.py new file mode 100644 index 0000000..4ba774c --- /dev/null +++ b/test/solidlsp/yaml_ls/test_yaml_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.yaml +class TestYamlDiagnostics: + @pytest.mark.parametrize("language_server", [Language.YAML], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.yaml", + (), + min_count=1, + ) diff --git a/test/solidlsp/zig/test_zig_basic.py b/test/solidlsp/zig/test_zig_basic.py new file mode 100644 index 0000000..7108774 --- /dev/null +++ b/test/solidlsp/zig/test_zig_basic.py @@ -0,0 +1,372 @@ +""" +Basic integration tests for Zig language server functionality. + +These tests validate symbol finding and navigation capabilities using the Zig Language Server (ZLS). +Note: ZLS requires files to be open in the editor to find cross-file references (performance optimization). +""" + +import os +import sys + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.zig +@pytest.mark.skipif( + sys.platform == "win32", reason="ZLS is disabled on Windows - cross-file references don't work reliably. Reason unknown." +) +class TestZigLanguageServer: + """Test Zig language server symbol finding and navigation capabilities. + + NOTE: All tests are skipped on Windows as ZLS is disabled on that platform + due to unreliable cross-file reference functionality. Reason unknown. + """ + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + def test_find_symbols_in_main(self, language_server: SolidLanguageServer) -> None: + """Test finding specific symbols in main.zig.""" + file_path = os.path.join("src", "main.zig") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + assert symbols is not None + assert len(symbols) > 0 + + # Extract symbol names from the returned structure + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} + + # Verify specific symbols exist + assert "main" in symbol_names, "main function not found" + assert "greeting" in symbol_names, "greeting function not found" + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + def test_find_symbols_in_calculator(self, language_server: SolidLanguageServer) -> None: + """Test finding Calculator struct and its methods.""" + file_path = os.path.join("src", "calculator.zig") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + assert symbols is not None + assert len(symbols) > 0 + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + # Find Calculator struct + calculator_symbol = None + for sym in symbol_list: + if sym.get("name") == "Calculator": + calculator_symbol = sym + break + + assert calculator_symbol is not None, "Calculator struct not found" + # ZLS may use different symbol kinds for structs (14 = Namespace, 5 = Class, 23 = Struct) + assert calculator_symbol.get("kind") in [ + SymbolKind.Class, + SymbolKind.Struct, + SymbolKind.Namespace, + 5, + 14, + 23, + ], "Calculator should be a struct/class/namespace" + + # Check for Calculator methods (init, add, subtract, etc.) + # Methods might be in children or at the same level + all_symbols = [] + for sym in symbol_list: + all_symbols.append(sym.get("name")) + if "children" in sym: + for child in sym["children"]: + all_symbols.append(child.get("name")) + + # Verify exact calculator methods exist + expected_methods = {"init", "add", "subtract", "multiply", "divide"} + found_methods = set(all_symbols) & expected_methods + assert found_methods == expected_methods, f"Expected exactly {expected_methods}, found: {found_methods}" + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + def test_find_symbols_in_math_utils(self, language_server: SolidLanguageServer) -> None: + """Test finding functions in math_utils.zig.""" + file_path = os.path.join("src", "math_utils.zig") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + assert symbols is not None + assert len(symbols) > 0 + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} + + # Verify math utility functions exist + assert "factorial" in symbol_names, "factorial function not found" + assert "isPrime" in symbol_names, "isPrime function not found" + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + def test_find_references_within_file(self, language_server: SolidLanguageServer) -> None: + """Test finding references within the same file.""" + file_path = os.path.join("src", "calculator.zig") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + # Find Calculator struct + calculator_symbol = None + for sym in symbol_list: + if sym.get("name") == "Calculator": + calculator_symbol = sym + break + + assert calculator_symbol is not None, "Calculator struct not found" + + # Find references to Calculator within the same file + sel_range = calculator_symbol.get("selectionRange", calculator_symbol.get("range")) + assert sel_range is not None, "Calculator symbol has no range information" + + sel_start = sel_range["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + + assert refs is not None + assert isinstance(refs, list) + # ZLS finds references within the same file + # Calculator is used in 4 test usages (lines 45, 51, 57, 63) + # Note: ZLS may not include the declaration itself as a reference + assert len(refs) >= 4, f"Should find at least 4 Calculator references within calculator.zig, found {len(refs)}" + + # Verify we found the test usages + ref_lines = sorted([ref["range"]["start"]["line"] for ref in refs]) + test_lines = [44, 50, 56, 62] # 0-indexed: tests at lines 45, 51, 57, 63 + for line in test_lines: + assert line in ref_lines, f"Should find Calculator reference at line {line + 1}, found at lines {[l + 1 for l in ref_lines]}" + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + @pytest.mark.skipif( + sys.platform == "win32", reason="ZLS cross-file references don't work reliably on Windows - URI path handling issues" + ) + @pytest.mark.xfail( + sys.platform == "darwin", + reason="ZLS cross-file references are flaky on macOS CI: sometimes finds 0 Calculator references in main.zig", + strict=False, + ) + def test_cross_file_references_with_open_files(self, language_server: SolidLanguageServer) -> None: + """ + Test finding cross-file references with files open. + + ZLS limitation: Cross-file references (textDocument/references) only work when + target files are open. This is a performance optimization in ZLS. + + NOTE: Disabled on Windows as cross-file references cannot be made to work reliably + due to URI path handling differences between Windows and Unix systems. + """ + import time + + # Open the files that contain references to enable cross-file search + with language_server.open_file("build.zig"): + with language_server.open_file(os.path.join("src", "main.zig")): + with language_server.open_file(os.path.join("src", "calculator.zig")): + # Give ZLS a moment to analyze the open files + time.sleep(1) + + # Find Calculator struct + symbols = language_server.request_document_symbols(os.path.join("src", "calculator.zig")).get_all_symbols_and_roots() + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + calculator_symbol = None + for sym in symbol_list: + if sym.get("name") == "Calculator": + calculator_symbol = sym + break + + assert calculator_symbol is not None, "Calculator struct not found" + + sel_range = calculator_symbol.get("selectionRange", calculator_symbol.get("range")) + assert sel_range is not None, "Calculator symbol has no range information" + + # Find references to Calculator + sel_start = sel_range["start"] + refs = language_server.request_references( + os.path.join("src", "calculator.zig"), sel_start["line"], sel_start["character"] + ) + + assert refs is not None + assert isinstance(refs, list) + + # With files open, ZLS should find cross-file references + main_refs = [ref for ref in refs if "main.zig" in ref.get("uri", "")] + + assert len(main_refs) >= 1, f"Should find at least 1 Calculator reference in main.zig, found {len(main_refs)}" + + # Verify exact location in main.zig (line 8, 0-indexed: 7) + main_ref_line = main_refs[0]["range"]["start"]["line"] + assert main_ref_line == 7, ( + f"Calculator reference in main.zig should be at line 8 (0-indexed: 7), found at line {main_ref_line + 1}" + ) + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + def test_cross_file_references_within_file(self, language_server: SolidLanguageServer) -> None: + """ + Test that ZLS finds references within the same file. + + Note: ZLS is designed to be lightweight and only analyzes files that are explicitly opened. + Cross-file references require manually opening the relevant files first. + """ + # Find references to Calculator from calculator.zig + file_path = os.path.join("src", "calculator.zig") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + calculator_symbol = None + for sym in symbol_list: + if sym.get("name") == "Calculator": + calculator_symbol = sym + break + + assert calculator_symbol is not None, "Calculator struct not found" + + sel_range = calculator_symbol.get("selectionRange", calculator_symbol.get("range")) + assert sel_range is not None, "Calculator symbol has no range information" + + sel_start = sel_range["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + + assert refs is not None + assert isinstance(refs, list) + + # ZLS finds references within the same file + # Calculator is used in 4 test usages (lines 45, 51, 57, 63) + # Note: ZLS may not include the declaration itself as a reference + assert len(refs) >= 4, f"Should find at least 4 Calculator references within calculator.zig, found {len(refs)}" + + # Verify we found the test usages + ref_lines = sorted([ref["range"]["start"]["line"] for ref in refs]) + test_lines = [44, 50, 56, 62] # 0-indexed: tests at lines 45, 51, 57, 63 + for line in test_lines: + assert line in ref_lines, f"Should find Calculator reference at line {line + 1}, found at lines {[l + 1 for l in ref_lines]}" + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + @pytest.mark.skipif( + sys.platform == "win32", reason="ZLS cross-file references don't work reliably on Windows - URI path handling issues" + ) + def test_go_to_definition_cross_file(self, language_server: SolidLanguageServer) -> None: + """ + Test go-to-definition from main.zig to calculator.zig. + + ZLS capability: Go-to-definition (textDocument/definition) works cross-file + WITHOUT requiring files to be open. + + NOTE: Disabled on Windows as cross-file references cannot be made to work reliably + due to URI path handling differences between Windows and Unix systems. + """ + file_path = os.path.join("src", "main.zig") + + # Line 8: const calc = calculator.Calculator.init(); + # Test go-to-definition for Calculator + definitions = language_server.request_definition(file_path, 7, 25) # Position of "Calculator" + + assert definitions is not None + assert isinstance(definitions, list) + assert len(definitions) > 0, "Should find definition of Calculator" + + # Should point to calculator.zig + calc_def = definitions[0] + assert "calculator.zig" in calc_def.get("uri", ""), "Definition should be in calculator.zig" + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + @pytest.mark.skipif( + sys.platform == "win32", reason="ZLS cross-file references don't work reliably on Windows - URI path handling issues" + ) + def test_cross_file_function_usage(self, language_server: SolidLanguageServer) -> None: + """Test finding usage of functions from math_utils in main.zig. + + NOTE: Disabled on Windows as cross-file references cannot be made to work reliably + due to URI path handling differences between Windows and Unix systems. + """ + # Line 23 in main.zig: const factorial_result = math_utils.factorial(5); + definitions = language_server.request_definition(os.path.join("src", "main.zig"), 22, 40) # Position of "factorial" + + assert definitions is not None + assert isinstance(definitions, list) + + if len(definitions) > 0: + # Should find factorial definition in math_utils.zig + math_def = [d for d in definitions if "math_utils.zig" in d.get("uri", "")] + assert len(math_def) > 0, "Should find factorial definition in math_utils.zig" + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + def test_verify_cross_file_imports(self, language_server: SolidLanguageServer) -> None: + """Verify that our test files have proper cross-file imports.""" + # Verify main.zig imports + main_symbols = language_server.request_document_symbols(os.path.join("src", "main.zig")).get_all_symbols_and_roots() + assert main_symbols is not None + main_list = main_symbols[0] if isinstance(main_symbols, tuple) else main_symbols + main_names = {sym.get("name") for sym in main_list if isinstance(sym, dict)} + + # main.zig should have main and greeting functions + assert "main" in main_names, "main function should be in main.zig" + assert "greeting" in main_names, "greeting function should be in main.zig" + + # Verify calculator.zig exports Calculator + calc_symbols = language_server.request_document_symbols(os.path.join("src", "calculator.zig")).get_all_symbols_and_roots() + assert calc_symbols is not None + calc_list = calc_symbols[0] if isinstance(calc_symbols, tuple) else calc_symbols + calc_names = {sym.get("name") for sym in calc_list if isinstance(sym, dict)} + assert "Calculator" in calc_names, "Calculator struct should be in calculator.zig" + + # Verify math_utils.zig exports functions + math_symbols = language_server.request_document_symbols(os.path.join("src", "math_utils.zig")).get_all_symbols_and_roots() + assert math_symbols is not None + math_list = math_symbols[0] if isinstance(math_symbols, tuple) else math_symbols + math_names = {sym.get("name") for sym in math_list if isinstance(sym, dict)} + assert "factorial" in math_names, "factorial function should be in math_utils.zig" + assert "isPrime" in math_names, "isPrime function should be in math_utils.zig" + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + def test_hover_information(self, language_server: SolidLanguageServer) -> None: + """Test hover information for symbols.""" + file_path = os.path.join("src", "main.zig") + + # Get hover info for the main function + hover_info = language_server.request_hover(file_path, 4, 8) # Position of "main" function + + assert hover_info is not None, "Should provide hover information for main function" + + # Hover info could be a dict with 'contents' or a string + if isinstance(hover_info, dict): + assert "contents" in hover_info or "value" in hover_info, "Hover should have contents" + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + def test_full_symbol_tree(self, language_server: SolidLanguageServer) -> None: + """Test that full symbol tree is not empty.""" + symbols = language_server.request_full_symbol_tree() + + assert symbols is not None + assert len(symbols) > 0, "Symbol tree should not be empty" + + # The tree should have at least one root node + root = symbols[0] + assert isinstance(root, dict), "Root should be a dict" + assert "name" in root, "Root should have a name" + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s, whitespace_allowed=True): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/diagnostics_sample.zig", + (), + min_count=1, + ) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..d82e273 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3758 @@ +version = 1 +revision = 3 +requires-python = ">=3.11, <3.15" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", + "python_full_version < '3.12'", +] + +[[package]] +name = "accessible-pygments" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, +] + +[[package]] +name = "agno" +version = "2.6.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docstring-parser" }, + { name = "gitpython" }, + { name = "h11" }, + { name = "httpx", extra = ["http2"] }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/b7/17c737b7a482b204eeb287e2c1616ed4abea93d71501810c37f269d002fb/agno-2.6.6.tar.gz", hash = "sha256:c8767ff7528a4f8b7aa8c0543592c3b9cc3c2e3f5a2852555a1c493408df3acf", size = 2025094, upload-time = "2026-05-14T07:35:01.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/d1/1a9078840c00cd2b6a2968dd11cc107c72290424ff7dbd4baf9a87617ac3/agno-2.6.6-py3-none-any.whl", hash = "sha256:ba8795e020d2a036af312734c1a796b5064466d4c45ab6d64ea51ec5ec2b2cd7", size = 2405077, upload-time = "2026-05-14T07:34:59.063Z" }, +] + +[[package]] +name = "alabaster" +version = "0.7.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776, upload-time = "2024-01-10T00:56:10.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anthropic" +version = "0.59.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/52daff015f5a1f24eec891b3041f5f816712fea8b5113dc76638bcbc23d8/anthropic-0.59.0.tar.gz", hash = "sha256:d710d1ef0547ebbb64b03f219e44ba078e83fc83752b96a9b22e9726b523fd8f", size = 425679, upload-time = "2025-07-23T16:23:16.901Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/b1/03f680393eac04afd8f2be44ee0e39e033c40faf43dbc1c11764b07a2687/anthropic-0.59.0-py3-none-any.whl", hash = "sha256:cbc8b3dccef66ad6435c4fa1d317e5ebb092399a4b88b33a09dc4bf3944c3183", size = 293057, upload-time = "2025-07-23T16:23:14.934Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "apeye" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apeye-core" }, + { name = "domdf-python-tools" }, + { name = "platformdirs" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/6b/cc65e31843d7bfda8313a9dc0c77a21e8580b782adca53c7cb3e511fe023/apeye-1.4.1.tar.gz", hash = "sha256:14ea542fad689e3bfdbda2189a354a4908e90aee4bf84c15ab75d68453d76a36", size = 99219, upload-time = "2023-08-14T15:32:41.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/7b/2d63664777b3e831ac1b1d8df5bbf0b7c8bee48e57115896080890527b1b/apeye-1.4.1-py3-none-any.whl", hash = "sha256:44e58a9104ec189bf42e76b3a7fe91e2b2879d96d48e9a77e5e32ff699c9204e", size = 107989, upload-time = "2023-08-14T15:32:40.064Z" }, +] + +[[package]] +name = "apeye-core" +version = "1.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "domdf-python-tools" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/4c/4f108cfd06923bd897bf992a6ecb6fb122646ee7af94d7f9a64abd071d4c/apeye_core-1.1.5.tar.gz", hash = "sha256:5de72ed3d00cc9b20fea55e54b7ab8f5ef8500eb33a5368bc162a5585e238a55", size = 96511, upload-time = "2024-01-30T17:45:48.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/9f/fa9971d2a0c6fef64c87ba362a493a4f230eff4ea8dfb9f4c7cbdf71892e/apeye_core-1.1.5-py3-none-any.whl", hash = "sha256:dc27a93f8c9e246b3b238c5ea51edf6115ab2618ef029b9f2d9a190ec8228fbf", size = 99286, upload-time = "2024-01-30T17:45:46.764Z" }, +] + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "autodocsumm" +version = "0.2.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/f28dea12fae1d1ad1e706f5cf6d16e8d735f305ebee86fd9390e099bd27d/autodocsumm-0.2.15.tar.gz", hash = "sha256:eaf431e7a5a39e41a215311173c8b95e83859059df1ccf3b79c64bf3d5582b3c", size = 46674, upload-time = "2026-03-26T20:44:07.074Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/3d/4357a0f685c0a2ae7132ac91905bec565e64f9ba63b079f7ec5da46e3597/autodocsumm-0.2.15-py3-none-any.whl", hash = "sha256:dbe6fabcaeae4540748ea9b3443eb76c2692e063d44f004f67c424610a5aca9a", size = 14852, upload-time = "2026-03-26T20:44:05.273Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "bottle" +version = "0.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/71/cca6167c06d00c81375fd668719df245864076d284f7cb46a694cbeb5454/bottle-0.13.4.tar.gz", hash = "sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47", size = 98717, upload-time = "2025-06-15T10:08:59.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/f6/b55ec74cfe68c6584163faa311503c20b0da4c09883a41e8e00d6726c954/bottle-0.13.4-py2.py3-none-any.whl", hash = "sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e", size = 103807, upload-time = "2025-06-15T10:08:57.691Z" }, +] + +[[package]] +name = "cachecontrol" +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" }, +] + +[package.optional-dependencies] +filecache = [ + { name = "filelock" }, +] + +[[package]] +name = "cattrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/ec/ba18945e7d6e55a58364d9fb2e46049c1c2998b3d805f19b703f14e81057/cattrs-26.1.0.tar.gz", hash = "sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40", size = 495672, upload-time = "2026-02-18T22:15:19.406Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096", size = 73054, upload-time = "2026-02-18T22:15:17.958Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994, upload-time = "2026-03-15T18:50:30.102Z" }, + { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697, upload-time = "2026-03-15T18:50:31.654Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673, upload-time = "2026-03-15T18:50:33.433Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120, upload-time = "2026-03-15T18:50:35.105Z" }, + { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911, upload-time = "2026-03-15T18:50:36.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516, upload-time = "2026-03-15T18:50:38.228Z" }, + { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795, upload-time = "2026-03-15T18:50:39.664Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833, upload-time = "2026-03-15T18:50:41.552Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920, upload-time = "2026-03-15T18:50:43.33Z" }, + { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951, upload-time = "2026-03-15T18:50:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703, upload-time = "2026-03-15T18:50:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857, upload-time = "2026-03-15T18:50:47.563Z" }, + { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751, upload-time = "2026-03-15T18:50:49.234Z" }, + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "clr-loader" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/46/7eea92b6aa2d68af78e049cbecec5f757f1aad44ecdecdc16bbad7eead51/clr_loader-0.3.1.tar.gz", hash = "sha256:2e073e9aaf49d1ae2f56ecba27987ad5fb68be4bcd9dd34a5bed8f0e4e128366", size = 86805, upload-time = "2026-04-18T17:49:44.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/da/ec1a6e36624000b6df0dd61183c42342ee5814c073315e802cadaad04d2f/clr_loader-0.3.1-py3-none-any.whl", hash = "sha256:cbad189de20d202a7d621956b0fc38049e13c9bf7ca2923441eff725cd121aa1", size = 55730, upload-time = "2026-04-18T17:49:42.99Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + +[[package]] +name = "cryptography" +version = "48.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, + { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, + { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, + { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, + { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, + { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" }, + { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" }, + { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" }, + { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" }, + { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" }, + { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" }, + { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" }, + { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" }, + { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, + { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, + { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, + { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" }, + { url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" }, +] + +[[package]] +name = "cssutils" +version = "2.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/9f/329d26121fe165be44b1dfff21aa0dc348f04633931f1d20ed6cf448a236/cssutils-2.11.1.tar.gz", hash = "sha256:0563a76513b6af6eebbe788c3bf3d01c920e46b3f90c8416738c5cfc773ff8e2", size = 711657, upload-time = "2024-06-04T15:51:39.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/ec/bb273b7208c606890dc36540fe667d06ce840a6f62f9fae7e658fcdc90fb/cssutils-2.11.1-py3-none-any.whl", hash = "sha256:a67bfdfdff4f3867fab43698ec4897c1a828eca5973f4073321b3bccaf1199b1", size = 385747, upload-time = "2024-06-04T15:51:37.499Z" }, +] + +[[package]] +name = "debugpy" +version = "1.8.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/cd8080344452e4874aae67c40d8940e2b4d47b01601a8fd9f44786c757c7/debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33", size = 1645207, upload-time = "2026-01-29T23:03:28.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/56/c3baf5cbe4dd77427fd9aef99fcdade259ad128feeb8a786c246adb838e5/debugpy-1.8.20-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:eada6042ad88fa1571b74bd5402ee8b86eded7a8f7b827849761700aff171f1b", size = 2208318, upload-time = "2026-01-29T23:03:36.481Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7d/4fa79a57a8e69fe0d9763e98d1110320f9ecd7f1f362572e3aafd7417c9d/debugpy-1.8.20-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:7de0b7dfeedc504421032afba845ae2a7bcc32ddfb07dae2c3ca5442f821c344", size = 3171493, upload-time = "2026-01-29T23:03:37.775Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f2/1e8f8affe51e12a26f3a8a8a4277d6e60aa89d0a66512f63b1e799d424a4/debugpy-1.8.20-cp311-cp311-win32.whl", hash = "sha256:773e839380cf459caf73cc533ea45ec2737a5cc184cf1b3b796cd4fd98504fec", size = 5209240, upload-time = "2026-01-29T23:03:39.109Z" }, + { url = "https://files.pythonhosted.org/packages/d5/92/1cb532e88560cbee973396254b21bece8c5d7c2ece958a67afa08c9f10dc/debugpy-1.8.20-cp311-cp311-win_amd64.whl", hash = "sha256:1f7650546e0eded1902d0f6af28f787fa1f1dbdbc97ddabaf1cd963a405930cb", size = 5233481, upload-time = "2026-01-29T23:03:40.659Z" }, + { url = "https://files.pythonhosted.org/packages/14/57/7f34f4736bfb6e00f2e4c96351b07805d83c9a7b33d28580ae01374430f7/debugpy-1.8.20-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:4ae3135e2089905a916909ef31922b2d733d756f66d87345b3e5e52b7a55f13d", size = 2550686, upload-time = "2026-01-29T23:03:42.023Z" }, + { url = "https://files.pythonhosted.org/packages/ab/78/b193a3975ca34458f6f0e24aaf5c3e3da72f5401f6054c0dfd004b41726f/debugpy-1.8.20-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:88f47850a4284b88bd2bfee1f26132147d5d504e4e86c22485dfa44b97e19b4b", size = 4310588, upload-time = "2026-01-29T23:03:43.314Z" }, + { url = "https://files.pythonhosted.org/packages/c1/55/f14deb95eaf4f30f07ef4b90a8590fc05d9e04df85ee379712f6fb6736d7/debugpy-1.8.20-cp312-cp312-win32.whl", hash = "sha256:4057ac68f892064e5f98209ab582abfee3b543fb55d2e87610ddc133a954d390", size = 5331372, upload-time = "2026-01-29T23:03:45.526Z" }, + { url = "https://files.pythonhosted.org/packages/a1/39/2bef246368bd42f9bd7cba99844542b74b84dacbdbea0833e610f384fee8/debugpy-1.8.20-cp312-cp312-win_amd64.whl", hash = "sha256:a1a8f851e7cf171330679ef6997e9c579ef6dd33c9098458bd9986a0f4ca52e3", size = 5372835, upload-time = "2026-01-29T23:03:47.245Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/fc500524cc6f104a9d049abc85a0a8b3f0d14c0a39b9c140511c61e5b40b/debugpy-1.8.20-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:5dff4bb27027821fdfcc9e8f87309a28988231165147c31730128b1c983e282a", size = 2539560, upload-time = "2026-01-29T23:03:48.738Z" }, + { url = "https://files.pythonhosted.org/packages/90/83/fb33dcea789ed6018f8da20c5a9bc9d82adc65c0c990faed43f7c955da46/debugpy-1.8.20-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:84562982dd7cf5ebebfdea667ca20a064e096099997b175fe204e86817f64eaf", size = 4293272, upload-time = "2026-01-29T23:03:50.169Z" }, + { url = "https://files.pythonhosted.org/packages/a6/25/b1e4a01bfb824d79a6af24b99ef291e24189080c93576dfd9b1a2815cd0f/debugpy-1.8.20-cp313-cp313-win32.whl", hash = "sha256:da11dea6447b2cadbf8ce2bec59ecea87cc18d2c574980f643f2d2dfe4862393", size = 5331208, upload-time = "2026-01-29T23:03:51.547Z" }, + { url = "https://files.pythonhosted.org/packages/13/f7/a0b368ce54ffff9e9028c098bd2d28cfc5b54f9f6c186929083d4c60ba58/debugpy-1.8.20-cp313-cp313-win_amd64.whl", hash = "sha256:eb506e45943cab2efb7c6eafdd65b842f3ae779f020c82221f55aca9de135ed7", size = 5372930, upload-time = "2026-01-29T23:03:53.585Z" }, + { url = "https://files.pythonhosted.org/packages/33/2e/f6cb9a8a13f5058f0a20fe09711a7b726232cd5a78c6a7c05b2ec726cff9/debugpy-1.8.20-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9c74df62fc064cd5e5eaca1353a3ef5a5d50da5eb8058fcef63106f7bebe6173", size = 2538066, upload-time = "2026-01-29T23:03:54.999Z" }, + { url = "https://files.pythonhosted.org/packages/c5/56/6ddca50b53624e1ca3ce1d1e49ff22db46c47ea5fb4c0cc5c9b90a616364/debugpy-1.8.20-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:077a7447589ee9bc1ff0cdf443566d0ecf540ac8aa7333b775ebcb8ce9f4ecad", size = 4269425, upload-time = "2026-01-29T23:03:56.518Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d9/d64199c14a0d4c476df46c82470a3ce45c8d183a6796cfb5e66533b3663c/debugpy-1.8.20-cp314-cp314-win32.whl", hash = "sha256:352036a99dd35053b37b7803f748efc456076f929c6a895556932eaf2d23b07f", size = 5331407, upload-time = "2026-01-29T23:03:58.481Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d9/1f07395b54413432624d61524dfd98c1a7c7827d2abfdb8829ac92638205/debugpy-1.8.20-cp314-cp314-win_amd64.whl", hash = "sha256:a98eec61135465b062846112e5ecf2eebb855305acc1dfbae43b72903b8ab5be", size = 5372521, upload-time = "2026-01-29T23:03:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c3/7f67dea8ccf8fdcb9c99033bbe3e90b9e7395415843accb81428c441be2d/debugpy-1.8.20-py2.py3-none-any.whl", hash = "sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7", size = 5337658, upload-time = "2026-01-29T23:04:17.404Z" }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + +[[package]] +name = "dict2css" +version = "0.3.0.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cssutils" }, + { name = "domdf-python-tools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/eb/776eef1f1aa0188c0fc165c3a60b71027539f71f2eedc43ad21b060e9c39/dict2css-0.3.0.post1.tar.gz", hash = "sha256:89c544c21c4ca7472c3fffb9d37d3d926f606329afdb751dc1de67a411b70719", size = 7845, upload-time = "2023-11-22T11:09:20.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/47/290daabcf91628f4fc0e17c75a1690b354ba067066cd14407712600e609f/dict2css-0.3.0.post1-py3-none-any.whl", hash = "sha256:f006a6b774c3e31869015122ae82c491fd25e7de4a75607a62aa3e798f837e0d", size = 25647, upload-time = "2023-11-22T11:09:19.221Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + +[[package]] +name = "docutils" +version = "0.20.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/53/a5da4f2c5739cf66290fac1431ee52aff6851c7c8ffd8264f13affd7bcdd/docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b", size = 2058365, upload-time = "2023-05-16T23:39:19.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/87/f238c0670b94533ac0353a4e2a1a771a0cc73277b88bff23d3ae35a256c1/docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6", size = 572666, upload-time = "2023-05-16T23:39:15.976Z" }, +] + +[[package]] +name = "domdf-python-tools" +version = "3.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "natsort" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/8b/ab2d8a292bba8fe3135cacc8bfd3576710a14b8f2d0a8cde19130d5c9d21/domdf_python_tools-3.10.0.tar.gz", hash = "sha256:2ae308d2f4f1e9145f5f4ba57f840fbfd1c2983ee26e4824347789649d3ae298", size = 100458, upload-time = "2025-02-12T17:34:05.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/11/208f72084084d3f6a2ed5ebfdfc846692c3f7ad6dce65e400194924f7eed/domdf_python_tools-3.10.0-py3-none-any.whl", hash = "sha256:5e71c1be71bbcc1f881d690c8984b60e64298ec256903b3147f068bc33090c36", size = 126860, upload-time = "2025-02-12T17:34:04.093Z" }, +] + +[[package]] +name = "dotenv" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dotenv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "fastjsonschema" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, +] + +[[package]] +name = "filelock" +version = "3.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, +] + +[[package]] +name = "google-auth" +version = "2.49.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, +] + +[[package]] +name = "google-genai" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "google-auth" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/37/6c0ececc3a7a629029b5beed2ceb9f28f73292236eb96272355636769b0d/google_genai-1.27.0.tar.gz", hash = "sha256:15a13ffe7b3938da50b9ab77204664d82122617256f55b5ce403d593848ef635", size = 220099, upload-time = "2025-07-23T22:00:46.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/12/279afe7357af73f9737a3412b6f0bc1482075b896340eb46a2f9cb0fd791/google_genai-1.27.0-py3-none-any.whl", hash = "sha256:afd6b4efaf8ec1d20a6e6657d768b68d998d60007c6e220e9024e23c913c1833", size = 218489, upload-time = "2025-07-23T22:00:44.879Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, + { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, + { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, + { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3a/efb2cf697fbccdf75b24e2c18025e7dfa54c4f31fab75c51d0fe79942cef/greenlet-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5", size = 230389, upload-time = "2026-02-20T20:17:18.772Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a1/65bbc059a43a7e2143ec4fc1f9e3f673e04f9c7b371a494a101422ac4fd5/greenlet-3.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd", size = 229645, upload-time = "2026-02-20T20:18:18.695Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + +[[package]] +name = "html5lib" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/b6/b55c3f49042f1df3dcd422b7f224f939892ee94f22abcf503a9b7339eaf2/html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f", size = 272215, upload-time = "2020-06-22T23:32:38.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/dd/a834df6482147d48e225a49515aabc28974ad5a4ca3215c18a882565b028/html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", size = 112173, upload-time = "2020-06-22T23:32:36.781Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "ipykernel" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython", version = "9.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "ipython", version = "9.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/8d/b68b728e2d06b9e0051019640a40a9eb7a88fcd82c2e1b5ce70bef5ff044/ipykernel-7.2.0.tar.gz", hash = "sha256:18ed160b6dee2cbb16e5f3575858bc19d8f1fe6046a9a680c708494ce31d909e", size = 176046, upload-time = "2026-02-06T16:43:27.403Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/b9/e73d5d9f405cba7706c539aa8b311b49d4c2f3d698d9c12f815231169c71/ipykernel-7.2.0-py3-none-any.whl", hash = "sha256:3bbd4420d2b3cc105cbdf3756bfc04500b1e52f090a90716851f3916c62e1661", size = 118788, upload-time = "2026-02-06T16:43:25.149Z" }, +] + +[[package]] +name = "ipython" +version = "9.10.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.12'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version < '3.12'" }, + { name = "jedi", marker = "python_full_version < '3.12'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.12'" }, + { name = "pexpect", marker = "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.12'" }, + { name = "pygments", marker = "python_full_version < '3.12'" }, + { name = "stack-data", marker = "python_full_version < '3.12'" }, + { name = "traitlets", marker = "python_full_version < '3.12'" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/25/daae0e764047b0a2480c7bbb25d48f4f509b5818636562eeac145d06dfee/ipython-9.10.1.tar.gz", hash = "sha256:e170e9b2a44312484415bdb750492699bf329233b03f2557a9692cce6466ada4", size = 4426663, upload-time = "2026-03-27T09:53:26.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/09/ba70f8d662d5671687da55ad2cc0064cf795b15e1eea70907532202e7c97/ipython-9.10.1-py3-none-any.whl", hash = "sha256:82d18ae9fb9164ded080c71ef92a182ee35ee7db2395f67616034bebb020a232", size = 622827, upload-time = "2026-03-27T09:53:24.566Z" }, +] + +[[package]] +name = "ipython" +version = "9.12.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.12'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.12'" }, + { name = "jedi", marker = "python_full_version >= '3.12'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.12'" }, + { name = "pexpect", marker = "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "stack-data", marker = "python_full_version >= '3.12'" }, + { name = "traitlets", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/73/7114f80a8f9cabdb13c27732dce24af945b2923dcab80723602f7c8bc2d8/ipython-9.12.0.tar.gz", hash = "sha256:01daa83f504b693ba523b5a407246cabde4eb4513285a3c6acaff11a66735ee4", size = 4428879, upload-time = "2026-03-27T09:42:45.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/22/906c8108974c673ebef6356c506cebb6870d48cedea3c41e949e2dd556bb/ipython-9.12.0-py3-none-any.whl", hash = "sha256:0f2701e8ee86e117e37f50563205d36feaa259d2e08d4a6bc6b6d74b18ce128d", size = 625661, upload-time = "2026-03-27T09:42:42.831Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jiter" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" }, + { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0d/061faffcfe94608cbc28a0d42a77a74222bdf5055ccdbe5fd2292b94f510/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c", size = 362587, upload-time = "2026-02-02T12:35:42.025Z" }, + { url = "https://files.pythonhosted.org/packages/92/c9/c66a7864982fd38a9773ec6e932e0398d1262677b8c60faecd02ffb67bf3/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4", size = 487537, upload-time = "2026-02-02T12:35:43.459Z" }, + { url = "https://files.pythonhosted.org/packages/6c/86/84eb4352cd3668f16d1a88929b5888a3fe0418ea8c1dfc2ad4e7bf6e069a/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165", size = 373717, upload-time = "2026-02-02T12:35:44.928Z" }, + { url = "https://files.pythonhosted.org/packages/6e/09/9fe4c159358176f82d4390407a03f506a8659ed13ca3ac93a843402acecf/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018", size = 362683, upload-time = "2026-02-02T12:35:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5e/85f3ab9caca0c1d0897937d378b4a515cae9e119730563572361ea0c48ae/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411", size = 392345, upload-time = "2026-02-02T12:35:48.088Z" }, + { url = "https://files.pythonhosted.org/packages/12/4c/05b8629ad546191939e6f0c2f17e29f542a398f4a52fb987bc70b6d1eb8b/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5", size = 517775, upload-time = "2026-02-02T12:35:49.482Z" }, + { url = "https://files.pythonhosted.org/packages/4d/88/367ea2eb6bc582c7052e4baf5ddf57ebe5ab924a88e0e09830dfb585c02d/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3", size = 551325, upload-time = "2026-02-02T12:35:51.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/12/fa377ffb94a2f28c41afaed093e0d70cfe512035d5ecb0cad0ae4792d35e/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1", size = 204709, upload-time = "2026-02-02T12:35:52.467Z" }, + { url = "https://files.pythonhosted.org/packages/cb/16/8e8203ce92f844dfcd3d9d6a5a7322c77077248dbb12da52d23193a839cd/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654", size = 204560, upload-time = "2026-02-02T12:35:53.925Z" }, + { url = "https://files.pythonhosted.org/packages/44/26/97cc40663deb17b9e13c3a5cf29251788c271b18ee4d262c8f94798b8336/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5", size = 189608, upload-time = "2026-02-02T12:35:55.304Z" }, + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, + { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" }, + { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" }, + { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" }, + { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" }, + { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" }, + { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" }, + { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" }, + { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, + { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, + { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, + { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, + { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, + { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, + { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, + { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/3c29819a27178d0e461a8571fb63c6ae38be6dc36b78b3ec2876bbd6a910/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c", size = 307016, upload-time = "2026-02-02T12:37:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/60993e4b07b1ac5ebe46da7aa99fdbb802eb986c38d26e3883ac0125c4e0/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2", size = 305024, upload-time = "2026-02-02T12:37:44.774Z" }, + { url = "https://files.pythonhosted.org/packages/77/fa/2227e590e9cf98803db2811f172b2d6460a21539ab73006f251c66f44b14/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434", size = 339337, upload-time = "2026-02-02T12:37:46.668Z" }, + { url = "https://files.pythonhosted.org/packages/2d/92/015173281f7eb96c0ef580c997da8ef50870d4f7f4c9e03c845a1d62ae04/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d", size = 346395, upload-time = "2026-02-02T12:37:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/fe/0f5a938c54105553436dbff7a61dc4fed4b1b2c98852f8833beaf4d5968f/joblib-1.5.1.tar.gz", hash = "sha256:f4f86e351f39fe3d0d32a9f2c3d8af1ee4cec285aafcb27003dda5205576b444", size = 330475, upload-time = "2025-05-23T12:04:37.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/4f/1195bbac8e0c2acc5f740661631d8d750dc38d4a32b23ee5df3cde6f4e0d/joblib-1.5.1-py3-none-any.whl", hash = "sha256:4719a31f054c7d766948dcd83e9613686b27114f190f717cec7eaa2084f8a74a", size = 307746, upload-time = "2025-05-23T12:04:35.124Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jupyter-book" +version = "1.0.4.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "linkify-it-py" }, + { name = "myst-nb" }, + { name = "myst-parser" }, + { name = "pyyaml" }, + { name = "sphinx" }, + { name = "sphinx-book-theme" }, + { name = "sphinx-comments" }, + { name = "sphinx-copybutton" }, + { name = "sphinx-design" }, + { name = "sphinx-external-toc" }, + { name = "sphinx-jupyterbook-latex" }, + { name = "sphinx-multitoc-numbering" }, + { name = "sphinx-thebe" }, + { name = "sphinx-togglebutton" }, + { name = "sphinxcontrib-bibtex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/ee/5d10ce5b161764ad44219853f386e98b535cb3879bcb0d7376961a1e3897/jupyter_book-1.0.4.post1.tar.gz", hash = "sha256:2fe92c49ff74840edc0a86bb034eafdd0f645fca6e48266be367ce4d808b9601", size = 67412, upload-time = "2025-02-28T14:55:48.637Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/86/d45756beaeb4b9b06125599b429451f8640b5db6f019d606f33c85743fd4/jupyter_book-1.0.4.post1-py3-none-any.whl", hash = "sha256:3a27a6b2581f1894ffe8f347d1a3432f06fc616997547919c42cd41c54db625d", size = 45005, upload-time = "2025-02-28T14:55:46.561Z" }, +] + +[[package]] +name = "jupyter-cache" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "click" }, + { name = "importlib-metadata" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "pyyaml" }, + { name = "sqlalchemy" }, + { name = "tabulate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/f7/3627358075f183956e8c4974603232b03afd4ddc7baf72c2bc9fff522291/jupyter_cache-1.0.1.tar.gz", hash = "sha256:16e808eb19e3fb67a223db906e131ea6e01f03aa27f49a7214ce6a5fec186fb9", size = 32048, upload-time = "2024-11-15T16:03:55.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/6b/67b87da9d36bff9df7d0efbd1a325fa372a43be7158effaf43ed7b22341d/jupyter_cache-1.0.1-py3-none-any.whl", hash = "sha256:9c3cafd825ba7da8b5830485343091143dff903e4d8c69db9349b728b140abf6", size = 33907, upload-time = "2024-11-15T16:03:54.021Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/e4/ba649102a3bc3fbca54e7239fb924fd434c766f855693d86de0b1f2bec81/jupyter_client-8.8.0.tar.gz", hash = "sha256:d556811419a4f2d96c869af34e854e3f059b7cc2d6d01a9cd9c85c267691be3e", size = 348020, upload-time = "2026-01-08T13:55:47.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/0b/ceb7694d864abc0a047649aec263878acb9f792e1fec3e676f22dc9015e3/jupyter_client-8.8.0-py3-none-any.whl", hash = "sha256:f93a5b99c5e23a507b773d3a1136bd6e16c67883ccdbd9a829b0bbdb98cd7d7a", size = 107371, upload-time = "2026-01-08T13:55:45.562Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "latexcodec" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/dd/4270b2c5e2ee49316c3859e62293bd2ea8e382339d63ab7bbe9f39c0ec3b/latexcodec-3.0.1.tar.gz", hash = "sha256:e78a6911cd72f9dec35031c6ec23584de6842bfbc4610a9678868d14cdfb0357", size = 31222, upload-time = "2025-06-17T18:47:34.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/40/23569737873cc9637fd488606347e9dd92b9fa37ba4fcda1f98ee5219a97/latexcodec-3.0.1-py3-none-any.whl", hash = "sha256:a9eb8200bff693f0437a69581f7579eb6bca25c4193515c09900ce76451e452e", size = 18532, upload-time = "2025-06-17T18:47:30.726Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + +[[package]] +name = "lsprotocol" +version = "2025.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cattrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/26/67b84e6ec1402f0e6764ef3d2a0aaf9a79522cc1d37738f4e5bb0b21521a/lsprotocol-2025.0.0.tar.gz", hash = "sha256:e879da2b9301e82cfc3e60d805630487ac2f7ab17492f4f5ba5aaba94fe56c29", size = 74896, upload-time = "2025-06-17T21:30:18.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/f0/92f2d609d6642b5f30cb50a885d2bf1483301c69d5786286500d15651ef2/lsprotocol-2025.0.0-py3-none-any.whl", hash = "sha256:f9d78f25221f2a60eaa4a96d3b4ffae011b107537facee61d3da3313880995c7", size = 76250, upload-time = "2025-06-17T21:30:19.455Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, +] + +[[package]] +name = "mcp" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + +[[package]] +name = "msgpack" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/6b/e9b1cdc042c4458801d2545ed782a95f3d6ba8e270cce8745b8603c7f748/msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22", size = 82812, upload-time = "2026-06-18T16:12:45.022Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3a/dd518a1bf78ed1e9ad8afe57307c079a00eafe4b3068932a27ca1ea56b4f/msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5", size = 82739, upload-time = "2026-06-18T16:12:46.025Z" }, + { url = "https://files.pythonhosted.org/packages/70/e0/7ba9e1542bf0771a27b8b37c1316e3f95ae9d748fd765284655c476ad4ef/msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06", size = 414233, upload-time = "2026-06-18T16:12:47.029Z" }, + { url = "https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4", size = 423843, upload-time = "2026-06-18T16:12:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b6/e5c737515ed1f166664b87601b532f58cbb73d8aa6a90b99f7c2c5037e8e/msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8", size = 390772, upload-time = "2026-06-18T16:12:49.624Z" }, + { url = "https://files.pythonhosted.org/packages/a8/46/62ed8c2e87d7021eab19921594d961ef3aa3794eec76c716dc30f3bfd433/msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b", size = 409559, upload-time = "2026-06-18T16:12:50.936Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/59aa3887b860bbf43532835e192b1c388a17590d6068ae4f8b2bc74c906e/msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e", size = 387838, upload-time = "2026-06-18T16:12:52.161Z" }, + { url = "https://files.pythonhosted.org/packages/09/11/f8563e471093420cf6478cb3271a0175d8402b82d879783d4035d2d03360/msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f", size = 421732, upload-time = "2026-06-18T16:12:53.556Z" }, + { url = "https://files.pythonhosted.org/packages/57/cf/e673683c4c6c90c1022b24c65af4b03eda72b182a1176ef6449069d66acc/msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d", size = 64091, upload-time = "2026-06-18T16:12:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/ca212739d179f9083bff2c7c08c24101c3555a334fadc2b876b18768a3ae/msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8", size = 70462, upload-time = "2026-06-18T16:12:55.898Z" }, + { url = "https://files.pythonhosted.org/packages/6d/be/6798347b425e26f35db82e69dd83c09716c856a3714e7bffc4c0860fd830/msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66", size = 65059, upload-time = "2026-06-18T16:12:57.053Z" }, + { url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" }, + { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" }, + { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" }, + { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, + { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, + { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, + { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, + { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, + { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" }, + { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, + { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, +] + +[[package]] +name = "mslex" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/97/7022667073c99a0fe028f2e34b9bf76b49a611afd21b02527fbfd92d4cd5/mslex-1.3.0.tar.gz", hash = "sha256:641c887d1d3db610eee2af37a8e5abda3f70b3006cdfd2d0d29dc0d1ae28a85d", size = 11583, upload-time = "2024-10-16T13:16:18.523Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/f2/66bd65ca0139675a0d7b18f0bada6e12b51a984e41a76dbe44761bf1b3ee/mslex-1.3.0-py3-none-any.whl", hash = "sha256:c7074b347201b3466fc077c5692fbce9b5f62a63a51f537a53fbbd02eff2eea4", size = 7820, upload-time = "2024-10-16T13:16:17.566Z" }, +] + +[[package]] +name = "myst-nb" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "ipykernel" }, + { name = "ipython", version = "9.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "ipython", version = "9.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "jupyter-cache" }, + { name = "myst-parser" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "pyyaml" }, + { name = "sphinx" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/b4/ff1abeea67e8cfe0a8c033389f6d1d8b0bfecfd611befb5cbdeab884fce6/myst_nb-1.4.0.tar.gz", hash = "sha256:c145598de62446a6fd009773dd071a40d3b76106ace780de1abdfc6961f614c2", size = 82285, upload-time = "2026-03-02T21:14:56.95Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/93/0a378b48488879a1d925b42a804edfc6e0cd0ef854220f2dce738a46e7e9/myst_nb-1.4.0-py3-none-any.whl", hash = "sha256:0e2c86e7d3b82c3aa51383f82d6268f7714f3b772c23a796ab09538a8e68b4e4", size = 82555, upload-time = "2026-03-02T21:14:55.652Z" }, +] + +[[package]] +name = "myst-parser" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "jinja2" }, + { name = "markdown-it-py" }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/64/e2f13dac02f599980798c01156393b781aec983b52a6e4057ee58f07c43a/myst_parser-3.0.1.tar.gz", hash = "sha256:88f0cb406cb363b077d176b51c476f62d60604d68a8dcdf4832e080441301a87", size = 92392, upload-time = "2024-04-28T20:22:42.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/de/21aa8394f16add8f7427f0a1326ccd2b3a2a8a3245c9252bc5ac034c6155/myst_parser-3.0.1-py3-none-any.whl", hash = "sha256:6457aaa33a5d474aca678b8ead9b3dc298e89c68e67012e73146ea6fd54babf1", size = 83163, upload-time = "2024-04-28T20:22:39.985Z" }, +] + +[[package]] +name = "natsort" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/a9/a0c57aee75f77794adaf35322f8b6404cbd0f89ad45c87197a937764b7d0/natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581", size = 76575, upload-time = "2023-06-20T04:17:19.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268, upload-time = "2023-06-20T04:17:17.522Z" }, +] + +[[package]] +name = "nbclient" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "nbformat" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/91/1c1d5a4b9a9ebba2b4e32b8c852c2975c872aec1fe42ab5e516b2cecd193/nbclient-0.10.4.tar.gz", hash = "sha256:1e54091b16e6da39e297b0ece3e10f6f29f4ac4e8ee515d29f8a7099bd6553c9", size = 62554, upload-time = "2025-12-23T07:45:46.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/a0/5b0c2f11142ed1dddec842457d3f65eaf71a0080894eb6f018755b319c3a/nbclient-0.10.4-py3-none-any.whl", hash = "sha256:9162df5a7373d70d606527300a95a975a47c137776cd942e52d9c7e29ff83440", size = 25465, upload-time = "2025-12-23T07:45:44.51Z" }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "oslex" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mslex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/19/b74ea9590378a35014acf72f221e84c5980aa7531d1852ef961764e7d3a6/oslex-2.0.0.tar.gz", hash = "sha256:30d9f4a7201bdce3ab7d9cfc0f9ee9e18c423b2b1d1668141b0dd3594b368ffe", size = 23942, upload-time = "2026-05-10T21:22:36.627Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/d1/3c0bad919598c4216944934db565ed9834ad5b792e1b5e67e911bb2976d2/oslex-2.0.0-py3-none-any.whl", hash = "sha256:f1c2944072aafcb06b6ee8a6c018d9ebb8a7f43d7cf0b6630b4f5d7be45f6ae4", size = 5905, upload-time = "2026-05-10T21:22:37.514Z" }, +] + +[[package]] +name = "overrides" +version = "7.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "parso" +version = "0.8.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd", size = 401621, upload-time = "2026-02-09T15:45:24.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, +] + +[[package]] +name = "pastel" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/f1/4594f5e0fcddb6953e5b8fe00da8c317b8b41b547e2b3ae2da7512943c62/pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d", size = 7555, upload-time = "2020-09-16T19:21:12.43Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955, upload-time = "2020-09-16T19:21:11.409Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "poethepoet" +version = "0.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pastel" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/ac/311c8a492dc887f0b7a54d0ec3324cb2f9538b7b78ea06e5f7ae1f167e52/poethepoet-0.36.0.tar.gz", hash = "sha256:2217b49cb4e4c64af0b42ff8c4814b17f02e107d38bc461542517348ede25663", size = 66854, upload-time = "2025-06-29T19:54:50.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/29/dedb3a6b7e17ea723143b834a2da428a7d743c80d5cd4d22ed28b5e8c441/poethepoet-0.36.0-py3-none-any.whl", hash = "sha256:693e3c1eae9f6731d3613c3c0c40f747d3c5c68a375beda42e590a63c5623308", size = 88031, upload-time = "2025-06-29T19:54:48.884Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "proxy-tools" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/cf/77d3e19b7fabd03895caca7857ef51e4c409e0ca6b37ee6e9f7daa50b642/proxy_tools-0.1.0.tar.gz", hash = "sha256:ccb3751f529c047e2d8a58440d86b205303cf0fe8146f784d1cbcd94f0a28010", size = 2978, upload-time = "2014-05-05T21:02:24.606Z" } + +[[package]] +name = "psutil" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003, upload-time = "2025-02-13T21:54:07.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051, upload-time = "2025-02-13T21:54:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535, upload-time = "2025-02-13T21:54:16.07Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004, upload-time = "2025-02-13T21:54:18.662Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986, upload-time = "2025-02-13T21:54:21.811Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544, upload-time = "2025-02-13T21:54:24.68Z" }, + { url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053, upload-time = "2025-02-13T21:54:34.31Z" }, + { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pybtex" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "latexcodec" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/bc/c2be05ca72f8c103670e983df8be26d1e288bc6556f487fa8cccaa27779f/pybtex-0.25.1.tar.gz", hash = "sha256:9eaf90267c7e83e225af89fea65c370afbf65f458220d3946a9e3049e1eca491", size = 406157, upload-time = "2025-06-26T13:27:41.903Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/68/ceb5d6679baa326261f5d3e5113d9cfed6efef2810afd9f18bffb8ed312b/pybtex-0.25.1-py2.py3-none-any.whl", hash = "sha256:9053b0d619409a0a83f38abad5d9921de5f7b3ede00742beafcd9f10ad0d8c5c", size = 127437, upload-time = "2025-06-26T13:27:43.585Z" }, +] + +[[package]] +name = "pybtex-docutils" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "pybtex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/84/796ea94d26188a853660f81bded39f8de4cfe595130aef0dea1088705a11/pybtex-docutils-1.0.3.tar.gz", hash = "sha256:3a7ebdf92b593e00e8c1c538aa9a20bca5d92d84231124715acc964d51d93c6b", size = 18348, upload-time = "2023-08-22T18:47:54.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/b1/ce1f4596211efb5410e178a803f08e59b20bedb66837dcf41e21c54f9ec1/pybtex_docutils-1.0.3-py3-none-any.whl", hash = "sha256:8fd290d2ae48e32fcb54d86b0efb8d573198653c7e2447d5bec5847095f430b9", size = 6385, upload-time = "2023-08-22T06:43:20.513Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pydata-sphinx-theme" +version = "0.15.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "accessible-pygments" }, + { name = "babel" }, + { name = "beautifulsoup4" }, + { name = "docutils" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "sphinx" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/ea/3ab478cccacc2e8ef69892c42c44ae547bae089f356c4b47caf61730958d/pydata_sphinx_theme-0.15.4.tar.gz", hash = "sha256:7762ec0ac59df3acecf49fd2f889e1b4565dbce8b88b2e29ee06fdd90645a06d", size = 2400673, upload-time = "2024-06-25T19:28:45.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/d3/c622950d87a2ffd1654208733b5bd1c5645930014abed8f4c0d74863988b/pydata_sphinx_theme-0.15.4-py3-none-any.whl", hash = "sha256:2136ad0e9500d0949f96167e63f3e298620040aea8f9c74621959eda5d4cf8e6", size = 4640157, upload-time = "2024-06-25T19:28:42.383Z" }, +] + +[[package]] +name = "pygls" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cattrs" }, + { name = "lsprotocol" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/2e/7bbe061d175c0baddde8fc9edb908a4c31ba5d9165b8c68e3439c3a9f138/pygls-2.1.1.tar.gz", hash = "sha256:1da03ba9053201bb337dcdd8d121df70feb2a91e1a0dcc74de5da79755b1a201", size = 55091, upload-time = "2026-03-25T11:19:10.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/1a/208293b6c350f5abea6941d5606080d4a492644052504f5312e5de30a902/pygls-2.1.1-py3-none-any.whl", hash = "sha256:510a6dea2476177230c7d851125e5948efdf3fdb9ebfd8543fc434972f8faed4", size = 68975, upload-time = "2026-03-25T11:19:11.374Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyinstrument" +version = "5.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/ce/824ee634994e612156f7b84eaf50b8523c676ebfed8d8dd12939a82f4c15/pyinstrument-5.1.1.tar.gz", hash = "sha256:bc401cda990b3c1cfe8e0e0473cbd605df3c63b73478a89ac4ab108f2184baa8", size = 264730, upload-time = "2025-08-12T11:35:43.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/65/dc0fcc6122e6f484ffa48260d1023e1fe90e53da8d69a7de656205af91ba/pyinstrument-5.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5272cff6eea21163b2105f6a80c907315e0f567720621e6d5672dc01bf71ee48", size = 130287, upload-time = "2025-08-12T11:34:32.436Z" }, + { url = "https://files.pythonhosted.org/packages/90/96/8a6cab312342f1ad7322b459dcbdb2e041251b7bbe4c1d0dd38ccbf2ae20/pyinstrument-5.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d4e7dc5a4aee37a44ff2e63db3127f2044dd95edcae240cb95915adbf223d4be", size = 122879, upload-time = "2025-08-12T11:34:33.506Z" }, + { url = "https://files.pythonhosted.org/packages/8a/89/1ad1ae703951832a34ec6b32354c6df1d0690b0333b7b8396c92afcdc23e/pyinstrument-5.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602d55121df1d88aeb6d8ebc801597fdcb9718f78d602ae81458d65c56f25d24", size = 146412, upload-time = "2025-08-12T11:34:34.975Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/588ec07eeaa5d5d4aff2d47c8010379e9b39a7aee7e3fda625a0ce4f5838/pyinstrument-5.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63b5a788ff955e0597bc95463e64d5fa3747017524fdc02a0f5d12d5117cf2b9", size = 144885, upload-time = "2025-08-12T11:34:36.546Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8f/119ad44d454edb927434bb53d8a69904ecaa47ec56f95c688c557a9590de/pyinstrument-5.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7f66038ec55a12e5510689240cdc745f8e98c90b93363f745106976e5cfb7397", size = 145530, upload-time = "2025-08-12T11:34:37.726Z" }, + { url = "https://files.pythonhosted.org/packages/8e/31/d53b20d1967ba78574807c5940251c0f238e8e3515ee9c4f20207eb09199/pyinstrument-5.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:110a08b6e7fa9542eb37c337e79467913d364a03bc2062f85566ba96bc82f54e", size = 144708, upload-time = "2025-08-12T11:34:38.855Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/78ccb35dd265e83c0b7404a501004387b2e2390a0a44b6e4a004307931d1/pyinstrument-5.1.1-cp311-cp311-win32.whl", hash = "sha256:a223d5e9226ccede5bf2fbd4d13ce0aeb5120501b633ba85290ed94df37d3623", size = 124151, upload-time = "2025-08-12T11:34:40.392Z" }, + { url = "https://files.pythonhosted.org/packages/43/c0/3eb94aa2c2f0b8d49e290d4df662e21e707eb4a23f3b938239634fbfdc17/pyinstrument-5.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:16ce582a7b56287d338a8b59688458341aab5c6abda970ba50b2f7b3fd69f89d", size = 124942, upload-time = "2025-08-12T11:34:41.564Z" }, + { url = "https://files.pythonhosted.org/packages/76/3a/7824caf1fb419d0108f375a15b28cdd7ace8593f1ea56ef8276fddce9526/pyinstrument-5.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bcd6a03bdf180d73bc8dc7371e09dda089a48057095584e5f2818df1c820525b", size = 130306, upload-time = "2025-08-12T11:34:42.624Z" }, + { url = "https://files.pythonhosted.org/packages/3a/54/60ddd5eae617e29b58de774d178f4e4f7cdffd07ed1de36f976927ce69d3/pyinstrument-5.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5ffa0948c1e268356dcf930c128624f34037ce92ee865fa4c056dee067aee4c5", size = 122817, upload-time = "2025-08-12T11:34:44.182Z" }, + { url = "https://files.pythonhosted.org/packages/35/12/35b694bfa58050607eedc80a68f64e6195c738249101a0dcbed0657147e7/pyinstrument-5.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c95adf98a920f2039eb0065966f980452a7af794bab387e9bfe8af3c681affa0", size = 148053, upload-time = "2025-08-12T11:34:45.589Z" }, + { url = "https://files.pythonhosted.org/packages/5d/4a/338b891f9119cf747153301d5d095942f378032309cd385e53857d03c2d2/pyinstrument-5.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bcb46ca8596b375c27850d4d06a1ce94ed78074774d35cbed3ccd28b663c5ba6", size = 146817, upload-time = "2025-08-12T11:34:46.668Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cc/186cdb048fee445bbf9bd18819287a61b57b66ec68cfc47bc3c1e38b1ae6/pyinstrument-5.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3fc16597d26b24a46bf3455686300c0b8a3eb565ebc82396f402c031dccc0145", size = 146914, upload-time = "2025-08-12T11:34:47.878Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d1/533309830dd356d43e54d7feebaffab357f08568972285c609e98f7e6119/pyinstrument-5.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5aa135b4bd9667ddcb25fa582f4db77c5117ef207cb10ae901a8e4c5d5cde0e0", size = 146533, upload-time = "2025-08-12T11:34:49.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/df/2a656d6b1bd68ecfbb73c557906274a40ec7219dd92980fc1324997cf93e/pyinstrument-5.1.1-cp312-cp312-win32.whl", hash = "sha256:d15e37f8074b3043fca7aa985cb2079d2c221ccb0d27f059451ede800c801645", size = 124286, upload-time = "2025-08-12T11:34:50.285Z" }, + { url = "https://files.pythonhosted.org/packages/51/af/144d331cc9734e9141ac1a75f3ce904074ebc93dfe43cab44049ba8c8c28/pyinstrument-5.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c27d5cef0e809f213e5a94143c397d948650f5142c91dcce3611f584779183e", size = 125032, upload-time = "2025-08-12T11:34:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/36/d4/b94f47aa7d301f6cdf5924bb75caacd0d0a1852bd4e876e3a64fc5798dad/pyinstrument-5.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:45af421c60c943a7f1619afabeba4951d4cc16b4206490d7d5b7ef5a4e2dfd42", size = 130315, upload-time = "2025-08-12T11:34:52.91Z" }, + { url = "https://files.pythonhosted.org/packages/1e/42/1bc2f28e139f69a0918d5d5dc1d59e65c640d4da9dd153fa48c2a8a87dd9/pyinstrument-5.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2603db3d745a65de66c96929ab9b0fcce050511eb24e32856ea2458785b8917f", size = 122805, upload-time = "2025-08-12T11:34:54.201Z" }, + { url = "https://files.pythonhosted.org/packages/a8/85/2f0c9115cd8a01e0a18d0650d9f3f20ff71e8ca17bd4af60dd3a0cb76f8a/pyinstrument-5.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fe32492100efaa1b0a488c237fe420fdaf141646733a31a97f96c4e1fa6bbf8", size = 148210, upload-time = "2025-08-12T11:34:55.662Z" }, + { url = "https://files.pythonhosted.org/packages/86/62/3c73a63e6913378cc7e9ffb5af1e50836511eee83b7c7bf252fad7ec24e4/pyinstrument-5.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:999b5373f8b1e846357923063ae5c9275ad8a85ed4e0a42960a349288d1f5007", size = 146995, upload-time = "2025-08-12T11:34:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/ab/8b/d21f4b6d8849881e9572967818e3e6d2dcb212e7dfa89e4e356d359db32b/pyinstrument-5.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58a2f69052178ec624e4df0cf546eda48b3a381572ac1cb3272b4c163888af9d", size = 147029, upload-time = "2025-08-12T11:34:58.255Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4d/1e43cecf2bcf4a3dd1100f4fc7a3da6438a65d0b95ca7b8ab5d094ea7c0b/pyinstrument-5.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d9bbc00d2e258edbefeb39b61ad4636099b08acd1effdd40d76883a13e7bf5a", size = 146668, upload-time = "2025-08-12T11:34:59.401Z" }, + { url = "https://files.pythonhosted.org/packages/34/48/00322b48e7adb665d04303b487454eb0c13a76ec0af8da20f452098fcc12/pyinstrument-5.1.1-cp313-cp313-win32.whl", hash = "sha256:cf2d8933e2aeaa02d4cb6279d83ef11ee882fb243fff96e3378153a730aadd6e", size = 124288, upload-time = "2025-08-12T11:35:00.514Z" }, + { url = "https://files.pythonhosted.org/packages/f5/14/d56515a110f74799aefc7489c1578ce4d99a4d731309559a427f954e7abc/pyinstrument-5.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:2402683a92617066b13a6d48f904396dcd15938016875b392534df027660eed4", size = 125041, upload-time = "2025-08-12T11:35:01.913Z" }, + { url = "https://files.pythonhosted.org/packages/18/2b/e4bdcabb5ae67de2ec3fa1f6e4eb4ae707b0bf460f895d4594792cdc919b/pyinstrument-5.1.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:688acba1c00cad73e43254e610f8e384a53ced3b0dbb5268fb44636e2b99663e", size = 130358, upload-time = "2025-08-12T11:35:03.569Z" }, + { url = "https://files.pythonhosted.org/packages/20/36/616f8db63997c096d3fb65e657cdf5bd2a63b53ed24a14750770dc500979/pyinstrument-5.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:116f5ad8cec4d6f5626305d7c1a104f5845a084bfb4b192d231eb8c41ea81f9a", size = 122827, upload-time = "2025-08-12T11:35:04.661Z" }, + { url = "https://files.pythonhosted.org/packages/af/7a/4f5d2bbc7c2466d46eb5ff47c6e667464eead47140e01a64be45215a59d4/pyinstrument-5.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d139d12a637001d3884344330054ce8335b2c8165dc3dd239726e1b358576bd", size = 147947, upload-time = "2025-08-12T11:35:05.786Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/c9b0081c0e52789a910390ce44e54c1318999d74386f15d92d0deb522aff/pyinstrument-5.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc5b87b1e27bec94457fed8d03c755a3c09edb4f35d975dbdffd77d863173254", size = 146702, upload-time = "2025-08-12T11:35:07.202Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1b/745ed7997da22ae68ff21b8f28e5e3a97b220335dce4ee7cf46d5eb17b32/pyinstrument-5.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15f4a2ed9562efab34b555e1208955cf9681b2272489d7a59cd0e289344ada2e", size = 146836, upload-time = "2025-08-12T11:35:08.297Z" }, + { url = "https://files.pythonhosted.org/packages/70/f0/05cefdcf79d1901f9d179e7f55f3acaadbc5fee7af955cebb3f555280638/pyinstrument-5.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1cb0c79bfa2b2b5734213429c9d7f455e5af664cfde785c69a5780f6c532c1fd", size = 146463, upload-time = "2025-08-12T11:35:09.483Z" }, + { url = "https://files.pythonhosted.org/packages/6c/cb/6a6f33316be3c7b8247f8ca0e418a2b6fb68d64c227169b7dbee50009366/pyinstrument-5.1.1-cp314-cp314-win32.whl", hash = "sha256:3b9f1216ae4848a8983dc405e1a42e46e75bd8ae96aaba328d4358b8fc80a7a0", size = 124950, upload-time = "2025-08-12T11:35:11.607Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ea/99caeb29f446f57d077a83c7c5f2b7c27c1719984d425f679bf2ec1eb6b0/pyinstrument-5.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:26971d4a17e0d5d4f6737e71c9de7a7ce5c83ab7daf078c6bf330be41d65273b", size = 125720, upload-time = "2025-08-12T11:35:12.683Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d0/953b75d634565ef34f8ed559f2e4af7cd1f2d5f5b578092e8f1d8199e4b1/pyinstrument-5.1.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:62362843884d654401ec4c25fed35f4b4ded077d96b3396f1e791c31e4203d3e", size = 131258, upload-time = "2025-08-12T11:35:13.805Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a4/4ec87cfd0974d79b2fcd72b3e20336fc65b96a5b08f2eb2867bf71b27b82/pyinstrument-5.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f2d640230b71c6d9ac8f27a9c5cd07fc8a6acad9196d1e48d9c33658b176fb80", size = 123276, upload-time = "2025-08-12T11:35:14.933Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/6a210989c8ede85f91b7e4ba5d9730492f1d081762570c06c750d787536c/pyinstrument-5.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f54f7292c63461c75ddf193f5e733803e463ccbc54f2fb7c9591337ddea7d10", size = 155767, upload-time = "2025-08-12T11:35:16.124Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a8/5ac81ffbfe36d2e5c3332a9452746a21540987da0d9491db751a905bba13/pyinstrument-5.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c156eb442f9f22960ae16bd195051863d5e8a68b877926e88bbaf8bbdc1456d1", size = 153423, upload-time = "2025-08-12T11:35:17.312Z" }, + { url = "https://files.pythonhosted.org/packages/3f/55/5620c2a61403cde044e81e33056c14fbf5793eea33f67f2223d61abec9ae/pyinstrument-5.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:caadaf67ad5926c46af784316024793c909b9e9ee550475855fd32171c4bd033", size = 153542, upload-time = "2025-08-12T11:35:18.729Z" }, + { url = "https://files.pythonhosted.org/packages/7a/83/a8f22466652250a847dfdf58f9a2717b470fdbbcb075c7f730bf608041a6/pyinstrument-5.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88ef2e8f483a5e1501d79a7ebdab592a597467810ed24d8db09ab6f568e938d3", size = 152337, upload-time = "2025-08-12T11:35:19.849Z" }, + { url = "https://files.pythonhosted.org/packages/0d/a6/cd4590da14deaeda6315519c26064874bbb9648a1358b80e8a8ca5d4add0/pyinstrument-5.1.1-cp314-cp314t-win32.whl", hash = "sha256:265bc4389f82e6521777bfab426a62a15c4940955e86f75db79a44e7349f9757", size = 125621, upload-time = "2025-08-12T11:35:21.201Z" }, + { url = "https://files.pythonhosted.org/packages/b3/30/177102e798539368aef25688a6a171d66ec92e6f16b6b651a89045a2bd13/pyinstrument-5.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fa254f269a72a007b5d02c18cd4b67081e0efabbd33e18acdbd5e3be905afa06", size = 126528, upload-time = "2025-08-12T11:35:22.578Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/10/e8192be5f38f3e8e7e046716de4cae33d56fd5ae08927a823bb916be36c1/pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02", size = 102511, upload-time = "2026-03-12T17:15:30.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/70/70f895f404d363d291dcf62c12c85fdd47619ad9674ac0f53364d035925a/pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e", size = 29700, upload-time = "2026-03-12T17:15:29.257Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyobjc-core" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/df/d2b290708e9da86d6e7a9a2a2022b91915cf2e712a5a82e306cb6ee99792/pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6", size = 671263, upload-time = "2025-11-14T09:31:35.231Z" }, + { url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335, upload-time = "2025-11-14T09:32:20.107Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370, upload-time = "2025-11-14T09:33:05.273Z" }, + { url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586, upload-time = "2025-11-14T09:33:53.302Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" }, + { url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/07/5760735c0fffc65107e648eaf7e0991f46da442ac4493501be5380e6d9d4/pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6", size = 383812, upload-time = "2025-11-14T09:40:53.169Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590, upload-time = "2025-11-14T09:41:17.336Z" }, + { url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689, upload-time = "2025-11-14T09:41:41.478Z" }, + { url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843, upload-time = "2025-11-14T09:42:05.719Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" }, +] + +[[package]] +name = "pyobjc-framework-quartz" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/18/cc59f3d4355c9456fc945eae7fe8797003c4da99212dd531ad1b0de8a0c6/pyobjc_framework_quartz-12.1.tar.gz", hash = "sha256:27f782f3513ac88ec9b6c82d9767eef95a5cf4175ce88a1e5a65875fee799608", size = 3159099, upload-time = "2025-11-14T10:21:24.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ef/dcd22b743e38b3c430fce4788176c2c5afa8bfb01085b8143b02d1e75201/pyobjc_framework_quartz-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:19f99ac49a0b15dd892e155644fe80242d741411a9ed9c119b18b7466048625a", size = 217795, upload-time = "2025-11-14T09:59:46.922Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9b/780f057e5962f690f23fdff1083a4cfda5a96d5b4d3bb49505cac4f624f2/pyobjc_framework_quartz-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7730cdce46c7e985535b5a42c31381af4aa6556e5642dc55b5e6597595e57a16", size = 218798, upload-time = "2025-11-14T10:00:01.236Z" }, + { url = "https://files.pythonhosted.org/packages/ba/2d/e8f495328101898c16c32ac10e7b14b08ff2c443a756a76fd1271915f097/pyobjc_framework_quartz-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:629b7971b1b43a11617f1460cd218bd308dfea247cd4ee3842eb40ca6f588860", size = 219206, upload-time = "2025-11-14T10:00:15.623Z" }, + { url = "https://files.pythonhosted.org/packages/67/43/b1f0ad3b842ab150a7e6b7d97f6257eab6af241b4c7d14cb8e7fde9214b8/pyobjc_framework_quartz-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:53b84e880c358ba1ddcd7e8d5ea0407d760eca58b96f0d344829162cda5f37b3", size = 224317, upload-time = "2025-11-14T10:00:30.703Z" }, + { url = "https://files.pythonhosted.org/packages/4a/00/96249c5c7e5aaca5f688ca18b8d8ad05cd7886ebd639b3c71a6a4cadbe75/pyobjc_framework_quartz-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:42d306b07f05ae7d155984503e0fb1b701fecd31dcc5c79fe8ab9790ff7e0de0", size = 219558, upload-time = "2025-11-14T10:00:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a6/708a55f3ff7a18c403b30a29a11dccfed0410485a7548c60a4b6d4cc0676/pyobjc_framework_quartz-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0cc08fddb339b2760df60dea1057453557588908e42bdc62184b6396ce2d6e9a", size = 224580, upload-time = "2025-11-14T10:01:00.091Z" }, +] + +[[package]] +name = "pyobjc-framework-security" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/aa/796e09a3e3d5cee32ebeebb7dcf421b48ea86e28c387924608a05e3f668b/pyobjc_framework_security-12.1.tar.gz", hash = "sha256:7fecb982bd2f7c4354513faf90ba4c53c190b7e88167984c2d0da99741de6da9", size = 168044, upload-time = "2025-11-14T10:22:06.334Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/3d/8d3a39cd292d7c76ab76233498189bc7170fc80f573b415308464f68c7ee/pyobjc_framework_security-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1b2d8819f0fb7b619ec7627a0d8c1cac1a57c5143579ce8ac21548165680684b", size = 41287, upload-time = "2025-11-14T10:02:54.491Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/5160c0f938fc0515fe8d9af146aac1b093f7ef285ce797fedae161b6c0e8/pyobjc_framework_security-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab42e55f5b782332be5442750fcd9637ee33247d57c7b1d5801bc0e24ee13278", size = 41280, upload-time = "2025-11-14T10:02:58.097Z" }, + { url = "https://files.pythonhosted.org/packages/32/48/b294ed75247c5cfa00d51925a10237337d24f54961d49a179b20a4307642/pyobjc_framework_security-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:afc36661cc6eb98cd794bed1d6668791e96557d6f72d9ac70aa49022d26af1d4", size = 41284, upload-time = "2025-11-14T10:03:01.722Z" }, + { url = "https://files.pythonhosted.org/packages/ef/57/0d3ef78779cf5c3bba878b2f824137e50978ad4a21dabe65d8b5ae0fc0d1/pyobjc_framework_security-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9510c98ab56921d1d416437372605cc1c1f6c1ad8d3061ee56b17bf423dd5427", size = 42162, upload-time = "2025-11-14T10:03:05.337Z" }, + { url = "https://files.pythonhosted.org/packages/66/4d/63c15f9449c191e7448a05ff8af4a82c39a51bb627bc96dc9697586c0f79/pyobjc_framework_security-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6319a34508fd87ab6ca3cda6f54e707196197a65b792b292705af967e225438a", size = 41348, upload-time = "2025-11-14T10:03:08.926Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d8/5aaa2a8124ed04a9d6ca7053dc0fa64e42be51497ed8263a24b744a95598/pyobjc_framework_security-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:03d166371cefdef24908825148eb848f99ee2c0b865870a09dcbb94334dd3e0a", size = 42908, upload-time = "2025-11-14T10:03:13.01Z" }, +] + +[[package]] +name = "pyobjc-framework-uniformtypeidentifiers" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/b8/dd9d2a94509a6c16d965a7b0155e78edf520056313a80f0cd352413f0d0b/pyobjc_framework_uniformtypeidentifiers-12.1.tar.gz", hash = "sha256:64510a6df78336579e9c39b873cfcd03371c4b4be2cec8af75a8a3d07dff607d", size = 17030, upload-time = "2025-11-14T10:23:02.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/5f/1f10f5275b06d213c9897850f1fca9c881c741c1f9190cea6db982b71824/pyobjc_framework_uniformtypeidentifiers-12.1-py2.py3-none-any.whl", hash = "sha256:ec5411e39152304d2a7e0e426c3058fa37a00860af64e164794e0bcffee813f2", size = 4901, upload-time = "2025-11-14T10:05:51.532Z" }, +] + +[[package]] +name = "pyobjc-framework-webkit" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/10/110a50e8e6670765d25190ca7f7bfeecc47ec4a8c018cb928f4f82c56e04/pyobjc_framework_webkit-12.1.tar.gz", hash = "sha256:97a54dd05ab5266bd4f614e41add517ae62cdd5a30328eabb06792474b37d82a", size = 284531, upload-time = "2025-11-14T10:23:40.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/37/5082a0bbe12e48d4ffa53b0c0f09c77a4a6ffcfa119e26fa8dd77c08dc1c/pyobjc_framework_webkit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3db734877025614eaef4504fadc0fbbe1279f68686a6f106f2e614e89e0d1a9d", size = 49970, upload-time = "2025-11-14T10:07:01.413Z" }, + { url = "https://files.pythonhosted.org/packages/db/67/64920c8d201a7fc27962f467c636c4e763b43845baba2e091a50a97a5d52/pyobjc_framework_webkit-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:af2c7197447638b92aafbe4847c063b6dd5e1ed83b44d3ce7e71e4c9b042ab5a", size = 50084, upload-time = "2025-11-14T10:07:05.868Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/80d36280164c69220ce99372f7736a028617c207e42cb587716009eecb88/pyobjc_framework_webkit-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1da0c428c9d9891c93e0de51c9f272bfeb96d34356cdf3136cb4ad56ce32ec2d", size = 50096, upload-time = "2025-11-14T10:07:10.027Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7a/03c29c46866e266b0c705811c55c22625c349b0a80f5cf4776454b13dc4c/pyobjc_framework_webkit-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1a29e334d5a7dd4a4f0b5647481b6ccf8a107b92e67b2b3c6b368c899f571965", size = 50572, upload-time = "2025-11-14T10:07:14.232Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ac/924878f239c167ffe3bfc643aee4d6dd5b357e25f6b28db227e40e9e6df3/pyobjc_framework_webkit-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:99d0d28542a266a95ee2585f51765c0331794bca461aaf4d1f5091489d475179", size = 50210, upload-time = "2025-11-14T10:07:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/637cda4983dc0936b73a385f3906256953ac434537b812814cb0b6d231a2/pyobjc_framework_webkit-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1aaa3bf12c7b68e1a36c0b294d2728e06f2cc220775e6dc4541d5046290e4dc8", size = 50680, upload-time = "2025-11-14T10:07:23.331Z" }, +] + +[[package]] +name = "pystray" +version = "0.19.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "python-xlib", marker = "sys_platform == 'linux'" }, + { name = "six" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/64/927a4b9024196a4799eba0180e0ca31568426f258a4a5c90f87a97f51d28/pystray-0.19.5-py2.py3-none-any.whl", hash = "sha256:a0c2229d02cf87207297c22d86ffc57c86c227517b038c0d3c59df79295ac617", size = 49068, upload-time = "2023-09-17T13:44:26.872Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/7e/9b35ad8f3d9ca680f7c87a88f19612fdd8da9796c4d3b46e560ac79dcc4a/python_multipart-0.0.31.tar.gz", hash = "sha256:fc631183bb13e56db3158a4909908dfb2e23565286744e798241e63750e5d680", size = 46689, upload-time = "2026-06-04T08:27:49.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/1e/7f7f299527a5a8ad90acd5f2f78dfa6c8495c6301a3205106ea68a84de96/python_multipart-0.0.31-py3-none-any.whl", hash = "sha256:8408153d68a9773291fc1da39a8b85a50044bddbabd2dd72e9229776b7b15e28", size = 29996, upload-time = "2026-06-04T08:27:47.804Z" }, +] + +[[package]] +name = "python-xlib" +version = "0.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, +] + +[[package]] +name = "pythonnet" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "clr-loader" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/57/da1992e44663b71365c6e842c8d7fa453d4ec45fb99a68cfee5b7e944d3c/pythonnet-3.1.0.tar.gz", hash = "sha256:7b34c382905d10a371509ffafd64cae0416305c28817738a9cd138336f4e9991", size = 250599, upload-time = "2026-05-23T20:30:21.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/4b/52414f442624d2589f5374a48c08d5ae94f24bea67fc13a20a752884e5b7/pythonnet-3.1.0-cp310.cp311.cp312.cp313.cp314-none-any.whl", hash = "sha256:698dd88edc198819ad63b624a6ebe76208c7b46e4fe13626f65e484f0358d6ba", size = 217578, upload-time = "2026-05-23T20:30:19.527Z" }, + { url = "https://files.pythonhosted.org/packages/db/67/031124fdcb937c266a3265118525bbf6dc13b8c79786d6a7290aecb6e7bb/pythonnet-3.1.0-cp310.cp311.cp312.cp313.cp314-none-win32.win_amd64.whl", hash = "sha256:7bdd4de03df3547a48122a3989265c8b31d5be0d19dadffa009eec7df8085e0b", size = 1644898, upload-time = "2026-05-23T20:30:16.213Z" }, +] + +[[package]] +name = "pywebview" +version = "6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bottle" }, + { name = "proxy-tools" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-security", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-uniformtypeidentifiers", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-webkit", marker = "sys_platform == 'darwin'" }, + { name = "pythonnet", marker = "sys_platform == 'win32'" }, + { name = "qtpy", marker = "sys_platform == 'openbsd6'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/5d/a107645b073b80b4010e6fc62594dd58a958267b1b71439e7b89849362c7/pywebview-6.2.tar.gz", hash = "sha256:f51bfd095762fee3eb7821c62fe2c0ddd39c9fecd8f33d51a871113f12f364e1", size = 512668, upload-time = "2026-04-13T08:02:12.187Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/1c/78260d06d244d9138b8012eef541679f74e1e2b97e4e9dbf32a842b5b61a/pywebview-6.2-py3-none-any.whl", hash = "sha256:94681a1f456ed217f8a2139a1de612988c0d37cdb30d74d54279438e85572c41", size = 524555, upload-time = "2026-04-13T08:02:04.841Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328, upload-time = "2025-09-08T23:07:45.946Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803, upload-time = "2025-09-08T23:07:47.551Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836, upload-time = "2025-09-08T23:07:49.436Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c4/2a6fe5111a01005fc7af3878259ce17684fabb8852815eda6225620f3c59/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e", size = 857038, upload-time = "2025-09-08T23:07:51.234Z" }, + { url = "https://files.pythonhosted.org/packages/cb/eb/bfdcb41d0db9cd233d6fb22dc131583774135505ada800ebf14dfb0a7c40/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e", size = 1657531, upload-time = "2025-09-08T23:07:52.795Z" }, + { url = "https://files.pythonhosted.org/packages/ab/21/e3180ca269ed4a0de5c34417dfe71a8ae80421198be83ee619a8a485b0c7/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2", size = 2034786, upload-time = "2025-09-08T23:07:55.047Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b1/5e21d0b517434b7f33588ff76c177c5a167858cc38ef740608898cd329f2/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394", size = 1894220, upload-time = "2025-09-08T23:07:57.172Z" }, + { url = "https://files.pythonhosted.org/packages/03/f2/44913a6ff6941905efc24a1acf3d3cb6146b636c546c7406c38c49c403d4/pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f", size = 567155, upload-time = "2025-09-08T23:07:59.05Z" }, + { url = "https://files.pythonhosted.org/packages/23/6d/d8d92a0eb270a925c9b4dd039c0b4dc10abc2fcbc48331788824ef113935/pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97", size = 633428, upload-time = "2025-09-08T23:08:00.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/14/01afebc96c5abbbd713ecfc7469cfb1bc801c819a74ed5c9fad9a48801cb/pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07", size = 559497, upload-time = "2025-09-08T23:08:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265, upload-time = "2025-09-08T23:09:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208, upload-time = "2025-09-08T23:09:51.073Z" }, + { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747, upload-time = "2025-09-08T23:09:52.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cf/f2b3784d536250ffd4be70e049f3b60981235d70c6e8ce7e3ef21e1adb25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271", size = 747371, upload-time = "2025-09-08T23:09:54.563Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, +] + +[[package]] +name = "qtpy" +version = "2.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/01/392eba83c8e47b946b929d7c46e0f04b35e9671f8bb6fc36b6f7945b4de8/qtpy-2.4.3.tar.gz", hash = "sha256:db744f7832e6d3da90568ba6ccbca3ee2b3b4a890c3d6fbbc63142f6e4cdf5bb", size = 66982, upload-time = "2025-02-11T15:09:25.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/76/37c0ccd5ab968a6a438f9c623aeecc84c202ab2fabc6a8fd927580c15b5a/QtPy-2.4.3-py3-none-any.whl", hash = "sha256:72095afe13673e017946cc258b8d5da43314197b741ed2890e563cf384b51aa1", size = 95045, upload-time = "2025-02-11T15:09:24.162Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regex" +version = "2026.2.28" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2", size = 415184, upload-time = "2026-02-28T02:19:42.792Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/db/8cbfd0ba3f302f2d09dd0019a9fcab74b63fee77a76c937d0e33161fb8c1/regex-2026.2.28-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e621fb7c8dc147419b28e1702f58a0177ff8308a76fa295c71f3e7827849f5d9", size = 488462, upload-time = "2026-02-28T02:16:22.616Z" }, + { url = "https://files.pythonhosted.org/packages/5d/10/ccc22c52802223f2368731964ddd117799e1390ffc39dbb31634a83022ee/regex-2026.2.28-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0d5bef2031cbf38757a0b0bc4298bb4824b6332d28edc16b39247228fbdbad97", size = 290774, upload-time = "2026-02-28T02:16:23.993Z" }, + { url = "https://files.pythonhosted.org/packages/62/b9/6796b3bf3101e64117201aaa3a5a030ec677ecf34b3cd6141b5d5c6c67d5/regex-2026.2.28-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bcb399ed84eabf4282587ba151f2732ad8168e66f1d3f85b1d038868fe547703", size = 288724, upload-time = "2026-02-28T02:16:25.403Z" }, + { url = "https://files.pythonhosted.org/packages/9c/02/291c0ae3f3a10cea941d0f5366da1843d8d1fa8a25b0671e20a0e454bb38/regex-2026.2.28-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1b34dfa72f826f535b20712afa9bb3ba580020e834f3c69866c5bddbf10098", size = 791924, upload-time = "2026-02-28T02:16:26.863Z" }, + { url = "https://files.pythonhosted.org/packages/0f/57/f0235cc520d9672742196c5c15098f8f703f2758d48d5a7465a56333e496/regex-2026.2.28-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:851fa70df44325e1e4cdb79c5e676e91a78147b1b543db2aec8734d2add30ec2", size = 860095, upload-time = "2026-02-28T02:16:28.772Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/393c94cbedda79a0f5f2435ebd01644aba0b338d327eb24b4aa5b8d6c07f/regex-2026.2.28-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:516604edd17b1c2c3e579cf4e9b25a53bf8fa6e7cedddf1127804d3e0140ca64", size = 906583, upload-time = "2026-02-28T02:16:30.977Z" }, + { url = "https://files.pythonhosted.org/packages/2c/73/a72820f47ca5abf2b5d911d0407ba5178fc52cf9780191ed3a54f5f419a2/regex-2026.2.28-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7ce83654d1ab701cb619285a18a8e5a889c1216d746ddc710c914ca5fd71022", size = 800234, upload-time = "2026-02-28T02:16:32.55Z" }, + { url = "https://files.pythonhosted.org/packages/34/b3/6e6a4b7b31fa998c4cf159a12cbeaf356386fbd1a8be743b1e80a3da51e4/regex-2026.2.28-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2791948f7c70bb9335a9102df45e93d428f4b8128020d85920223925d73b9e1", size = 772803, upload-time = "2026-02-28T02:16:34.029Z" }, + { url = "https://files.pythonhosted.org/packages/10/e7/5da0280c765d5a92af5e1cd324b3fe8464303189cbaa449de9a71910e273/regex-2026.2.28-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:03a83cc26aa2acda6b8b9dfe748cf9e84cbd390c424a1de34fdcef58961a297a", size = 781117, upload-time = "2026-02-28T02:16:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/76/39/0b8d7efb256ae34e1b8157acc1afd8758048a1cf0196e1aec2e71fd99f4b/regex-2026.2.28-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ec6f5674c5dc836994f50f1186dd1fafde4be0666aae201ae2fcc3d29d8adf27", size = 854224, upload-time = "2026-02-28T02:16:38.119Z" }, + { url = "https://files.pythonhosted.org/packages/21/ff/a96d483ebe8fe6d1c67907729202313895d8de8495569ec319c6f29d0438/regex-2026.2.28-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:50c2fc924749543e0eacc93ada6aeeb3ea5f6715825624baa0dccaec771668ae", size = 761898, upload-time = "2026-02-28T02:16:40.333Z" }, + { url = "https://files.pythonhosted.org/packages/89/bd/d4f2e75cb4a54b484e796017e37c0d09d8a0a837de43d17e238adf163f4e/regex-2026.2.28-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ba55c50f408fb5c346a3a02d2ce0ebc839784e24f7c9684fde328ff063c3cdea", size = 844832, upload-time = "2026-02-28T02:16:41.875Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a7/428a135cf5e15e4e11d1e696eb2bf968362f8ea8a5f237122e96bc2ae950/regex-2026.2.28-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:edb1b1b3a5576c56f08ac46f108c40333f222ebfd5cf63afdfa3aab0791ebe5b", size = 788347, upload-time = "2026-02-28T02:16:43.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/59/68691428851cf9c9c3707217ab1d9b47cfeec9d153a49919e6c368b9e926/regex-2026.2.28-cp311-cp311-win32.whl", hash = "sha256:948c12ef30ecedb128903c2c2678b339746eb7c689c5c21957c4a23950c96d15", size = 266033, upload-time = "2026-02-28T02:16:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/1483de1c57024e89296cbcceb9cccb3f625d416ddb46e570be185c9b05a9/regex-2026.2.28-cp311-cp311-win_amd64.whl", hash = "sha256:fd63453f10d29097cc3dc62d070746523973fb5aa1c66d25f8558bebd47fed61", size = 277978, upload-time = "2026-02-28T02:16:46.75Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/abec45dc6e7252e3dbc797120496e43bb5730a7abf0d9cb69340696a2f2d/regex-2026.2.28-cp311-cp311-win_arm64.whl", hash = "sha256:00f2b8d9615aa165fdff0a13f1a92049bfad555ee91e20d246a51aa0b556c60a", size = 270340, upload-time = "2026-02-28T02:16:48.626Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/9061b03cf0fc4b5fa2c3984cbbaed54324377e440a5c5a29d29a72518d62/regex-2026.2.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcf26c3c6d0da98fada8ae4ef0aa1c3405a431c0a77eb17306d38a89b02adcd7", size = 489574, upload-time = "2026-02-28T02:16:50.455Z" }, + { url = "https://files.pythonhosted.org/packages/77/83/0c8a5623a233015595e3da499c5a1c13720ac63c107897a6037bb97af248/regex-2026.2.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02473c954af35dd2defeb07e44182f5705b30ea3f351a7cbffa9177beb14da5d", size = 291426, upload-time = "2026-02-28T02:16:52.52Z" }, + { url = "https://files.pythonhosted.org/packages/9e/06/3ef1ac6910dc3295ebd71b1f9bfa737e82cfead211a18b319d45f85ddd09/regex-2026.2.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b65d33a17101569f86d9c5966a8b1d7fbf8afdda5a8aa219301b0a80f58cf7d", size = 289200, upload-time = "2026-02-28T02:16:54.08Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c9/8cc8d850b35ab5650ff6756a1cb85286e2000b66c97520b29c1587455344/regex-2026.2.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71dcecaa113eebcc96622c17692672c2d104b1d71ddf7adeda90da7ddeb26fc", size = 796765, upload-time = "2026-02-28T02:16:55.905Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5d/57702597627fc23278ebf36fbb497ac91c0ce7fec89ac6c81e420ca3e38c/regex-2026.2.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:481df4623fa4969c8b11f3433ed7d5e3dc9cec0f008356c3212b3933fb77e3d8", size = 863093, upload-time = "2026-02-28T02:16:58.094Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/f3ecad537ca2811b4d26b54ca848cf70e04fcfc138667c146a9f3157779c/regex-2026.2.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64e7c6ad614573e0640f271e811a408d79a9e1fe62a46adb602f598df42a818d", size = 909455, upload-time = "2026-02-28T02:17:00.918Z" }, + { url = "https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4", size = 802037, upload-time = "2026-02-28T02:17:02.842Z" }, + { url = "https://files.pythonhosted.org/packages/44/7c/c6d91d8911ac6803b45ca968e8e500c46934e58c0903cbc6d760ee817a0a/regex-2026.2.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:864cdd1a2ef5716b0ab468af40139e62ede1b3a53386b375ec0786bb6783fc05", size = 775113, upload-time = "2026-02-28T02:17:04.506Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8d/4a9368d168d47abd4158580b8c848709667b1cd293ff0c0c277279543bd0/regex-2026.2.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:511f7419f7afab475fd4d639d4aedfc54205bcb0800066753ef68a59f0f330b5", size = 784194, upload-time = "2026-02-28T02:17:06.888Z" }, + { url = "https://files.pythonhosted.org/packages/cc/bf/2c72ab5d8b7be462cb1651b5cc333da1d0068740342f350fcca3bca31947/regex-2026.2.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b42f7466e32bf15a961cf09f35fa6323cc72e64d3d2c990b10de1274a5da0a59", size = 856846, upload-time = "2026-02-28T02:17:09.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f4/6b65c979bb6d09f51bb2d2a7bc85de73c01ec73335d7ddd202dcb8cd1c8f/regex-2026.2.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8710d61737b0c0ce6836b1da7109f20d495e49b3809f30e27e9560be67a257bf", size = 763516, upload-time = "2026-02-28T02:17:11.004Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/29ea5e27400ee86d2cc2b4e80aa059df04eaf78b4f0c18576ae077aeff68/regex-2026.2.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4390c365fd2d45278f45afd4673cb90f7285f5701607e3ad4274df08e36140ae", size = 849278, upload-time = "2026-02-28T02:17:12.693Z" }, + { url = "https://files.pythonhosted.org/packages/1d/91/3233d03b5f865111cd517e1c95ee8b43e8b428d61fa73764a80c9bb6f537/regex-2026.2.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb3b1db8ff6c7b8bf838ab05583ea15230cb2f678e569ab0e3a24d1e8320940b", size = 790068, upload-time = "2026-02-28T02:17:14.9Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/abc706c1fb03b4580a09645b206a3fc032f5a9f457bc1a8038ac555658ab/regex-2026.2.28-cp312-cp312-win32.whl", hash = "sha256:f8ed9a5d4612df9d4de15878f0bc6aa7a268afbe5af21a3fdd97fa19516e978c", size = 266416, upload-time = "2026-02-28T02:17:17.15Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/2a6f7dff190e5fa9df9fb4acf2fdf17a1aa0f7f54596cba8de608db56b3a/regex-2026.2.28-cp312-cp312-win_amd64.whl", hash = "sha256:01d65fd24206c8e1e97e2e31b286c59009636c022eb5d003f52760b0f42155d4", size = 277297, upload-time = "2026-02-28T02:17:18.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f0/58a2484851fadf284458fdbd728f580d55c1abac059ae9f048c63b92f427/regex-2026.2.28-cp312-cp312-win_arm64.whl", hash = "sha256:c0b5ccbb8ffb433939d248707d4a8b31993cb76ab1a0187ca886bf50e96df952", size = 270408, upload-time = "2026-02-28T02:17:20.328Z" }, + { url = "https://files.pythonhosted.org/packages/87/f6/dc9ef48c61b79c8201585bf37fa70cd781977da86e466cd94e8e95d2443b/regex-2026.2.28-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6d63a07e5ec8ce7184452cb00c41c37b49e67dc4f73b2955b5b8e782ea970784", size = 489311, upload-time = "2026-02-28T02:17:22.591Z" }, + { url = "https://files.pythonhosted.org/packages/95/c8/c20390f2232d3f7956f420f4ef1852608ad57aa26c3dd78516cb9f3dc913/regex-2026.2.28-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e59bc8f30414d283ae8ee1617b13d8112e7135cb92830f0ec3688cb29152585a", size = 291285, upload-time = "2026-02-28T02:17:24.355Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a6/ba1068a631ebd71a230e7d8013fcd284b7c89c35f46f34a7da02082141b1/regex-2026.2.28-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:de0cf053139f96219ccfabb4a8dd2d217c8c82cb206c91d9f109f3f552d6b43d", size = 289051, upload-time = "2026-02-28T02:17:26.722Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1b/7cc3b7af4c244c204b7a80924bd3d85aecd9ba5bc82b485c5806ee8cda9e/regex-2026.2.28-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb4db2f17e6484904f986c5a657cec85574c76b5c5e61c7aae9ffa1bc6224f95", size = 796842, upload-time = "2026-02-28T02:17:29.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/87/26bd03efc60e0d772ac1e7b60a2e6325af98d974e2358f659c507d3c76db/regex-2026.2.28-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52b017b35ac2214d0db5f4f90e303634dc44e4aba4bd6235a27f97ecbe5b0472", size = 863083, upload-time = "2026-02-28T02:17:31.363Z" }, + { url = "https://files.pythonhosted.org/packages/ae/54/aeaf4afb1aa0a65e40de52a61dc2ac5b00a83c6cb081c8a1d0dda74f3010/regex-2026.2.28-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69fc560ccbf08a09dc9b52ab69cacfae51e0ed80dc5693078bdc97db2f91ae96", size = 909412, upload-time = "2026-02-28T02:17:33.248Z" }, + { url = "https://files.pythonhosted.org/packages/12/2f/049901def913954e640d199bbc6a7ca2902b6aeda0e5da9d17f114100ec2/regex-2026.2.28-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e61eea47230eba62a31f3e8a0e3164d0f37ef9f40529fb2c79361bc6b53d2a92", size = 802101, upload-time = "2026-02-28T02:17:35.053Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/512fb9ff7f5b15ea204bb1967ebb649059446decacccb201381f9fa6aad4/regex-2026.2.28-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4f5c0b182ad4269e7381b7c27fdb0408399881f7a92a4624fd5487f2971dfc11", size = 775260, upload-time = "2026-02-28T02:17:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/9a92935878aba19bd72706b9db5646a6f993d99b3f6ed42c02ec8beb1d61/regex-2026.2.28-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:96f6269a2882fbb0ee76967116b83679dc628e68eaea44e90884b8d53d833881", size = 784311, upload-time = "2026-02-28T02:17:39.855Z" }, + { url = "https://files.pythonhosted.org/packages/09/d3/fc51a8a738a49a6b6499626580554c9466d3ea561f2b72cfdc72e4149773/regex-2026.2.28-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b5acd4b6a95f37c3c3828e5d053a7d4edaedb85de551db0153754924cb7c83e3", size = 856876, upload-time = "2026-02-28T02:17:42.317Z" }, + { url = "https://files.pythonhosted.org/packages/08/b7/2e641f3d084b120ca4c52e8c762a78da0b32bf03ef546330db3e2635dc5f/regex-2026.2.28-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2234059cfe33d9813a3677ef7667999caea9eeaa83fef98eb6ce15c6cf9e0215", size = 763632, upload-time = "2026-02-28T02:17:45.073Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6d/0009021d97e79ee99f3d8641f0a8d001eed23479ade4c3125a5480bf3e2d/regex-2026.2.28-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c15af43c72a7fb0c97cbc66fa36a43546eddc5c06a662b64a0cbf30d6ac40944", size = 849320, upload-time = "2026-02-28T02:17:47.192Z" }, + { url = "https://files.pythonhosted.org/packages/05/7a/51cfbad5758f8edae430cb21961a9c8d04bce1dae4d2d18d4186eec7cfa1/regex-2026.2.28-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9185cc63359862a6e80fe97f696e04b0ad9a11c4ac0a4a927f979f611bfe3768", size = 790152, upload-time = "2026-02-28T02:17:49.067Z" }, + { url = "https://files.pythonhosted.org/packages/90/3d/a83e2b6b3daa142acb8c41d51de3876186307d5cb7490087031747662500/regex-2026.2.28-cp313-cp313-win32.whl", hash = "sha256:fb66e5245db9652abd7196ace599b04d9c0e4aa7c8f0e2803938377835780081", size = 266398, upload-time = "2026-02-28T02:17:50.744Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/16e9ebb1fe5425e11b9596c8d57bf8877dcb32391da0bfd33742e3290637/regex-2026.2.28-cp313-cp313-win_amd64.whl", hash = "sha256:71a911098be38c859ceb3f9a9ce43f4ed9f4c6720ad8684a066ea246b76ad9ff", size = 277282, upload-time = "2026-02-28T02:17:53.074Z" }, + { url = "https://files.pythonhosted.org/packages/07/b4/92851335332810c5a89723bf7a7e35c7209f90b7d4160024501717b28cc9/regex-2026.2.28-cp313-cp313-win_arm64.whl", hash = "sha256:39bb5727650b9a0275c6a6690f9bb3fe693a7e6cc5c3155b1240aedf8926423e", size = 270382, upload-time = "2026-02-28T02:17:54.888Z" }, + { url = "https://files.pythonhosted.org/packages/24/07/6c7e4cec1e585959e96cbc24299d97e4437a81173217af54f1804994e911/regex-2026.2.28-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:97054c55db06ab020342cc0d35d6f62a465fa7662871190175f1ad6c655c028f", size = 492541, upload-time = "2026-02-28T02:17:56.813Z" }, + { url = "https://files.pythonhosted.org/packages/7c/13/55eb22ada7f43d4f4bb3815b6132183ebc331c81bd496e2d1f3b8d862e0d/regex-2026.2.28-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0d25a10811de831c2baa6aef3c0be91622f44dd8d31dd12e69f6398efb15e48b", size = 292984, upload-time = "2026-02-28T02:17:58.538Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/c301f8cb29ce9644a5ef85104c59244e6e7e90994a0f458da4d39baa8e17/regex-2026.2.28-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d6cfe798d8da41bb1862ed6e0cba14003d387c3c0c4a5d45591076ae9f0ce2f8", size = 291509, upload-time = "2026-02-28T02:18:00.208Z" }, + { url = "https://files.pythonhosted.org/packages/b5/43/aabe384ec1994b91796e903582427bc2ffaed9c4103819ed3c16d8e749f3/regex-2026.2.28-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd0ce43e71d825b7c0661f9c54d4d74bd97c56c3fd102a8985bcfea48236bacb", size = 809429, upload-time = "2026-02-28T02:18:02.328Z" }, + { url = "https://files.pythonhosted.org/packages/04/b8/8d2d987a816720c4f3109cee7c06a4b24ad0e02d4fc74919ab619e543737/regex-2026.2.28-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00945d007fd74a9084d2ab79b695b595c6b7ba3698972fadd43e23230c6979c1", size = 869422, upload-time = "2026-02-28T02:18:04.23Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/2c004509e763c0c3719f97c03eca26473bffb3868d54c5f280b8cd4f9e3d/regex-2026.2.28-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bec23c11cbbf09a4df32fe50d57cbdd777bc442269b6e39a1775654f1c95dee2", size = 915175, upload-time = "2026-02-28T02:18:06.791Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/fd429066da487ef555a9da73bf214894aec77fc8c66a261ee355a69871a8/regex-2026.2.28-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5cdcc17d935c8f9d3f4db5c2ebe2640c332e3822ad5d23c2f8e0228e6947943a", size = 812044, upload-time = "2026-02-28T02:18:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ca/feedb7055c62a3f7f659971bf45f0e0a87544b6b0cf462884761453f97c5/regex-2026.2.28-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a448af01e3d8031c89c5d902040b124a5e921a25c4e5e07a861ca591ce429341", size = 782056, upload-time = "2026-02-28T02:18:10.777Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/1aa959ed0d25c1dd7dd5047ea8ba482ceaef38ce363c401fd32a6b923e60/regex-2026.2.28-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:10d28e19bd4888e4abf43bd3925f3c134c52fdf7259219003588a42e24c2aa25", size = 798743, upload-time = "2026-02-28T02:18:13.025Z" }, + { url = "https://files.pythonhosted.org/packages/3b/1f/dadb9cf359004784051c897dcf4d5d79895f73a1bbb7b827abaa4814ae80/regex-2026.2.28-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:99985a2c277dcb9ccb63f937451af5d65177af1efdeb8173ac55b61095a0a05c", size = 864633, upload-time = "2026-02-28T02:18:16.84Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f1/b9a25eb24e1cf79890f09e6ec971ee5b511519f1851de3453bc04f6c902b/regex-2026.2.28-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e1e7b24cb3ae9953a560c563045d1ba56ee4749fbd05cf21ba571069bd7be81b", size = 770862, upload-time = "2026-02-28T02:18:18.892Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/c5cb10b7aa6f182f9247a30cc9527e326601f46f4df864ac6db588d11fcd/regex-2026.2.28-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d8511a01d0e4ee1992eb3ba19e09bc1866fe03f05129c3aec3fdc4cbc77aad3f", size = 854788, upload-time = "2026-02-28T02:18:21.475Z" }, + { url = "https://files.pythonhosted.org/packages/0a/50/414ba0731c4bd40b011fa4703b2cc86879ec060c64f2a906e65a56452589/regex-2026.2.28-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:aaffaecffcd2479ce87aa1e74076c221700b7c804e48e98e62500ee748f0f550", size = 800184, upload-time = "2026-02-28T02:18:23.492Z" }, + { url = "https://files.pythonhosted.org/packages/69/50/0c7290987f97e7e6830b0d853f69dc4dc5852c934aae63e7fdcd76b4c383/regex-2026.2.28-cp313-cp313t-win32.whl", hash = "sha256:ef77bdde9c9eba3f7fa5b58084b29bbcc74bcf55fdbeaa67c102a35b5bd7e7cc", size = 269137, upload-time = "2026-02-28T02:18:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/68/80/ef26ff90e74ceb4051ad6efcbbb8a4be965184a57e879ebcbdef327d18fa/regex-2026.2.28-cp313-cp313t-win_amd64.whl", hash = "sha256:98adf340100cbe6fbaf8e6dc75e28f2c191b1be50ffefe292fb0e6f6eefdb0d8", size = 280682, upload-time = "2026-02-28T02:18:27.205Z" }, + { url = "https://files.pythonhosted.org/packages/69/8b/fbad9c52e83ffe8f97e3ed1aa0516e6dff6bb633a41da9e64645bc7efdc5/regex-2026.2.28-cp313-cp313t-win_arm64.whl", hash = "sha256:2fb950ac1d88e6b6a9414381f403797b236f9fa17e1eee07683af72b1634207b", size = 271735, upload-time = "2026-02-28T02:18:29.015Z" }, + { url = "https://files.pythonhosted.org/packages/cf/03/691015f7a7cb1ed6dacb2ea5de5682e4858e05a4c5506b2839cd533bbcd6/regex-2026.2.28-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:78454178c7df31372ea737996fb7f36b3c2c92cccc641d251e072478afb4babc", size = 489497, upload-time = "2026-02-28T02:18:30.889Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ba/8db8fd19afcbfa0e1036eaa70c05f20ca8405817d4ad7a38a6b4c2f031ac/regex-2026.2.28-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:5d10303dd18cedfd4d095543998404df656088240bcfd3cd20a8f95b861f74bd", size = 291295, upload-time = "2026-02-28T02:18:33.426Z" }, + { url = "https://files.pythonhosted.org/packages/5a/79/9aa0caf089e8defef9b857b52fc53801f62ff868e19e5c83d4a96612eba1/regex-2026.2.28-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:19a9c9e0a8f24f39d575a6a854d516b48ffe4cbdcb9de55cb0570a032556ecff", size = 289275, upload-time = "2026-02-28T02:18:35.247Z" }, + { url = "https://files.pythonhosted.org/packages/eb/26/ee53117066a30ef9c883bf1127eece08308ccf8ccd45c45a966e7a665385/regex-2026.2.28-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09500be324f49b470d907b3ef8af9afe857f5cca486f853853f7945ddbf75911", size = 797176, upload-time = "2026-02-28T02:18:37.15Z" }, + { url = "https://files.pythonhosted.org/packages/05/1b/67fb0495a97259925f343ae78b5d24d4a6624356ae138b57f18bd43006e4/regex-2026.2.28-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb1c4ff62277d87a7335f2c1ea4e0387b8f2b3ad88a64efd9943906aafad4f33", size = 863813, upload-time = "2026-02-28T02:18:39.478Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/93ac9bbafc53618091c685c7ed40239a90bf9f2a82c983f0baa97cb7ae07/regex-2026.2.28-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b8b3f1be1738feadc69f62daa250c933e85c6f34fa378f54a7ff43807c1b9117", size = 908678, upload-time = "2026-02-28T02:18:41.619Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/a8f5e0561702b25239846a16349feece59712ae20598ebb205580332a471/regex-2026.2.28-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc8ed8c3f41c27acb83f7b6a9eb727a73fc6663441890c5cb3426a5f6a91ce7d", size = 801528, upload-time = "2026-02-28T02:18:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/96/5d/ed6d4cbde80309854b1b9f42d9062fee38ade15f7eb4909f6ef2440403b5/regex-2026.2.28-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa539be029844c0ce1114762d2952ab6cfdd7c7c9bd72e0db26b94c3c36dcc5a", size = 775373, upload-time = "2026-02-28T02:18:46.102Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e9/6e53c34e8068b9deec3e87210086ecb5b9efebdefca6b0d3fa43d66dcecb/regex-2026.2.28-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7900157786428a79615a8264dac1f12c9b02957c473c8110c6b1f972dcecaddf", size = 784859, upload-time = "2026-02-28T02:18:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/48/3c/736e1c7ca7f0dcd2ae33819888fdc69058a349b7e5e84bc3e2f296bbf794/regex-2026.2.28-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0b1d2b07614d95fa2bf8a63fd1e98bd8fa2b4848dc91b1efbc8ba219fdd73952", size = 857813, upload-time = "2026-02-28T02:18:50.576Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7c/48c4659ad9da61f58e79dbe8c05223e0006696b603c16eb6b5cbfbb52c27/regex-2026.2.28-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b389c61aa28a79c2e0527ac36da579869c2e235a5b208a12c5b5318cda2501d8", size = 763705, upload-time = "2026-02-28T02:18:52.59Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a1/bc1c261789283128165f71b71b4b221dd1b79c77023752a6074c102f18d8/regex-2026.2.28-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f467cb602f03fbd1ab1908f68b53c649ce393fde056628dc8c7e634dab6bfc07", size = 848734, upload-time = "2026-02-28T02:18:54.595Z" }, + { url = "https://files.pythonhosted.org/packages/10/d8/979407faf1397036e25a5ae778157366a911c0f382c62501009f4957cf86/regex-2026.2.28-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c8cb2deba42f5ec1ede46374e990f8adc5e6456a57ac1a261b19be6f28e4e6", size = 789871, upload-time = "2026-02-28T02:18:57.34Z" }, + { url = "https://files.pythonhosted.org/packages/03/23/da716821277115fcb1f4e3de1e5dc5023a1e6533598c486abf5448612579/regex-2026.2.28-cp314-cp314-win32.whl", hash = "sha256:9036b400b20e4858d56d117108d7813ed07bb7803e3eed766675862131135ca6", size = 271825, upload-time = "2026-02-28T02:18:59.202Z" }, + { url = "https://files.pythonhosted.org/packages/91/ff/90696f535d978d5f16a52a419be2770a8d8a0e7e0cfecdbfc31313df7fab/regex-2026.2.28-cp314-cp314-win_amd64.whl", hash = "sha256:1d367257cd86c1cbb97ea94e77b373a0bbc2224976e247f173d19e8f18b4afa7", size = 280548, upload-time = "2026-02-28T02:19:01.049Z" }, + { url = "https://files.pythonhosted.org/packages/69/f9/5e1b5652fc0af3fcdf7677e7df3ad2a0d47d669b34ac29a63bb177bb731b/regex-2026.2.28-cp314-cp314-win_arm64.whl", hash = "sha256:5e68192bb3a1d6fb2836da24aa494e413ea65853a21505e142e5b1064a595f3d", size = 273444, upload-time = "2026-02-28T02:19:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/d3/eb/8389f9e940ac89bcf58d185e230a677b4fd07c5f9b917603ad5c0f8fa8fe/regex-2026.2.28-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a5dac14d0872eeb35260a8e30bac07ddf22adc1e3a0635b52b02e180d17c9c7e", size = 492546, upload-time = "2026-02-28T02:19:05.378Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c7/09441d27ce2a6fa6a61ea3150ea4639c1dcda9b31b2ea07b80d6937b24dd/regex-2026.2.28-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ec0c608b7a7465ffadb344ed7c987ff2f11ee03f6a130b569aa74d8a70e8333c", size = 292986, upload-time = "2026-02-28T02:19:07.24Z" }, + { url = "https://files.pythonhosted.org/packages/fb/69/4144b60ed7760a6bd235e4087041f487aa4aa62b45618ce018b0c14833ea/regex-2026.2.28-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7815afb0ca45456613fdaf60ea9c993715511c8d53a83bc468305cbc0ee23c7", size = 291518, upload-time = "2026-02-28T02:19:09.698Z" }, + { url = "https://files.pythonhosted.org/packages/2d/be/77e5426cf5948c82f98c53582009ca9e94938c71f73a8918474f2e2990bb/regex-2026.2.28-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b059e71ec363968671693a78c5053bd9cb2fe410f9b8e4657e88377ebd603a2e", size = 809464, upload-time = "2026-02-28T02:19:12.494Z" }, + { url = "https://files.pythonhosted.org/packages/45/99/2c8c5ac90dc7d05c6e7d8e72c6a3599dc08cd577ac476898e91ca787d7f1/regex-2026.2.28-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8cf76f1a29f0e99dcfd7aef1551a9827588aae5a737fe31442021165f1920dc", size = 869553, upload-time = "2026-02-28T02:19:15.151Z" }, + { url = "https://files.pythonhosted.org/packages/53/34/daa66a342f0271e7737003abf6c3097aa0498d58c668dbd88362ef94eb5d/regex-2026.2.28-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:180e08a435a0319e6a4821c3468da18dc7001987e1c17ae1335488dfe7518dd8", size = 915289, upload-time = "2026-02-28T02:19:17.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c7/e22c2aaf0a12e7e22ab19b004bb78d32ca1ecc7ef245949935463c5567de/regex-2026.2.28-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e496956106fd59ba6322a8ea17141a27c5040e5ee8f9433ae92d4e5204462a0", size = 812156, upload-time = "2026-02-28T02:19:20.011Z" }, + { url = "https://files.pythonhosted.org/packages/7f/bb/2dc18c1efd9051cf389cd0d7a3a4d90f6804b9fff3a51b5dc3c85b935f71/regex-2026.2.28-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bba2b18d70eeb7b79950f12f633beeecd923f7c9ad6f6bae28e59b4cb3ab046b", size = 782215, upload-time = "2026-02-28T02:19:22.047Z" }, + { url = "https://files.pythonhosted.org/packages/17/1e/9e4ec9b9013931faa32226ec4aa3c71fe664a6d8a2b91ac56442128b332f/regex-2026.2.28-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6db7bfae0f8a2793ff1f7021468ea55e2699d0790eb58ee6ab36ae43aa00bc5b", size = 798925, upload-time = "2026-02-28T02:19:24.173Z" }, + { url = "https://files.pythonhosted.org/packages/71/57/a505927e449a9ccb41e2cc8d735e2abe3444b0213d1cf9cb364a8c1f2524/regex-2026.2.28-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d0b02e8b7e5874b48ae0f077ecca61c1a6a9f9895e9c6dfb191b55b242862033", size = 864701, upload-time = "2026-02-28T02:19:26.376Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ad/c62cb60cdd93e13eac5b3d9d6bd5d284225ed0e3329426f94d2552dd7cca/regex-2026.2.28-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:25b6eb660c5cf4b8c3407a1ed462abba26a926cc9965e164268a3267bcc06a43", size = 770899, upload-time = "2026-02-28T02:19:29.38Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5a/874f861f5c3d5ab99633e8030dee1bc113db8e0be299d1f4b07f5b5ec349/regex-2026.2.28-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5a932ea8ad5d0430351ff9c76c8db34db0d9f53c1d78f06022a21f4e290c5c18", size = 854727, upload-time = "2026-02-28T02:19:31.494Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ca/d2c03b0efde47e13db895b975b2be6a73ed90b8ba963677927283d43bf74/regex-2026.2.28-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2c95e1a2b0f89d01e821ff4de1be4b5d73d1f4b0bf679fa27c1ad8d2327f1a", size = 800366, upload-time = "2026-02-28T02:19:34.248Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/ee13b20b763b8989f7c75d592bfd5de37dc1181814a2a2747fedcf97e3ba/regex-2026.2.28-cp314-cp314t-win32.whl", hash = "sha256:bbb882061f742eb5d46f2f1bd5304055be0a66b783576de3d7eef1bed4778a6e", size = 274936, upload-time = "2026-02-28T02:19:36.313Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e7/d8020e39414c93af7f0d8688eabcecece44abfd5ce314b21dfda0eebd3d8/regex-2026.2.28-cp314-cp314t-win_amd64.whl", hash = "sha256:6591f281cb44dc13de9585b552cec6fc6cf47fb2fe7a48892295ee9bc4a612f9", size = 284779, upload-time = "2026-02-28T02:19:38.625Z" }, + { url = "https://files.pythonhosted.org/packages/13/c0/ad225f4a405827486f1955283407cf758b6d2fb966712644c5f5aef33d1b/regex-2026.2.28-cp314-cp314t-win_arm64.whl", hash = "sha256:dee50f1be42222f89767b64b283283ef963189da0dda4a515aa54a5563c62dec", size = 275010, upload-time = "2026-02-28T02:19:40.65Z" }, +] + +[[package]] +name = "requests" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "ruamel-yaml" +version = "0.18.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ruamel-yaml-clib", marker = "python_full_version < '3.14' and platform_python_implementation == 'CPython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/87/6da0df742a4684263261c253f00edd5829e6aca970fff69e75028cccc547/ruamel.yaml-0.18.14.tar.gz", hash = "sha256:7227b76aaec364df15936730efbf7d72b30c0b79b1d578bbb8e3dcb2d81f52b7", size = 145511, upload-time = "2025-06-09T08:51:09.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/6d/6fe4805235e193aad4aaf979160dd1f3c487c57d48b810c816e6e842171b/ruamel.yaml-0.18.14-py3-none-any.whl", hash = "sha256:710ff198bb53da66718c7db27eec4fbcc9aa6ca7204e4c1df2f282b6fe5eb6b2", size = 118570, upload-time = "2025-06-09T08:51:06.348Z" }, +] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/97/60fda20e2fb54b83a61ae14648b0817c8f5d84a3821e40bfbdae1437026a/ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600", size = 225794, upload-time = "2025-11-16T16:12:59.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/80/8ce7b9af532aa94dd83360f01ce4716264db73de6bc8efd22c32341f6658/ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c583229f336682b7212a43d2fa32c30e643d3076178fb9f7a6a14dde85a2d8bd", size = 147998, upload-time = "2025-11-16T16:13:13.241Z" }, + { url = "https://files.pythonhosted.org/packages/53/09/de9d3f6b6701ced5f276d082ad0f980edf08ca67114523d1b9264cd5e2e0/ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56ea19c157ed8c74b6be51b5fa1c3aff6e289a041575f0556f66e5fb848bb137", size = 132743, upload-time = "2025-11-16T16:13:14.265Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f7/73a9b517571e214fe5c246698ff3ed232f1ef863c8ae1667486625ec688a/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5fea0932358e18293407feb921d4f4457db837b67ec1837f87074667449f9401", size = 731459, upload-time = "2025-11-16T20:22:44.338Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a2/0dc0013169800f1c331a6f55b1282c1f4492a6d32660a0cf7b89e6684919/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71831bd61fbdb7aa0399d5c4da06bea37107ab5c79ff884cc07f2450910262", size = 749289, upload-time = "2025-11-16T16:13:15.633Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ed/3fb20a1a96b8dc645d88c4072df481fe06e0289e4d528ebbdcc044ebc8b3/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:617d35dc765715fa86f8c3ccdae1e4229055832c452d4ec20856136acc75053f", size = 777630, upload-time = "2025-11-16T16:13:16.898Z" }, + { url = "https://files.pythonhosted.org/packages/60/50/6842f4628bc98b7aa4733ab2378346e1441e150935ad3b9f3c3c429d9408/ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b45498cc81a4724a2d42273d6cfc243c0547ad7c6b87b4f774cb7bcc131c98d", size = 744368, upload-time = "2025-11-16T16:13:18.117Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b0/128ae8e19a7d794c2e36130a72b3bb650ce1dd13fb7def6cf10656437dcf/ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:def5663361f6771b18646620fca12968aae730132e104688766cf8a3b1d65922", size = 745233, upload-time = "2025-11-16T20:22:45.833Z" }, + { url = "https://files.pythonhosted.org/packages/75/05/91130633602d6ba7ce3e07f8fc865b40d2a09efd4751c740df89eed5caf9/ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:014181cdec565c8745b7cbc4de3bf2cc8ced05183d986e6d1200168e5bb59490", size = 770963, upload-time = "2025-11-16T16:13:19.344Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4b/fd4542e7f33d7d1bc64cc9ac9ba574ce8cf145569d21f5f20133336cdc8c/ruamel_yaml_clib-0.2.15-cp311-cp311-win32.whl", hash = "sha256:d290eda8f6ada19e1771b54e5706b8f9807e6bb08e873900d5ba114ced13e02c", size = 102640, upload-time = "2025-11-16T16:13:20.498Z" }, + { url = "https://files.pythonhosted.org/packages/bb/eb/00ff6032c19c7537371e3119287999570867a0eafb0154fccc80e74bf57a/ruamel_yaml_clib-0.2.15-cp311-cp311-win_amd64.whl", hash = "sha256:bdc06ad71173b915167702f55d0f3f027fc61abd975bd308a0968c02db4a4c3e", size = 121996, upload-time = "2025-11-16T16:13:21.855Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/5fde11a0722d676e469d3d6f78c6a17591b9c7e0072ca359801c4bd17eee/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb15a2e2a90c8475df45c0949793af1ff413acfb0a716b8b94e488ea95ce7cff", size = 149088, upload-time = "2025-11-16T16:13:22.836Z" }, + { url = "https://files.pythonhosted.org/packages/85/82/4d08ac65ecf0ef3b046421985e66301a242804eb9a62c93ca3437dc94ee0/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64da03cbe93c1e91af133f5bec37fd24d0d4ba2418eaf970d7166b0a26a148a2", size = 134553, upload-time = "2025-11-16T16:13:24.151Z" }, + { url = "https://files.pythonhosted.org/packages/b9/cb/22366d68b280e281a932403b76da7a988108287adff2bfa5ce881200107a/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f6d3655e95a80325b84c4e14c080b2470fe4f33b6846f288379ce36154993fb1", size = 737468, upload-time = "2025-11-16T20:22:47.335Z" }, + { url = "https://files.pythonhosted.org/packages/71/73/81230babf8c9e33770d43ed9056f603f6f5f9665aea4177a2c30ae48e3f3/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71845d377c7a47afc6592aacfea738cc8a7e876d586dfba814501d8c53c1ba60", size = 753349, upload-time = "2025-11-16T16:13:26.269Z" }, + { url = "https://files.pythonhosted.org/packages/61/62/150c841f24cda9e30f588ef396ed83f64cfdc13b92d2f925bb96df337ba9/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9", size = 788211, upload-time = "2025-11-16T16:13:27.441Z" }, + { url = "https://files.pythonhosted.org/packages/30/93/e79bd9cbecc3267499d9ead919bd61f7ddf55d793fb5ef2b1d7d92444f35/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4b293a37dc97e2b1e8a1aec62792d1e52027087c8eea4fc7b5abd2bdafdd6642", size = 743203, upload-time = "2025-11-16T16:13:28.671Z" }, + { url = "https://files.pythonhosted.org/packages/8d/06/1eb640065c3a27ce92d76157f8efddb184bd484ed2639b712396a20d6dce/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512571ad41bba04eac7268fe33f7f4742210ca26a81fe0c75357fa682636c690", size = 747292, upload-time = "2025-11-16T20:22:48.584Z" }, + { url = "https://files.pythonhosted.org/packages/a5/21/ee353e882350beab65fcc47a91b6bdc512cace4358ee327af2962892ff16/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5e9f630c73a490b758bf14d859a39f375e6999aea5ddd2e2e9da89b9953486a", size = 771624, upload-time = "2025-11-16T16:13:29.853Z" }, + { url = "https://files.pythonhosted.org/packages/57/34/cc1b94057aa867c963ecf9ea92ac59198ec2ee3a8d22a126af0b4d4be712/ruamel_yaml_clib-0.2.15-cp312-cp312-win32.whl", hash = "sha256:f4421ab780c37210a07d138e56dd4b51f8642187cdfb433eb687fe8c11de0144", size = 100342, upload-time = "2025-11-16T16:13:31.067Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e5/8925a4208f131b218f9a7e459c0d6fcac8324ae35da269cb437894576366/ruamel_yaml_clib-0.2.15-cp312-cp312-win_amd64.whl", hash = "sha256:2b216904750889133d9222b7b873c199d48ecbb12912aca78970f84a5aa1a4bc", size = 119013, upload-time = "2025-11-16T16:13:32.164Z" }, + { url = "https://files.pythonhosted.org/packages/17/5e/2f970ce4c573dc30c2f95825f2691c96d55560268ddc67603dc6ea2dd08e/ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dcec721fddbb62e60c2801ba08c87010bd6b700054a09998c4d09c08147b8fb", size = 147450, upload-time = "2025-11-16T16:13:33.542Z" }, + { url = "https://files.pythonhosted.org/packages/d6/03/a1baa5b94f71383913f21b96172fb3a2eb5576a4637729adbf7cd9f797f8/ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:65f48245279f9bb301d1276f9679b82e4c080a1ae25e679f682ac62446fac471", size = 133139, upload-time = "2025-11-16T16:13:34.587Z" }, + { url = "https://files.pythonhosted.org/packages/dc/19/40d676802390f85784235a05788fd28940923382e3f8b943d25febbb98b7/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46895c17ead5e22bea5e576f1db7e41cb273e8d062c04a6a49013d9f60996c25", size = 731474, upload-time = "2025-11-16T20:22:49.934Z" }, + { url = "https://files.pythonhosted.org/packages/ce/bb/6ef5abfa43b48dd55c30d53e997f8f978722f02add61efba31380d73e42e/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3eb199178b08956e5be6288ee0b05b2fb0b5c1f309725ad25d9c6ea7e27f962a", size = 748047, upload-time = "2025-11-16T16:13:35.633Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5d/e4f84c9c448613e12bd62e90b23aa127ea4c46b697f3d760acc32cb94f25/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d1032919280ebc04a80e4fb1e93f7a738129857eaec9448310e638c8bccefcf", size = 782129, upload-time = "2025-11-16T16:13:36.781Z" }, + { url = "https://files.pythonhosted.org/packages/de/4b/e98086e88f76c00c88a6bcf15eae27a1454f661a9eb72b111e6bbb69024d/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab0df0648d86a7ecbd9c632e8f8d6b21bb21b5fc9d9e095c796cacf32a728d2d", size = 736848, upload-time = "2025-11-16T16:13:37.952Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5c/5964fcd1fd9acc53b7a3a5d9a05ea4f95ead9495d980003a557deb9769c7/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:331fb180858dd8534f0e61aa243b944f25e73a4dae9962bd44c46d1761126bbf", size = 741630, upload-time = "2025-11-16T20:22:51.718Z" }, + { url = "https://files.pythonhosted.org/packages/07/1e/99660f5a30fceb58494598e7d15df883a07292346ef5696f0c0ae5dee8c6/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd4c928ddf6bce586285daa6d90680b9c291cfd045fc40aad34e445d57b1bf51", size = 766619, upload-time = "2025-11-16T16:13:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/fa0344a9327b58b54970e56a27b32416ffbcfe4dcc0700605516708579b2/ruamel_yaml_clib-0.2.15-cp313-cp313-win32.whl", hash = "sha256:bf0846d629e160223805db9fe8cc7aec16aaa11a07310c50c8c7164efa440aec", size = 100171, upload-time = "2025-11-16T16:13:40.456Z" }, + { url = "https://files.pythonhosted.org/packages/06/c4/c124fbcef0684fcf3c9b72374c2a8c35c94464d8694c50f37eef27f5a145/ruamel_yaml_clib-0.2.15-cp313-cp313-win_amd64.whl", hash = "sha256:45702dfbea1420ba3450bb3dd9a80b33f0badd57539c6aac09f42584303e0db6", size = 118845, upload-time = "2025-11-16T16:13:41.481Z" }, + { url = "https://files.pythonhosted.org/packages/3e/bd/ab8459c8bb759c14a146990bf07f632c1cbec0910d4853feeee4be2ab8bb/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:753faf20b3a5906faf1fc50e4ddb8c074cb9b251e00b14c18b28492f933ac8ef", size = 147248, upload-time = "2025-11-16T16:13:42.872Z" }, + { url = "https://files.pythonhosted.org/packages/69/f2/c4cec0a30f1955510fde498aac451d2e52b24afdbcb00204d3a951b772c3/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:480894aee0b29752560a9de46c0e5f84a82602f2bc5c6cde8db9a345319acfdf", size = 133764, upload-time = "2025-11-16T16:13:43.932Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/2480d062281385a2ea4f7cc9476712446e0c548cd74090bff92b4b49e898/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d3b58ab2454b4747442ac76fab66739c72b1e2bb9bd173d7694b9f9dbc9c000", size = 730537, upload-time = "2025-11-16T20:22:52.918Z" }, + { url = "https://files.pythonhosted.org/packages/75/08/e365ee305367559f57ba6179d836ecc3d31c7d3fdff2a40ebf6c32823a1f/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bfd309b316228acecfa30670c3887dcedf9b7a44ea39e2101e75d2654522acd4", size = 746944, upload-time = "2025-11-16T16:13:45.338Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/8b56b08db91e569d0a4fbfa3e492ed2026081bdd7e892f63ba1c88a2f548/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2812ff359ec1f30129b62372e5f22a52936fac13d5d21e70373dbca5d64bb97c", size = 778249, upload-time = "2025-11-16T16:13:46.871Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1d/70dbda370bd0e1a92942754c873bd28f513da6198127d1736fa98bb2a16f/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7e74ea87307303ba91073b63e67f2c667e93f05a8c63079ee5b7a5c8d0d7b043", size = 737140, upload-time = "2025-11-16T16:13:48.349Z" }, + { url = "https://files.pythonhosted.org/packages/5b/87/822d95874216922e1120afb9d3fafa795a18fdd0c444f5c4c382f6dac761/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:713cd68af9dfbe0bb588e144a61aad8dcc00ef92a82d2e87183ca662d242f524", size = 741070, upload-time = "2025-11-16T20:22:54.151Z" }, + { url = "https://files.pythonhosted.org/packages/b9/17/4e01a602693b572149f92c983c1f25bd608df02c3f5cf50fd1f94e124a59/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:542d77b72786a35563f97069b9379ce762944e67055bea293480f7734b2c7e5e", size = 765882, upload-time = "2025-11-16T16:13:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/9f/17/7999399081d39ebb79e807314de6b611e1d1374458924eb2a489c01fc5ad/ruamel_yaml_clib-0.2.15-cp314-cp314-win32.whl", hash = "sha256:424ead8cef3939d690c4b5c85ef5b52155a231ff8b252961b6516ed7cf05f6aa", size = 102567, upload-time = "2025-11-16T16:13:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/67/be582a7370fdc9e6846c5be4888a530dcadd055eef5b932e0e85c33c7d73/ruamel_yaml_clib-0.2.15-cp314-cp314-win_amd64.whl", hash = "sha256:ac9b8d5fa4bb7fd2917ab5027f60d4234345fd366fe39aa711d5dca090aa1467", size = 122847, upload-time = "2025-11-16T16:13:51.807Z" }, +] + +[[package]] +name = "ruff" +version = "0.12.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/cd/01015eb5034605fd98d829c5839ec2c6b4582b479707f7c1c2af861e8258/ruff-0.12.5.tar.gz", hash = "sha256:b209db6102b66f13625940b7f8c7d0f18e20039bb7f6101fbdac935c9612057e", size = 5170722, upload-time = "2025-07-24T13:26:37.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/de/ad2f68f0798ff15dd8c0bcc2889558970d9a685b3249565a937cd820ad34/ruff-0.12.5-py3-none-linux_armv6l.whl", hash = "sha256:1de2c887e9dec6cb31fcb9948299de5b2db38144e66403b9660c9548a67abd92", size = 11819133, upload-time = "2025-07-24T13:25:56.369Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fc/c6b65cd0e7fbe60f17e7ad619dca796aa49fbca34bb9bea5f8faf1ec2643/ruff-0.12.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d1ab65e7d8152f519e7dea4de892317c9da7a108da1c56b6a3c1d5e7cf4c5e9a", size = 12501114, upload-time = "2025-07-24T13:25:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/c5/de/c6bec1dce5ead9f9e6a946ea15e8d698c35f19edc508289d70a577921b30/ruff-0.12.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:962775ed5b27c7aa3fdc0d8f4d4433deae7659ef99ea20f783d666e77338b8cf", size = 11716873, upload-time = "2025-07-24T13:26:01.496Z" }, + { url = "https://files.pythonhosted.org/packages/a1/16/cf372d2ebe91e4eb5b82a2275c3acfa879e0566a7ac94d331ea37b765ac8/ruff-0.12.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73b4cae449597e7195a49eb1cdca89fd9fbb16140c7579899e87f4c85bf82f73", size = 11958829, upload-time = "2025-07-24T13:26:03.721Z" }, + { url = "https://files.pythonhosted.org/packages/25/bf/cd07e8f6a3a6ec746c62556b4c4b79eeb9b0328b362bb8431b7b8afd3856/ruff-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b13489c3dc50de5e2d40110c0cce371e00186b880842e245186ca862bf9a1ac", size = 11626619, upload-time = "2025-07-24T13:26:06.118Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c9/c2ccb3b8cbb5661ffda6925f81a13edbb786e623876141b04919d1128370/ruff-0.12.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1504fea81461cf4841778b3ef0a078757602a3b3ea4b008feb1308cb3f23e08", size = 13221894, upload-time = "2025-07-24T13:26:08.292Z" }, + { url = "https://files.pythonhosted.org/packages/6b/58/68a5be2c8e5590ecdad922b2bcd5583af19ba648f7648f95c51c3c1eca81/ruff-0.12.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c7da4129016ae26c32dfcbd5b671fe652b5ab7fc40095d80dcff78175e7eddd4", size = 14163909, upload-time = "2025-07-24T13:26:10.474Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d1/ef6b19622009ba8386fdb792c0743f709cf917b0b2f1400589cbe4739a33/ruff-0.12.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca972c80f7ebcfd8af75a0f18b17c42d9f1ef203d163669150453f50ca98ab7b", size = 13583652, upload-time = "2025-07-24T13:26:13.381Z" }, + { url = "https://files.pythonhosted.org/packages/62/e3/1c98c566fe6809a0c83751d825a03727f242cdbe0d142c9e292725585521/ruff-0.12.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8dbbf9f25dfb501f4237ae7501d6364b76a01341c6f1b2cd6764fe449124bb2a", size = 12700451, upload-time = "2025-07-24T13:26:15.488Z" }, + { url = "https://files.pythonhosted.org/packages/24/ff/96058f6506aac0fbc0d0fc0d60b0d0bd746240a0594657a2d94ad28033ba/ruff-0.12.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c47dea6ae39421851685141ba9734767f960113d51e83fd7bb9958d5be8763a", size = 12937465, upload-time = "2025-07-24T13:26:17.808Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d3/68bc5e7ab96c94b3589d1789f2dd6dd4b27b263310019529ac9be1e8f31b/ruff-0.12.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c5076aa0e61e30f848846f0265c873c249d4b558105b221be1828f9f79903dc5", size = 11771136, upload-time = "2025-07-24T13:26:20.422Z" }, + { url = "https://files.pythonhosted.org/packages/52/75/7356af30a14584981cabfefcf6106dea98cec9a7af4acb5daaf4b114845f/ruff-0.12.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a5a4c7830dadd3d8c39b1cc85386e2c1e62344f20766be6f173c22fb5f72f293", size = 11601644, upload-time = "2025-07-24T13:26:22.928Z" }, + { url = "https://files.pythonhosted.org/packages/c2/67/91c71d27205871737cae11025ee2b098f512104e26ffd8656fd93d0ada0a/ruff-0.12.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:46699f73c2b5b137b9dc0fc1a190b43e35b008b398c6066ea1350cce6326adcb", size = 12478068, upload-time = "2025-07-24T13:26:26.134Z" }, + { url = "https://files.pythonhosted.org/packages/34/04/b6b00383cf2f48e8e78e14eb258942fdf2a9bf0287fbf5cdd398b749193a/ruff-0.12.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a655a0a0d396f0f072faafc18ebd59adde8ca85fb848dc1b0d9f024b9c4d3bb", size = 12991537, upload-time = "2025-07-24T13:26:28.533Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/053d6445dc7544fb6594785056d8ece61daae7214859ada4a152ad56b6e0/ruff-0.12.5-py3-none-win32.whl", hash = "sha256:dfeb2627c459b0b78ca2bbdc38dd11cc9a0a88bf91db982058b26ce41714ffa9", size = 11751575, upload-time = "2025-07-24T13:26:30.835Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0f/ab16e8259493137598b9149734fec2e06fdeda9837e6f634f5c4e35916da/ruff-0.12.5-py3-none-win_amd64.whl", hash = "sha256:ae0d90cf5f49466c954991b9d8b953bd093c32c27608e409ae3564c63c5306a5", size = 12882273, upload-time = "2025-07-24T13:26:32.929Z" }, + { url = "https://files.pythonhosted.org/packages/00/db/c376b0661c24cf770cb8815268190668ec1330eba8374a126ceef8c72d55/ruff-0.12.5-py3-none-win_arm64.whl", hash = "sha256:48cdbfc633de2c5c37d9f090ba3b352d1576b0015bfc3bc98eaf230275b7e805", size = 11951564, upload-time = "2025-07-24T13:26:34.994Z" }, +] + +[[package]] +name = "sensai-utils" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/dd/faa2e2de71a03af3def212c70777e794dd54ad5ab87927bb5c29f85f24fc/sensai_utils-1.5.0.tar.gz", hash = "sha256:2ca709a0d5807caf1632d665a455c173987b25276ce61693021672e875f0f17b", size = 61677, upload-time = "2025-07-28T22:46:10.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/d5/62a0e693230bace8e9a767d6d187a4d9421a7c6ee4b48551f8ff7bd1629a/sensai_utils-1.5.0-py3-none-any.whl", hash = "sha256:c23c51d23d353e7a9b77c70aa8ba7f2d0a8fe4e67ee5bc1ef41c69dba5e4befb", size = 68836, upload-time = "2025-07-28T22:46:08.929Z" }, +] + +[[package]] +name = "serena-agent" +version = "1.5.4.dev0" +source = { editable = "." } +dependencies = [ + { name = "anthropic" }, + { name = "beautifulsoup4" }, + { name = "cryptography" }, + { name = "docstring-parser" }, + { name = "dotenv" }, + { name = "filelock" }, + { name = "flask" }, + { name = "jinja2" }, + { name = "joblib" }, + { name = "lsprotocol" }, + { name = "mcp" }, + { name = "oslex" }, + { name = "overrides" }, + { name = "pathspec" }, + { name = "psutil" }, + { name = "pydantic" }, + { name = "pygls" }, + { name = "pystray" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "pythonnet", marker = "sys_platform == 'win32'" }, + { name = "pywebview" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "requests" }, + { name = "ruamel-yaml" }, + { name = "sensai-utils" }, + { name = "starlette" }, + { name = "tiktoken" }, + { name = "tqdm" }, + { name = "types-pyyaml" }, + { name = "urllib3" }, + { name = "werkzeug" }, +] + +[package.optional-dependencies] +agno = [ + { name = "agno" }, + { name = "sqlalchemy" }, +] +dev = [ + { name = "jinja2" }, + { name = "jupyter-book" }, + { name = "poethepoet" }, + { name = "pyasn1" }, + { name = "pyinstrument" }, + { name = "pyjwt" }, + { name = "pytest" }, + { name = "pytest-timeout" }, + { name = "pytest-xdist" }, + { name = "ruff" }, + { name = "sphinx" }, + { name = "sphinx-book-theme" }, + { name = "sphinx-rtd-theme" }, + { name = "sphinx-toolbox" }, + { name = "syrupy" }, + { name = "toml-sort" }, + { name = "tornado" }, + { name = "ty" }, + { name = "types-pyyaml" }, + { name = "types-requests" }, + { name = "wheel" }, +] +google = [ + { name = "google-genai" }, +] + +[package.metadata] +requires-dist = [ + { name = "agno", marker = "extra == 'agno'", specifier = "==2.6.6" }, + { name = "anthropic", specifier = "==0.59.0" }, + { name = "beautifulsoup4", specifier = "==4.14.2" }, + { name = "cryptography", specifier = "==48.0.1" }, + { name = "docstring-parser", specifier = "==0.17.0" }, + { name = "dotenv", specifier = "==0.9.9" }, + { name = "filelock", specifier = "==3.25.2" }, + { name = "flask", specifier = "==3.1.3" }, + { name = "google-genai", marker = "extra == 'google'", specifier = "==1.27.0" }, + { name = "jinja2", specifier = "==3.1.6" }, + { name = "jinja2", marker = "extra == 'dev'", specifier = "==3.1.6" }, + { name = "joblib", specifier = "==1.5.1" }, + { name = "jupyter-book", marker = "extra == 'dev'", specifier = "==1.0.4.post1" }, + { name = "lsprotocol", specifier = "==2025.0.0" }, + { name = "mcp", specifier = "==1.27.0" }, + { name = "oslex", specifier = "==2.0.0" }, + { name = "overrides", specifier = "==7.7.0" }, + { name = "pathspec", specifier = "==0.12.1" }, + { name = "poethepoet", marker = "extra == 'dev'", specifier = "==0.36.0" }, + { name = "psutil", specifier = "==7.0.0" }, + { name = "pyasn1", marker = "extra == 'dev'", specifier = "==0.6.3" }, + { name = "pydantic", specifier = "==2.12.5" }, + { name = "pygls", specifier = "==2.1.1" }, + { name = "pyinstrument", marker = "extra == 'dev'", specifier = "==5.1.1" }, + { name = "pyjwt", marker = "extra == 'dev'", specifier = "==2.12.0" }, + { name = "pystray", specifier = "==0.19.5" }, + { name = "pytest", marker = "extra == 'dev'", specifier = "==8.4.1" }, + { name = "pytest-timeout", marker = "extra == 'dev'", specifier = "==2.4.0" }, + { name = "pytest-xdist", marker = "extra == 'dev'", specifier = "==3.8.0" }, + { name = "python-dotenv", specifier = "==1.2.2" }, + { name = "python-multipart", specifier = "==0.0.31" }, + { name = "pythonnet", marker = "sys_platform == 'win32'", specifier = "==3.1.0" }, + { name = "pywebview", specifier = "==6.2" }, + { name = "pyyaml", specifier = "==6.0.2" }, + { name = "regex", specifier = "==2026.2.28" }, + { name = "requests", specifier = "==2.33.0" }, + { name = "ruamel-yaml", specifier = "==0.18.14" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.12.5" }, + { name = "sensai-utils", specifier = "==1.5.0" }, + { name = "sphinx", marker = "extra == 'dev'", specifier = "==7.4.7" }, + { name = "sphinx-book-theme", marker = "extra == 'dev'", specifier = "==1.1.4" }, + { name = "sphinx-rtd-theme", marker = "extra == 'dev'", specifier = "==2.0.0" }, + { name = "sphinx-toolbox", marker = "extra == 'dev'", specifier = "==3.7.0" }, + { name = "sqlalchemy", marker = "extra == 'agno'", specifier = "==2.0.41" }, + { name = "starlette", specifier = "==1.3.1" }, + { name = "syrupy", marker = "extra == 'dev'", specifier = "==4.9.1" }, + { name = "tiktoken", specifier = "==0.12.0" }, + { name = "toml-sort", marker = "extra == 'dev'", specifier = "==0.24.2" }, + { name = "tornado", marker = "extra == 'dev'", specifier = "==6.5.7" }, + { name = "tqdm", specifier = "==4.67.1" }, + { name = "ty", marker = "extra == 'dev'", specifier = "==0.0.24" }, + { name = "types-pyyaml", specifier = "==6.0.12.20250516" }, + { name = "types-pyyaml", marker = "extra == 'dev'", specifier = "==6.0.12.20250516" }, + { name = "types-requests", marker = "extra == 'dev'", specifier = "==2.32.4.20250809" }, + { name = "urllib3", specifier = "==2.7.0" }, + { name = "werkzeug", specifier = "==3.1.7" }, + { name = "wheel", marker = "extra == 'dev'", specifier = "==0.46.3" }, +] +provides-extras = ["dev", "agno", "google"] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, +] + +[[package]] +name = "sphinx" +version = "7.4.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911, upload-time = "2024-07-20T14:46:56.059Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/ef/153f6803c5d5f8917dbb7f7fcf6d34a871ede3296fa89c2c703f5f8a6c8e/sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239", size = 3401624, upload-time = "2024-07-20T14:46:52.142Z" }, +] + +[[package]] +name = "sphinx-autodoc-typehints" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/cd/03e7b917230dc057922130a79ba0240df1693bfd76727ea33fae84b39138/sphinx_autodoc_typehints-2.3.0.tar.gz", hash = "sha256:535c78ed2d6a1bad393ba9f3dfa2602cf424e2631ee207263e07874c38fde084", size = 40709, upload-time = "2024-08-29T16:25:48.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f3/e0a4ce49da4b6f4e4ce84b3c39a0677831884cb9d8a87ccbf1e9e56e53ac/sphinx_autodoc_typehints-2.3.0-py3-none-any.whl", hash = "sha256:3098e2c6d0ba99eacd013eb06861acc9b51c6e595be86ab05c08ee5506ac0c67", size = 19836, upload-time = "2024-08-29T16:25:46.707Z" }, +] + +[[package]] +name = "sphinx-book-theme" +version = "1.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydata-sphinx-theme" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/19/d002ed96bdc7738c15847c730e1e88282d738263deac705d5713b4d8fa94/sphinx_book_theme-1.1.4.tar.gz", hash = "sha256:73efe28af871d0a89bd05856d300e61edce0d5b2fbb7984e84454be0fedfe9ed", size = 439188, upload-time = "2025-02-20T16:32:32.581Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/9e/c41d68be04eef5b6202b468e0f90faf0c469f3a03353f2a218fd78279710/sphinx_book_theme-1.1.4-py3-none-any.whl", hash = "sha256:843b3f5c8684640f4a2d01abd298beb66452d1b2394cd9ef5be5ebd5640ea0e1", size = 433952, upload-time = "2025-02-20T16:32:31.009Z" }, +] + +[[package]] +name = "sphinx-comments" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/75/5bbf29e83eaf79843180cf424d0d550bda14a1792ca51dcf79daa065ba93/sphinx-comments-0.0.3.tar.gz", hash = "sha256:00170afff27019fad08e421da1ae49c681831fb2759786f07c826e89ac94cf21", size = 7960, upload-time = "2020-08-12T00:07:31.183Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/97/a5c39f619375d4f81d5422377fb027075898efa6b6202c1ccf1e5bb38a32/sphinx_comments-0.0.3-py3-none-any.whl", hash = "sha256:1e879b4e9bfa641467f83e3441ac4629225fc57c29995177d043252530c21d00", size = 4591, upload-time = "2020-08-12T00:07:30.297Z" }, +] + +[[package]] +name = "sphinx-copybutton" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" }, +] + +[[package]] +name = "sphinx-design" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/7b/804f311da4663a4aecc6cf7abd83443f3d4ded970826d0c958edc77d4527/sphinx_design-0.7.0.tar.gz", hash = "sha256:d2a3f5b19c24b916adb52f97c5f00efab4009ca337812001109084a740ec9b7a", size = 2203582, upload-time = "2026-01-19T13:12:53.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/cf/45dd359f6ca0c3762ce0490f681da242f0530c49c81050c035c016bfdd3a/sphinx_design-0.7.0-py3-none-any.whl", hash = "sha256:f82bf179951d58f55dca78ab3706aeafa496b741a91b1911d371441127d64282", size = 2220350, upload-time = "2026-01-19T13:12:51.077Z" }, +] + +[[package]] +name = "sphinx-external-toc" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "pyyaml" }, + { name = "sphinx" }, + { name = "sphinx-multitoc-numbering" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/f8/85bcd2f1c142e580a1394c18920506d9399b8e8e97e4899bbee9c74a896e/sphinx_external_toc-1.1.0.tar.gz", hash = "sha256:f81833865006f6b4a9b2550a2474a6e3d7e7f2cb23ba23309260577ea65552f6", size = 37194, upload-time = "2026-01-16T13:15:59.03Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/80/1704c9179012e289dee2178354e385277ea51f4fa827c4bf7e36c77b0f4b/sphinx_external_toc-1.1.0-py3-none-any.whl", hash = "sha256:26c390b8d85aa641366fed2d3674910ec6820f48b91027affef485a2655ad7d0", size = 30609, upload-time = "2026-01-16T13:15:57.926Z" }, +] + +[[package]] +name = "sphinx-jinja2-compat" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "standard-imghdr", marker = "python_full_version >= '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/98/43313781f29e8c6c46fec907430310172d6f207e95e4fbea9289990fbbfe/sphinx_jinja2_compat-0.4.1.tar.gz", hash = "sha256:0188f0802d42c3da72997533b55a00815659a78d3f81d4b4747b1fb15a5728e6", size = 5222, upload-time = "2025-08-06T20:06:25.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/c8/4fd58c1000d7f8f5572c507f4550d2e2d9741e500c68eb2e3da17cbe5a85/sphinx_jinja2_compat-0.4.1-py3-none-any.whl", hash = "sha256:64ca0d46f0d8029fbe69ea612793a55e6ef0113e1bba4a85d402158c09f17a14", size = 8123, upload-time = "2025-08-06T20:06:24.947Z" }, +] + +[[package]] +name = "sphinx-jupyterbook-latex" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/29/18a1fc30e9315e72f068637079169525069a7c0b2fbe51cf689af0576214/sphinx_jupyterbook_latex-1.0.0.tar.gz", hash = "sha256:f54c6674c13f1616f9a93443e98b9b5353f9fdda8e39b6ec552ccf0b3e5ffb62", size = 11945, upload-time = "2023-12-11T15:37:25.034Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/1f/1d4ecaf58b17fe61497644655f40b04d84a88348e41a6f0c6392394d95e4/sphinx_jupyterbook_latex-1.0.0-py3-none-any.whl", hash = "sha256:e0cd3e9e1c5af69136434e21a533343fdf013475c410a414d5b7b4922b4f3891", size = 13319, upload-time = "2023-12-11T15:37:23.25Z" }, +] + +[[package]] +name = "sphinx-multitoc-numbering" +version = "0.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/1e/577bae038372885ebc34bd8c0f290295785a0250cac6528eb6d50e4b92d5/sphinx-multitoc-numbering-0.1.3.tar.gz", hash = "sha256:c9607671ac511236fa5d61a7491c1031e700e8d498c9d2418e6c61d1251209ae", size = 4542, upload-time = "2021-03-15T12:01:43.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/9f/902f2030674cd9473fdbe5a2c2dec2618c27ec853484c35f82cf8df40ece/sphinx_multitoc_numbering-0.1.3-py3-none-any.whl", hash = "sha256:33d2e707a9b2b8ad636b3d4302e658a008025106fe0474046c651144c26d8514", size = 4616, upload-time = "2021-03-15T12:01:42.419Z" }, +] + +[[package]] +name = "sphinx-prompt" +version = "1.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "docutils" }, + { name = "idna" }, + { name = "jinja2" }, + { name = "pygments" }, + { name = "requests" }, + { name = "sphinx" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/a3/91293c0e0f0b76d0697ba7a41541929ca3f5457671d008bd84a9bde17e21/sphinx_prompt-1.10.2.tar.gz", hash = "sha256:47b592ba75caebd044b0eddf7a5a1b6e0aef6df587b034377cd101a999b686ba", size = 5566, upload-time = "2025-11-28T09:23:18.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/f4/44ce4d0179fb4e9cfe181a8aa281bba23e40158a609fb3680774529acaaa/sphinx_prompt-1.10.2-py3-none-any.whl", hash = "sha256:6594337962c4b1498602e6984634bed4a0dc7955852e3cfc255eb0af766ed859", size = 7474, upload-time = "2025-11-28T09:23:17.154Z" }, +] + +[[package]] +name = "sphinx-rtd-theme" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "sphinx" }, + { name = "sphinxcontrib-jquery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/33/2a35a9cdbfda9086bda11457bcc872173ab3565b16b6d7f6b3efaa6dc3d6/sphinx_rtd_theme-2.0.0.tar.gz", hash = "sha256:bd5d7b80622406762073a04ef8fadc5f9151261563d47027de09910ce03afe6b", size = 2785005, upload-time = "2023-11-28T04:14:03.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/46/00fda84467815c29951a9c91e3ae7503c409ddad04373e7cfc78daad4300/sphinx_rtd_theme-2.0.0-py2.py3-none-any.whl", hash = "sha256:ec93d0856dc280cf3aee9a4c9807c60e027c7f7b461b77aeffed682e68f0e586", size = 2824721, upload-time = "2023-11-28T04:13:59.589Z" }, +] + +[[package]] +name = "sphinx-tabs" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "pygments" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/a9a91995cb365e589f413b77fc75f1c0e9b4ac61bfa8da52a779ad855cc0/sphinx-tabs-3.4.7.tar.gz", hash = "sha256:991ad4a424ff54119799ba1491701aa8130dd43509474aef45a81c42d889784d", size = 15891, upload-time = "2024-10-08T13:37:27.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/c6/f47505b564b918a3ba60c1e99232d4942c4a7e44ecaae603e829e3d05dae/sphinx_tabs-3.4.7-py3-none-any.whl", hash = "sha256:c12d7a36fd413b369e9e9967a0a4015781b71a9c393575419834f19204bd1915", size = 9727, upload-time = "2024-10-08T13:37:26.192Z" }, +] + +[[package]] +name = "sphinx-thebe" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/fd/926ba4af1eb2708b1ac0fa4376e4bfb11d9a32b2a00e3614137a569c1ddf/sphinx_thebe-0.3.1.tar.gz", hash = "sha256:576047f45560e82f64aa5f15200b1eb094dcfe1c5b8f531a8a65bd208e25a493", size = 20789, upload-time = "2024-02-07T13:31:57.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/7c/a53bdb465fd364bc3d255d96d5d70e6ba5183cfb4e45b8aa91c59b099124/sphinx_thebe-0.3.1-py3-none-any.whl", hash = "sha256:e7e7edee9f0d601c76bc70156c471e114939484b111dd8e74fe47ac88baffc52", size = 9030, upload-time = "2024-02-07T13:31:55.286Z" }, +] + +[[package]] +name = "sphinx-togglebutton" +version = "0.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "setuptools" }, + { name = "sphinx" }, + { name = "wheel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/be/169a0b0a8ad9588e8697c85e1d489aaaca7416073c2fc0267c360af5aae9/sphinx_togglebutton-0.4.5.tar.gz", hash = "sha256:c870dfbd3bc6e119b50ff9a37a64f8991902269e856728931c7d89877e8d4b3d", size = 18101, upload-time = "2026-03-27T13:50:41.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/2e/3dd55564928c5d61f92827d4b91307dde7911a40fbe0000645d73202eea9/sphinx_togglebutton-0.4.5-py3-none-any.whl", hash = "sha256:74eac6d2426110c3e1e6f989a98e07d7823141a335df1ad8a9d637bdf6a7af62", size = 44907, upload-time = "2026-03-27T13:50:40.94Z" }, +] + +[[package]] +name = "sphinx-toolbox" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apeye" }, + { name = "autodocsumm" }, + { name = "beautifulsoup4" }, + { name = "cachecontrol", extra = ["filecache"] }, + { name = "dict2css" }, + { name = "docutils" }, + { name = "domdf-python-tools" }, + { name = "filelock" }, + { name = "html5lib" }, + { name = "ruamel-yaml" }, + { name = "sphinx" }, + { name = "sphinx-autodoc-typehints" }, + { name = "sphinx-jinja2-compat" }, + { name = "sphinx-prompt" }, + { name = "sphinx-tabs" }, + { name = "tabulate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/12/eaad3e01a1cc46fdd0b8c7c7b8a66b35967b1a8026a0e09922bf5f060d55/sphinx_toolbox-3.7.0.tar.gz", hash = "sha256:045db70e6d73241ff0b922f2e76597e2cc4904dbabef9fc6f3a46718b0e48d51", size = 111205, upload-time = "2024-07-04T13:37:52.2Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/34/04a727ad0335fd5670854926a1cad2dda2957f4261b755bdcaf2e6a4473e/sphinx_toolbox-3.7.0-py3-none-any.whl", hash = "sha256:9ea800fb6b2ecc5f382ab3547b415029fe9603b0b7eeb4c15c5da77c1eb5f21a", size = 193952, upload-time = "2024-07-04T13:37:49.371Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-bibtex" +version = "2.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "pybtex" }, + { name = "pybtex-docutils" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/83/1488c9879f2fa3c2cbd6f666c7a3a42a1fa9e08462bec73281fa6c092cba/sphinxcontrib_bibtex-2.6.5.tar.gz", hash = "sha256:9b3224dd6fece9268ebd8c905dc0a83ff2f6c54148a9235fe70e9d1e9ff149c0", size = 118462, upload-time = "2025-06-27T10:40:14.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/a0/3a612da94f828f26cabb247817393e79472c32b12c49222bf85fb6d7b6c8/sphinxcontrib_bibtex-2.6.5-py3-none-any.whl", hash = "sha256:455ea4509642ea0b28ede3721550273626f85af65af01f161bfd8e19dc1edd7d", size = 40410, upload-time = "2025-06-27T10:40:12.274Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424, upload-time = "2025-05-14T17:10:32.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/4e/b00e3ffae32b74b5180e15d2ab4040531ee1bef4c19755fe7926622dc958/sqlalchemy-2.0.41-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6375cd674fe82d7aa9816d1cb96ec592bac1726c11e0cafbf40eeee9a4516b5f", size = 2121232, upload-time = "2025-05-14T17:48:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/ef/30/6547ebb10875302074a37e1970a5dce7985240665778cfdee2323709f749/sqlalchemy-2.0.41-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f8c9fdd15a55d9465e590a402f42082705d66b05afc3ffd2d2eb3c6ba919560", size = 2110897, upload-time = "2025-05-14T17:48:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/9e/21/59df2b41b0f6c62da55cd64798232d7349a9378befa7f1bb18cf1dfd510a/sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32f9dc8c44acdee06c8fc6440db9eae8b4af8b01e4b1aee7bdd7241c22edff4f", size = 3273313, upload-time = "2025-05-14T17:51:56.205Z" }, + { url = "https://files.pythonhosted.org/packages/62/e4/b9a7a0e5c6f79d49bcd6efb6e90d7536dc604dab64582a9dec220dab54b6/sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c11ceb9a1f482c752a71f203a81858625d8df5746d787a4786bca4ffdf71c6", size = 3273807, upload-time = "2025-05-14T17:55:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/d8/79f2427251b44ddee18676c04eab038d043cff0e764d2d8bb08261d6135d/sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:911cc493ebd60de5f285bcae0491a60b4f2a9f0f5c270edd1c4dbaef7a38fc04", size = 3209632, upload-time = "2025-05-14T17:51:59.384Z" }, + { url = "https://files.pythonhosted.org/packages/d4/16/730a82dda30765f63e0454918c982fb7193f6b398b31d63c7c3bd3652ae5/sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03968a349db483936c249f4d9cd14ff2c296adfa1290b660ba6516f973139582", size = 3233642, upload-time = "2025-05-14T17:55:29.901Z" }, + { url = "https://files.pythonhosted.org/packages/04/61/c0d4607f7799efa8b8ea3c49b4621e861c8f5c41fd4b5b636c534fcb7d73/sqlalchemy-2.0.41-cp311-cp311-win32.whl", hash = "sha256:293cd444d82b18da48c9f71cd7005844dbbd06ca19be1ccf6779154439eec0b8", size = 2086475, upload-time = "2025-05-14T17:56:02.095Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8e/8344f8ae1cb6a479d0741c02cd4f666925b2bf02e2468ddaf5ce44111f30/sqlalchemy-2.0.41-cp311-cp311-win_amd64.whl", hash = "sha256:3d3549fc3e40667ec7199033a4e40a2f669898a00a7b18a931d3efb4c7900504", size = 2110903, upload-time = "2025-05-14T17:56:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2a/f1f4e068b371154740dd10fb81afb5240d5af4aa0087b88d8b308b5429c2/sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9", size = 2119645, upload-time = "2025-05-14T17:55:24.854Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/c664a7e73d36fbfc4730f8cf2bf930444ea87270f2825efbe17bf808b998/sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1", size = 2107399, upload-time = "2025-05-14T17:55:28.097Z" }, + { url = "https://files.pythonhosted.org/packages/5c/78/8a9cf6c5e7135540cb682128d091d6afa1b9e48bd049b0d691bf54114f70/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70", size = 3293269, upload-time = "2025-05-14T17:50:38.227Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/f74add3978c20de6323fb11cb5162702670cc7a9420033befb43d8d5b7a4/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e", size = 3303364, upload-time = "2025-05-14T17:51:49.829Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d4/c990f37f52c3f7748ebe98883e2a0f7d038108c2c5a82468d1ff3eec50b7/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078", size = 3229072, upload-time = "2025-05-14T17:50:39.774Z" }, + { url = "https://files.pythonhosted.org/packages/15/69/cab11fecc7eb64bc561011be2bd03d065b762d87add52a4ca0aca2e12904/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae", size = 3268074, upload-time = "2025-05-14T17:51:51.736Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/0c19ec16858585d37767b167fc9602593f98998a68a798450558239fb04a/sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6", size = 2084514, upload-time = "2025-05-14T17:55:49.915Z" }, + { url = "https://files.pythonhosted.org/packages/7f/23/4c2833d78ff3010a4e17f984c734f52b531a8c9060a50429c9d4b0211be6/sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0", size = 2111557, upload-time = "2025-05-14T17:55:51.349Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ad/2e1c6d4f235a97eeef52d0200d8ddda16f6c4dd70ae5ad88c46963440480/sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443", size = 2115491, upload-time = "2025-05-14T17:55:31.177Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8d/be490e5db8400dacc89056f78a52d44b04fbf75e8439569d5b879623a53b/sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc", size = 2102827, upload-time = "2025-05-14T17:55:34.921Z" }, + { url = "https://files.pythonhosted.org/packages/a0/72/c97ad430f0b0e78efaf2791342e13ffeafcbb3c06242f01a3bb8fe44f65d/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1", size = 3225224, upload-time = "2025-05-14T17:50:41.418Z" }, + { url = "https://files.pythonhosted.org/packages/5e/51/5ba9ea3246ea068630acf35a6ba0d181e99f1af1afd17e159eac7e8bc2b8/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a", size = 3230045, upload-time = "2025-05-14T17:51:54.722Z" }, + { url = "https://files.pythonhosted.org/packages/78/2f/8c14443b2acea700c62f9b4a8bad9e49fc1b65cfb260edead71fd38e9f19/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d", size = 3159357, upload-time = "2025-05-14T17:50:43.483Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b2/43eacbf6ccc5276d76cea18cb7c3d73e294d6fb21f9ff8b4eef9b42bbfd5/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23", size = 3197511, upload-time = "2025-05-14T17:51:57.308Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/677c17c5d6a004c3c45334ab1dbe7b7deb834430b282b8a0f75ae220c8eb/sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f", size = 2082420, upload-time = "2025-05-14T17:55:52.69Z" }, + { url = "https://files.pythonhosted.org/packages/e9/61/e8c1b9b6307c57157d328dd8b8348ddc4c47ffdf1279365a13b2b98b8049/sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df", size = 2108329, upload-time = "2025-05-14T17:55:54.495Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224, upload-time = "2025-05-14T17:39:42.154Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/2f/9223c24f568bb7a0c03d751e609844dce0968f13b39a3f73fbb3a96cd27a/sse_starlette-3.3.3.tar.gz", hash = "sha256:72a95d7575fd5129bd0ae15275ac6432bb35ac542fdebb82889c24bb9f3f4049", size = 32420, upload-time = "2026-03-17T20:05:55.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/e2/b8cff57a67dddf9a464d7e943218e031617fb3ddc133aeeb0602ff5f6c85/sse_starlette-3.3.3-py3-none-any.whl", hash = "sha256:c5abb5082a1cc1c6294d89c5290c46b5f67808cfdb612b7ec27e8ba061c22e8d", size = 14329, upload-time = "2026-03-17T20:05:54.35Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "standard-imghdr" +version = "3.10.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/d2/2eb5521072c9598886035c65c023f39f7384bcb73eed70794f469e34efac/standard_imghdr-3.10.14.tar.gz", hash = "sha256:2598fe2e7c540dbda34b233295e10957ab8dc8ac6f3bd9eaa8d38be167232e52", size = 5474, upload-time = "2024-04-21T18:55:10.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/d0/9852f70eb01f814843530c053542b72d30e9fbf74da7abb0107e71938389/standard_imghdr-3.10.14-py3-none-any.whl", hash = "sha256:cdf6883163349624dee9a81d2853a20260337c4cd41c04e99c082e01833a08e2", size = 5598, upload-time = "2024-04-21T18:54:48.587Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "syrupy" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/f8/022d8704a3314f3e96dbd6bbd16ebe119ce30e35f41aabfa92345652fceb/syrupy-4.9.1.tar.gz", hash = "sha256:b7d0fcadad80a7d2f6c4c71917918e8ebe2483e8c703dfc8d49cdbb01081f9a4", size = 52492, upload-time = "2025-03-24T01:36:37.225Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/9d/aef9ec5fd5a4ee2f6a96032c4eda5888c5c7cec65cef6b28c4fc37671d88/syrupy-4.9.1-py3-none-any.whl", hash = "sha256:b94cc12ed0e5e75b448255430af642516842a2374a46936dd2650cfb6dd20eda", size = 52214, upload-time = "2025-03-24T01:36:35.278Z" }, +] + +[[package]] +name = "tabulate" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, +] + +[[package]] +name = "tenacity" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/4d/6a19536c50b849338fcbe9290d562b52cbdcf30d8963d3588a68a4107df1/tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78", size = 47309, upload-time = "2024-07-05T07:25:31.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/3f/8ba87d9e287b9d385a02a7114ddcef61b26f86411e121c9003eb509a1773/tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687", size = 28165, upload-time = "2024-07-05T07:25:29.591Z" }, +] + +[[package]] +name = "tiktoken" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565, upload-time = "2025-10-06T20:21:44.566Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284, upload-time = "2025-10-06T20:21:45.622Z" }, + { url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201, upload-time = "2025-10-06T20:21:47.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d0/3d9275198e067f8b65076a68894bb52fd253875f3644f0a321a720277b8a/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded", size = 1152444, upload-time = "2025-10-06T20:21:48.139Z" }, + { url = "https://files.pythonhosted.org/packages/78/db/a58e09687c1698a7c592e1038e01c206569b86a0377828d51635561f8ebf/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd", size = 1195080, upload-time = "2025-10-06T20:21:49.246Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/a9e4d2bf91d515c0f74afc526fd773a812232dd6cda33ebea7f531202325/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967", size = 1255240, upload-time = "2025-10-06T20:21:50.274Z" }, + { url = "https://files.pythonhosted.org/packages/9d/15/963819345f1b1fb0809070a79e9dd96938d4ca41297367d471733e79c76c/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def", size = 879422, upload-time = "2025-10-06T20:21:51.734Z" }, + { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, + { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, + { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" }, + { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" }, + { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" }, + { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" }, + { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" }, + { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" }, + { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" }, + { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" }, + { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" }, + { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" }, + { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" }, + { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, +] + +[[package]] +name = "toml-sort" +version = "0.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/55/b128ee446606e9f2e49582bb1e462a255317401fd7adb0b2114e50006d01/toml_sort-0.24.2.tar.gz", hash = "sha256:20cb7c5e9de9c871990f1594f028aaf8bd0f78d7ce37995a22289dc157a45b79", size = 17973, upload-time = "2024-11-19T14:03:05.092Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/55/7a7c02de848eef670bd018b62122ce32a6e227203e5a626c96d4935958a6/toml_sort-0.24.2-py3-none-any.whl", hash = "sha256:d81d299789a1fd9dd306a4021951eab5fc0c5486599e277fcf8142c7735f3308", size = 20162, upload-time = "2024-11-19T14:03:03.338Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, + { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, + { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, + { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, +] + +[[package]] +name = "ty" +version = "0.0.24" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/96/652a425030f95dc2c9548d9019e52502e17079e1daeefbc4036f1c0905b4/ty-0.0.24.tar.gz", hash = "sha256:9fe42f6b98207bdaef51f71487d6d087f2cb02555ee3939884d779b2b3cc8bfc", size = 5354286, upload-time = "2026-03-19T16:55:57.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/e5/34457ee11708e734ba81ad65723af83030e484f961e281d57d1eecf08951/ty-0.0.24-py3-none-linux_armv6l.whl", hash = "sha256:1ab4f1f61334d533a3fdf5d9772b51b1300ac5da4f3cdb0be9657a3ccb2ce3e7", size = 10394877, upload-time = "2026-03-19T16:55:54.246Z" }, + { url = "https://files.pythonhosted.org/packages/44/81/bc9a1b1a87f43db15ab64ad781a4f999734ec3b470ad042624fa875b20e6/ty-0.0.24-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:facbf2c4aaa6985229e08f8f9bf152215eb078212f22b5c2411f35386688ab42", size = 10211109, upload-time = "2026-03-19T16:55:28.554Z" }, + { url = "https://files.pythonhosted.org/packages/e4/63/cfc805adeaa61d63ba3ea71127efa7d97c40ba36d97ee7bd957341d05107/ty-0.0.24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b6d2a3b6d4470c483552a31e9b368c86f154dcc964bccb5406159dc9cd362246", size = 9694769, upload-time = "2026-03-19T16:55:34.309Z" }, + { url = "https://files.pythonhosted.org/packages/33/09/edc220726b6ec44a58900401f6b27140997ef15026b791e26b69a6e69eb5/ty-0.0.24-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c94c25d0500939fd5f8f16ce41cbed5b20528702c1d649bf80300253813f0a2", size = 10176287, upload-time = "2026-03-19T16:55:37.17Z" }, + { url = "https://files.pythonhosted.org/packages/f8/bf/cbe2227be711e65017655d8ee4d050f4c92b113fb4dc4c3bd6a19d3a86d8/ty-0.0.24-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:89cbe7bc7df0fab02dbd8cda79b737df83f1ef7fb573b08c0ee043dc68cffb08", size = 10214832, upload-time = "2026-03-19T16:56:08.518Z" }, + { url = "https://files.pythonhosted.org/packages/af/1d/d15803ee47e9143d10e10bd81ccc14761d08758082bda402950685f0ddfe/ty-0.0.24-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db2c5d269bcc9b764850c99f457b5018a79b3ef40ecfbc03344e65effd6cf743", size = 10709892, upload-time = "2026-03-19T16:56:05.727Z" }, + { url = "https://files.pythonhosted.org/packages/36/12/6db0d86c477147f67b9052de209421d76c3e855197b000c25fcbbe86b3a2/ty-0.0.24-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba44512db5b97c3bbd59d93e11296e8548d0c9a3bdd1280de36d7ff22d351896", size = 11280872, upload-time = "2026-03-19T16:56:02.899Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fc/155fe83a97c06d33ccc9e0f428258b32df2e08a428300c715d34757f0111/ty-0.0.24-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a52b7f589c3205512a9c50ba5b2b1e8c0698b72e51b8b9285c90420c06f1cae8", size = 11060520, upload-time = "2026-03-19T16:55:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f1/32c05a1c4c3c2a95c5b7361dee03a9bf1231d4ad096b161c838b45bce5a0/ty-0.0.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7981df5c709c054da4ac5d7c93f8feb8f45e69e829e4461df4d5f0988fe67d04", size = 10791455, upload-time = "2026-03-19T16:55:25.728Z" }, + { url = "https://files.pythonhosted.org/packages/17/2c/53c1ea6bedfa4d4ab64d4de262d8f5e405ecbffefd364459c628c0310d33/ty-0.0.24-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2860151ad95a00d0f0280b8fef79900d08dcd63276b57e6e5774f2c055979c5", size = 10156708, upload-time = "2026-03-19T16:55:45.563Z" }, + { url = "https://files.pythonhosted.org/packages/45/39/7d2919cf194707169474d80720a5f3d793e983416f25e7ffcf80504c9df2/ty-0.0.24-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5674a1146d927ab77ff198a88e0c4505134ced342a0e7d1beb4a076a728b7496", size = 10236263, upload-time = "2026-03-19T16:55:31.474Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7f/48eac722f2fd12a5b7aae0effdcb75c46053f94b783d989e3ef0d7380082/ty-0.0.24-py3-none-musllinux_1_2_i686.whl", hash = "sha256:438ecbf1608a9b16dd84502f3f1b23ef2ef32bbd0ab3e0ca5a82f0e0d1cd41ea", size = 10402559, upload-time = "2026-03-19T16:55:39.602Z" }, + { url = "https://files.pythonhosted.org/packages/75/e0/8cf868b9749ce1e5166462759545964e95b02353243594062b927d8bff2a/ty-0.0.24-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ddeed3098dd92a83964e7aa7b41e509ba3530eb539fc4cd8322ff64a09daf1f5", size = 10893684, upload-time = "2026-03-19T16:55:51.439Z" }, + { url = "https://files.pythonhosted.org/packages/17/9f/f54bf3be01d2c2ed731d10a5afa3324dc66f987a6ae0a4a6cbfa2323d080/ty-0.0.24-py3-none-win32.whl", hash = "sha256:83013fb3a4764a8f8bcc6ca11ff8bdfd8c5f719fc249241cb2b8916e80778eb1", size = 9781542, upload-time = "2026-03-19T16:56:11.588Z" }, + { url = "https://files.pythonhosted.org/packages/fb/49/c004c5cc258b10b3a145666e9a9c28ae7678bc958c8926e8078d5d769081/ty-0.0.24-py3-none-win_amd64.whl", hash = "sha256:748a60eb6912d1cf27aaab105ffadb6f4d2e458a3fcadfbd3cf26db0d8062eeb", size = 10764801, upload-time = "2026-03-19T16:55:42.752Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/006a074e185bfccf5e4c026015245ab4fcd2362b13a8d24cf37a277909a9/ty-0.0.24-py3-none-win_arm64.whl", hash = "sha256:280a3d31e86d0721947238f17030c33f0911cae851d108ea9f4e3ab12a5ed01f", size = 10194093, upload-time = "2026-03-19T16:55:48.303Z" }, +] + +[[package]] +name = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250516" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/22/59e2aeb48ceeee1f7cd4537db9568df80d62bdb44a7f9e743502ea8aab9c/types_pyyaml-6.0.12.20250516.tar.gz", hash = "sha256:9f21a70216fc0fa1b216a8176db5f9e0af6eb35d2f2932acb87689d03a5bf6ba", size = 17378, upload-time = "2025-05-16T03:08:04.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5f/e0af6f7f6a260d9af67e1db4f54d732abad514252a7a378a6c4d17dd1036/types_pyyaml-6.0.12.20250516-py3-none-any.whl", hash = "sha256:8478208feaeb53a34cb5d970c56a7cd76b72659442e733e268a94dc72b2d0530", size = 20312, upload-time = "2025-05-16T03:08:04.019Z" }, +] + +[[package]] +name = "types-requests" +version = "2.32.4.20250809" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/b0/9355adb86ec84d057fea765e4c49cce592aaf3d5117ce5609a95a7fc3dac/types_requests-2.32.4.20250809.tar.gz", hash = "sha256:d8060de1c8ee599311f56ff58010fb4902f462a1470802cf9f6ed27bc46c4df3", size = 23027, upload-time = "2025-08-09T03:17:10.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/6f/ec0012be842b1d888d46884ac5558fd62aeae1f0ec4f7a581433d890d4b5/types_requests-2.32.4.20250809-py3-none-any.whl", hash = "sha256:f73d1832fb519ece02c85b1f09d5f0dd3108938e7d47e7f94bbfa18a6782b163", size = 20644, upload-time = "2025-08-09T03:17:09.716Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.42.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/43/76ded108b296a49f52de6bac5192ca1c4be84e886f9b5c9ba8427d9694fd/werkzeug-3.1.7.tar.gz", hash = "sha256:fb8c01fe6ab13b9b7cdb46892b99b1d66754e1d7ab8e542e865ec13f526b5351", size = 875700, upload-time = "2026-03-24T01:08:07.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/b2/0bba9bbb4596d2d2f285a16c2ab04118f6b957d8441566e1abb892e6a6b2/werkzeug-3.1.7-py3-none-any.whl", hash = "sha256:4b314d81163a3e1a169b6a0be2a000a0e204e8873c5de6586f453c55688d422f", size = 226295, upload-time = "2026-03-24T01:08:06.133Z" }, +] + +[[package]] +name = "wheel" +version = "0.46.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/24/a2eb353a6edac9a0303977c4cb048134959dd2a51b48a269dfc9dde00c8a/wheel-0.46.3.tar.gz", hash = "sha256:e3e79874b07d776c40bd6033f8ddf76a7dad46a7b8aa1b2787a83083519a1803", size = 60605, upload-time = "2026-01-22T12:39:49.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/22/b76d483683216dde3d67cba61fb2444be8d5be289bf628c13fc0fd90e5f9/wheel-0.46.3-py3-none-any.whl", hash = "sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d", size = 30557, upload-time = "2026-01-22T12:39:48.099Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]