attocode-code-intel 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- attocode_code_intel-0.1.0.dist-info/METADATA +221 -0
- attocode_code_intel-0.1.0.dist-info/RECORD +345 -0
- attocode_code_intel-0.1.0.dist-info/WHEEL +4 -0
- attocode_code_intel-0.1.0.dist-info/entry_points.txt +2 -0
- attocode_code_intel-0.1.0.dist-info/licenses/LICENSE +21 -0
- attocode_intel/GUIDELINES.md +432 -0
- attocode_intel/__init__.py +52 -0
- attocode_intel/_internal/__init__.py +1 -0
- attocode_intel/_internal/errors/__init__.py +126 -0
- attocode_intel/_internal/integrations/__init__.py +1 -0
- attocode_intel/_internal/integrations/context/__init__.py +1 -0
- attocode_intel/_internal/integrations/context/architecture_drift.py +354 -0
- attocode_intel/_internal/integrations/context/ast_chunker.py +202 -0
- attocode_intel/_internal/integrations/context/ast_service.py +1483 -0
- attocode_intel/_internal/integrations/context/blob_oid.py +423 -0
- attocode_intel/_internal/integrations/context/cache_manifest.py +221 -0
- attocode_intel/_internal/integrations/context/cas.py +466 -0
- attocode_intel/_internal/integrations/context/code_analyzer.py +528 -0
- attocode_intel/_internal/integrations/context/codebase_ast.py +1461 -0
- attocode_intel/_internal/integrations/context/codebase_context.py +1736 -0
- attocode_intel/_internal/integrations/context/cross_mode.py +263 -0
- attocode_intel/_internal/integrations/context/cross_references.py +616 -0
- attocode_intel/_internal/integrations/context/embedding_rotation.py +597 -0
- attocode_intel/_internal/integrations/context/embeddings.py +282 -0
- attocode_intel/_internal/integrations/context/frecency.py +427 -0
- attocode_intel/_internal/integrations/context/fuzzy.py +320 -0
- attocode_intel/_internal/integrations/context/hierarchical_explorer.py +331 -0
- attocode_intel/_internal/integrations/context/hydration.py +90 -0
- attocode_intel/_internal/integrations/context/index_repair.py +87 -0
- attocode_intel/_internal/integrations/context/index_store.py +586 -0
- attocode_intel/_internal/integrations/context/memory_store.py +602 -0
- attocode_intel/_internal/integrations/context/module_resolution.py +52 -0
- attocode_intel/_internal/integrations/context/query_constraints.py +361 -0
- attocode_intel/_internal/integrations/context/query_history.py +417 -0
- attocode_intel/_internal/integrations/context/reranker.py +351 -0
- attocode_intel/_internal/integrations/context/semantic_search.py +2931 -0
- attocode_intel/_internal/integrations/context/syntax_evidence.py +94 -0
- attocode_intel/_internal/integrations/context/systemone_ranker.py +245 -0
- attocode_intel/_internal/integrations/context/temporal_coupling.py +411 -0
- attocode_intel/_internal/integrations/context/trigram_index.py +820 -0
- attocode_intel/_internal/integrations/context/trigram_regex.py +171 -0
- attocode_intel/_internal/integrations/context/ts_parser.py +2199 -0
- attocode_intel/_internal/integrations/context/vector_store.py +795 -0
- attocode_intel/_internal/integrations/feature_flags/__init__.py +357 -0
- attocode_intel/_internal/integrations/lsp/__init__.py +1 -0
- attocode_intel/_internal/integrations/lsp/client.py +1140 -0
- attocode_intel/_internal/integrations/mcp/__init__.py +1 -0
- attocode_intel/_internal/integrations/mcp/client.py +253 -0
- attocode_intel/_internal/integrations/security/__init__.py +1 -0
- attocode_intel/_internal/integrations/security/custom_rules.py +176 -0
- attocode_intel/_internal/integrations/security/dataflow.py +522 -0
- attocode_intel/_internal/integrations/security/dependency_audit.py +303 -0
- attocode_intel/_internal/integrations/security/matcher.py +44 -0
- attocode_intel/_internal/integrations/security/patterns.py +724 -0
- attocode_intel/_internal/integrations/security/scanner.py +351 -0
- attocode_intel/_internal/integrations/utilities/__init__.py +1 -0
- attocode_intel/_internal/integrations/utilities/ignore.py +64 -0
- attocode_intel/_internal/integrations/utilities/token_estimate.py +51 -0
- attocode_intel/_internal/tricks/__init__.py +1 -0
- attocode_intel/_internal/tricks/recursive_context.py +357 -0
- attocode_intel/_internal/types/__init__.py +1 -0
- attocode_intel/_internal/types/events.py +289 -0
- attocode_intel/_internal/types/severity.py +15 -0
- attocode_intel/_shared.py +341 -0
- attocode_intel/analysis_coverage.py +56 -0
- attocode_intel/api/__init__.py +1 -0
- attocode_intel/api/access.py +32 -0
- attocode_intel/api/app.py +247 -0
- attocode_intel/api/auth/__init__.py +128 -0
- attocode_intel/api/auth/api_keys.py +61 -0
- attocode_intel/api/auth/context.py +34 -0
- attocode_intel/api/auth/jwt.py +66 -0
- attocode_intel/api/auth/oauth.py +146 -0
- attocode_intel/api/auth/passwords.py +15 -0
- attocode_intel/api/deps.py +351 -0
- attocode_intel/api/knowledge_bridge.py +13 -0
- attocode_intel/api/mcp_transport.py +51 -0
- attocode_intel/api/middleware.py +411 -0
- attocode_intel/api/models.py +698 -0
- attocode_intel/api/providers/__init__.py +1 -0
- attocode_intel/api/providers/db_provider.py +1106 -0
- attocode_intel/api/providers/local_provider.py +476 -0
- attocode_intel/api/providers/remote_provider.py +740 -0
- attocode_intel/api/repository_source.py +21 -0
- attocode_intel/api/routes/__init__.py +1 -0
- attocode_intel/api/routes/_pin_helper.py +158 -0
- attocode_intel/api/routes/activity.py +148 -0
- attocode_intel/api/routes/adr.py +102 -0
- attocode_intel/api/routes/analysis.py +515 -0
- attocode_intel/api/routes/api_keys.py +170 -0
- attocode_intel/api/routes/auth.py +428 -0
- attocode_intel/api/routes/branches.py +262 -0
- attocode_intel/api/routes/cross_repo_search.py +198 -0
- attocode_intel/api/routes/embeddings.py +386 -0
- attocode_intel/api/routes/files.py +132 -0
- attocode_intel/api/routes/files_v2.py +388 -0
- attocode_intel/api/routes/gc.py +322 -0
- attocode_intel/api/routes/git_v2.py +433 -0
- attocode_intel/api/routes/graph.py +174 -0
- attocode_intel/api/routes/graph_viz.py +160 -0
- attocode_intel/api/routes/health.py +62 -0
- attocode_intel/api/routes/history.py +175 -0
- attocode_intel/api/routes/jobs.py +84 -0
- attocode_intel/api/routes/learning.py +205 -0
- attocode_intel/api/routes/lsp.py +272 -0
- attocode_intel/api/routes/notify.py +623 -0
- attocode_intel/api/routes/operations.py +27 -0
- attocode_intel/api/routes/orgs.py +924 -0
- attocode_intel/api/routes/preferences.py +100 -0
- attocode_intel/api/routes/presence.py +42 -0
- attocode_intel/api/routes/projects.py +116 -0
- attocode_intel/api/routes/repos.py +47 -0
- attocode_intel/api/routes/search.py +180 -0
- attocode_intel/api/routes/snapshots.py +614 -0
- attocode_intel/api/routes/webhooks.py +313 -0
- attocode_intel/api/routes/websocket.py +116 -0
- attocode_intel/api/static/graph.html +239 -0
- attocode_intel/api/utils.py +96 -0
- attocode_intel/artifacts/__init__.py +45 -0
- attocode_intel/artifacts/hashing.py +230 -0
- attocode_intel/artifacts/provenance.py +190 -0
- attocode_intel/artifacts/retrieval_pin.py +159 -0
- attocode_intel/audit.py +59 -0
- attocode_intel/bug_finder.py +206 -0
- attocode_intel/bundle.py +80 -0
- attocode_intel/catalog.py +242 -0
- attocode_intel/change_review.py +75 -0
- attocode_intel/cli.py +3062 -0
- attocode_intel/community.py +63 -0
- attocode_intel/confidence/__init__.py +144 -0
- attocode_intel/confidence/jev.py +234 -0
- attocode_intel/confidence/llm.py +330 -0
- attocode_intel/confidence/redact.py +44 -0
- attocode_intel/confidence/settings.py +74 -0
- attocode_intel/config.py +256 -0
- attocode_intel/crypto.py +31 -0
- attocode_intel/db/__init__.py +7 -0
- attocode_intel/db/base.py +33 -0
- attocode_intel/db/engine.py +61 -0
- attocode_intel/db/models.py +529 -0
- attocode_intel/entrypoint.py +185 -0
- attocode_intel/focused_evidence.py +130 -0
- attocode_intel/freshness.py +154 -0
- attocode_intel/gateway.py +734 -0
- attocode_intel/git/__init__.py +7 -0
- attocode_intel/git/credentials.py +75 -0
- attocode_intel/git/manager.py +493 -0
- attocode_intel/git/models.py +96 -0
- attocode_intel/git/storage.py +98 -0
- attocode_intel/graph_query_parser.py +761 -0
- attocode_intel/helpers.py +712 -0
- attocode_intel/indexing/__init__.py +29 -0
- attocode_intel/indexing/debouncer.py +136 -0
- attocode_intel/indexing/delta_indexer.py +151 -0
- attocode_intel/indexing/full_indexer.py +323 -0
- attocode_intel/indexing/incremental.py +240 -0
- attocode_intel/indexing/parser.py +403 -0
- attocode_intel/indexing/summarizer.py +264 -0
- attocode_intel/installer.py +1896 -0
- attocode_intel/knowledge.py +269 -0
- attocode_intel/local_knowledge.py +77 -0
- attocode_intel/migrations/alembic.ini +36 -0
- attocode_intel/migrations/env.py +71 -0
- attocode_intel/migrations/script.py.mako +27 -0
- attocode_intel/migrations/versions/001_initial_schema.py +219 -0
- attocode_intel/migrations/versions/002_branch_version_and_embeddings.py +61 -0
- attocode_intel/migrations/versions/003_embedding_uniqueness_and_cascade.py +51 -0
- attocode_intel/migrations/versions/004_audit_events.py +38 -0
- attocode_intel/migrations/versions/005_user_preferences.py +30 -0
- attocode_intel/migrations/versions/006_google_oauth.py +27 -0
- attocode_intel/migrations/versions/007_pgvector_embeddings.py +28 -0
- attocode_intel/migrations/versions/008_symbol_references.py +37 -0
- attocode_intel/migrations/versions/009_webhook_secret_encryption.py +25 -0
- attocode_intel/migrations/versions/010_add_commits_table.py +40 -0
- attocode_intel/migrations/versions/011_commit_changed_files.py +25 -0
- attocode_intel/migrations/versions/012_branch_merged_at.py +25 -0
- attocode_intel/migrations/versions/013_revoked_tokens.py +32 -0
- attocode_intel/migrations/versions/014_blame_hunks.py +40 -0
- attocode_intel/migrations/versions/015_commit_file_stats.py +40 -0
- attocode_intel/migrations/versions/016_embedding_provenance_columns.py +179 -0
- attocode_intel/migrations/versions/017_provenance_table.py +102 -0
- attocode_intel/migrations/versions/018_repo_snapshots.py +178 -0
- attocode_intel/migrations/versions/019_symref_caller.py +42 -0
- attocode_intel/migrations/versions/020_shared_knowledge.py +29 -0
- attocode_intel/onboarding.py +258 -0
- attocode_intel/output.py +178 -0
- attocode_intel/precision.py +133 -0
- attocode_intel/presence.py +160 -0
- attocode_intel/probe.py +110 -0
- attocode_intel/project_dir.py +97 -0
- attocode_intel/pubsub.py +113 -0
- attocode_intel/query_ranking.py +242 -0
- attocode_intel/readiness.py +972 -0
- attocode_intel/redis.py +51 -0
- attocode_intel/reference_pages.py +60 -0
- attocode_intel/remote.py +131 -0
- attocode_intel/repo_ranker.py +246 -0
- attocode_intel/request_context.py +82 -0
- attocode_intel/rules/__init__.py +28 -0
- attocode_intel/rules/ci.py +320 -0
- attocode_intel/rules/combinators.py +293 -0
- attocode_intel/rules/enricher.py +127 -0
- attocode_intel/rules/evolution.py +452 -0
- attocode_intel/rules/executor.py +531 -0
- attocode_intel/rules/filters/__init__.py +5 -0
- attocode_intel/rules/filters/pipeline.py +151 -0
- attocode_intel/rules/formatter.py +198 -0
- attocode_intel/rules/hygiene.py +242 -0
- attocode_intel/rules/importers/__init__.py +1 -0
- attocode_intel/rules/importers/semgrep.py +382 -0
- attocode_intel/rules/loader.py +292 -0
- attocode_intel/rules/marketplace.py +375 -0
- attocode_intel/rules/metavar.py +181 -0
- attocode_intel/rules/model.py +317 -0
- attocode_intel/rules/packs/__init__.py +1 -0
- attocode_intel/rules/packs/community/_license_templates/Apache-2.0.txt +176 -0
- attocode_intel/rules/packs/community/_license_templates/MIT.txt +21 -0
- attocode_intel/rules/packs/community/bandit-python/LICENSE +176 -0
- attocode_intel/rules/packs/community/bandit-python/NOTICE +13 -0
- attocode_intel/rules/packs/community/bandit-python/PORTING.md +23 -0
- attocode_intel/rules/packs/community/bandit-python/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B105-hardcoded-password.yaml +23 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B303-weak-md5.yaml +21 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B304-weak-des.yaml +20 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B307-use-of-eval.yaml +22 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B324-weak-sha1.yaml +21 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B501-no-cert-validation.yaml +23 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B602-shell-true.yaml +22 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B608-sqli.yaml +22 -0
- attocode_intel/rules/packs/community/eslint-typescript/LICENSE +21 -0
- attocode_intel/rules/packs/community/eslint-typescript/NOTICE +13 -0
- attocode_intel/rules/packs/community/eslint-typescript/PORTING.md +23 -0
- attocode_intel/rules/packs/community/eslint-typescript/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/eqeqeq.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-eval.yaml +22 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-implied-eval.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-new-func.yaml +21 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-throw-literal.yaml +19 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-var.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-with.yaml +18 -0
- attocode_intel/rules/packs/community/gosec-go/LICENSE +176 -0
- attocode_intel/rules/packs/community/gosec-go/NOTICE +13 -0
- attocode_intel/rules/packs/community/gosec-go/PORTING.md +23 -0
- attocode_intel/rules/packs/community/gosec-go/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G101-hardcoded-credentials.yaml +22 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G102-bind-all-interfaces.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G103-unsafe-block.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G201-sql-format.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G304-file-inclusion.yaml +23 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G401-weak-cipher.yaml +20 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G402-tls-min-version.yaml +20 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G501-md5-import.yaml +21 -0
- attocode_intel/rules/packs/examples/cpp/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/cpp/rules/security.yaml +90 -0
- attocode_intel/rules/packs/examples/cpp/rules/structural.yaml +42 -0
- attocode_intel/rules/packs/examples/csharp/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/csharp/rules/security.yaml +69 -0
- attocode_intel/rules/packs/examples/csharp/rules/structural.yaml +42 -0
- attocode_intel/rules/packs/examples/go/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/go/rules/performance.yaml +136 -0
- attocode_intel/rules/packs/examples/go/rules/security.yaml +48 -0
- attocode_intel/rules/packs/examples/go/rules/structural.yaml +58 -0
- attocode_intel/rules/packs/examples/go/taint/sanitizers.yaml +24 -0
- attocode_intel/rules/packs/examples/go/taint/sinks.yaml +51 -0
- attocode_intel/rules/packs/examples/go/taint/sources.yaml +26 -0
- attocode_intel/rules/packs/examples/java/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/java/rules/correctness.yaml +55 -0
- attocode_intel/rules/packs/examples/java/rules/structural.yaml +48 -0
- attocode_intel/rules/packs/examples/java/taint/sinks.yaml +37 -0
- attocode_intel/rules/packs/examples/java/taint/sources.yaml +21 -0
- attocode_intel/rules/packs/examples/kotlin/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/kotlin/rules/correctness.yaml +50 -0
- attocode_intel/rules/packs/examples/kotlin/rules/structural.yaml +38 -0
- attocode_intel/rules/packs/examples/php/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/php/rules/security.yaml +66 -0
- attocode_intel/rules/packs/examples/php/rules/structural.yaml +41 -0
- attocode_intel/rules/packs/examples/python/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/python/rules/performance.yaml +90 -0
- attocode_intel/rules/packs/examples/python/rules/structural.yaml +55 -0
- attocode_intel/rules/packs/examples/ruby/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/ruby/rules/security.yaml +73 -0
- attocode_intel/rules/packs/examples/ruby/rules/structural.yaml +41 -0
- attocode_intel/rules/packs/examples/rust/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/rust/rules/correctness.yaml +51 -0
- attocode_intel/rules/packs/examples/rust/rules/structural.yaml +49 -0
- attocode_intel/rules/packs/examples/typescript/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/typescript/rules/performance.yaml +70 -0
- attocode_intel/rules/packs/examples/typescript/rules/structural.yaml +48 -0
- attocode_intel/rules/packs/pack_loader.py +244 -0
- attocode_intel/rules/plugins/__init__.py +1 -0
- attocode_intel/rules/plugins/plugin_loader.py +132 -0
- attocode_intel/rules/profiling.py +332 -0
- attocode_intel/rules/registry.py +213 -0
- attocode_intel/rules/sarif.py +187 -0
- attocode_intel/rules/synthesis.py +420 -0
- attocode_intel/rules/testing.py +326 -0
- attocode_intel/server.py +734 -0
- attocode_intel/service.py +3416 -0
- attocode_intel/sharing.py +57 -0
- attocode_intel/storage/__init__.py +6 -0
- attocode_intel/storage/branch_overlay.py +352 -0
- attocode_intel/storage/content_store.py +220 -0
- attocode_intel/storage/dependency_store.py +143 -0
- attocode_intel/storage/diff_engine.py +187 -0
- attocode_intel/storage/embedding_store.py +594 -0
- attocode_intel/storage/provenance_store.py +137 -0
- attocode_intel/storage/security_scanner_db.py +164 -0
- attocode_intel/storage/symbol_store.py +160 -0
- attocode_intel/structural_evidence.py +144 -0
- attocode_intel/symbol_inspection.py +141 -0
- attocode_intel/symbol_links.py +167 -0
- attocode_intel/telemetry.py +42 -0
- attocode_intel/test_ranking.py +73 -0
- attocode_intel/testing/__init__.py +43 -0
- attocode_intel/testing/fixtures.py +122 -0
- attocode_intel/testing/helpers.py +75 -0
- attocode_intel/testing/mocks.py +99 -0
- attocode_intel/tools/__init__.py +2 -0
- attocode_intel/tools/adr_tools.py +628 -0
- attocode_intel/tools/analysis_tools.py +382 -0
- attocode_intel/tools/composite_tools.py +695 -0
- attocode_intel/tools/cross_mode_tools.py +173 -0
- attocode_intel/tools/dead_code_tools.py +582 -0
- attocode_intel/tools/distill_tools.py +353 -0
- attocode_intel/tools/frecency_tools.py +220 -0
- attocode_intel/tools/fuzzy_tools.py +255 -0
- attocode_intel/tools/history_tools.py +486 -0
- attocode_intel/tools/learning_tools.py +222 -0
- attocode_intel/tools/lsp_tools.py +218 -0
- attocode_intel/tools/maintenance_tools.py +1662 -0
- attocode_intel/tools/navigation_tools.py +220 -0
- attocode_intel/tools/overlay_tools.py +540 -0
- attocode_intel/tools/pin_store.py +432 -0
- attocode_intel/tools/pin_tools.py +266 -0
- attocode_intel/tools/query_constraints_tools.py +262 -0
- attocode_intel/tools/query_history_tools.py +158 -0
- attocode_intel/tools/readiness_tools.py +44 -0
- attocode_intel/tools/rule_tools.py +992 -0
- attocode_intel/tools/search_tools.py +770 -0
- attocode_intel/tools/snapshot_tools.py +624 -0
- attocode_intel/workers/__init__.py +1 -0
- attocode_intel/workers/job_utils.py +111 -0
- attocode_intel/workers/jobs.py +573 -0
- attocode_intel/workers/run.py +30 -0
- attocode_intel/workers/settings.py +61 -0
|
@@ -0,0 +1,1140 @@
|
|
|
1
|
+
"""Language Server Protocol client manager.
|
|
2
|
+
|
|
3
|
+
Manages LSP clients for multiple programming languages,
|
|
4
|
+
providing definitions, completions, hover info, references,
|
|
5
|
+
and diagnostics via the LSP JSON-RPC protocol.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from attocode_intel._internal.errors import ConfigurationError, ToolError
|
|
22
|
+
from attocode_intel._internal.types.events import SimpleEventListener
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# =============================================================================
|
|
27
|
+
# Types
|
|
28
|
+
# =============================================================================
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class LSPPosition:
|
|
33
|
+
"""Position in a document (0-indexed)."""
|
|
34
|
+
|
|
35
|
+
line: int
|
|
36
|
+
character: int
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class LSPRange:
|
|
41
|
+
"""Range in a document."""
|
|
42
|
+
|
|
43
|
+
start: LSPPosition
|
|
44
|
+
end: LSPPosition
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class LSPLocation:
|
|
49
|
+
"""Location in a document."""
|
|
50
|
+
|
|
51
|
+
uri: str
|
|
52
|
+
range: LSPRange
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class LSPDiagnostic:
|
|
57
|
+
"""Diagnostic message from a language server."""
|
|
58
|
+
|
|
59
|
+
range: LSPRange
|
|
60
|
+
message: str
|
|
61
|
+
severity: str = "error" # error, warning, information, hint
|
|
62
|
+
source: str | None = None
|
|
63
|
+
code: str | int | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class LSPCompletion:
|
|
68
|
+
"""Completion item from a language server."""
|
|
69
|
+
|
|
70
|
+
label: str
|
|
71
|
+
kind: str = "text"
|
|
72
|
+
detail: str | None = None
|
|
73
|
+
documentation: str | None = None
|
|
74
|
+
insert_text: str | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class WSSymbol:
|
|
79
|
+
"""A symbol found via workspace/symbol."""
|
|
80
|
+
|
|
81
|
+
name: str
|
|
82
|
+
kind: str # "class", "function", "method", "variable", etc.
|
|
83
|
+
uri: str
|
|
84
|
+
range: LSPRange
|
|
85
|
+
container_name: str | None = None
|
|
86
|
+
detail: str | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class LanguageServerConfig:
|
|
91
|
+
"""Configuration for a language server."""
|
|
92
|
+
|
|
93
|
+
command: str
|
|
94
|
+
args: list[str] = field(default_factory=list)
|
|
95
|
+
extensions: list[str] = field(default_factory=list)
|
|
96
|
+
language_id: str = ""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class LSPConfig:
|
|
101
|
+
"""LSP manager configuration."""
|
|
102
|
+
|
|
103
|
+
enabled: bool = True
|
|
104
|
+
servers: dict[str, LanguageServerConfig] = field(default_factory=dict)
|
|
105
|
+
auto_detect: bool = True
|
|
106
|
+
timeout: float = 30.0
|
|
107
|
+
root_uri: str = ""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
LSPEventListener = SimpleEventListener
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# =============================================================================
|
|
114
|
+
# Built-in Server Configs
|
|
115
|
+
# =============================================================================
|
|
116
|
+
|
|
117
|
+
BUILTIN_SERVERS: dict[str, LanguageServerConfig] = {
|
|
118
|
+
"typescript": LanguageServerConfig(
|
|
119
|
+
command="typescript-language-server",
|
|
120
|
+
args=["--stdio"],
|
|
121
|
+
extensions=[".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"],
|
|
122
|
+
language_id="typescript",
|
|
123
|
+
),
|
|
124
|
+
"python": LanguageServerConfig(
|
|
125
|
+
command="pyright-langserver",
|
|
126
|
+
args=["--stdio"],
|
|
127
|
+
extensions=[".py", ".pyi"],
|
|
128
|
+
language_id="python",
|
|
129
|
+
),
|
|
130
|
+
"rust": LanguageServerConfig(
|
|
131
|
+
command="rust-analyzer",
|
|
132
|
+
args=[],
|
|
133
|
+
extensions=[".rs"],
|
|
134
|
+
language_id="rust",
|
|
135
|
+
),
|
|
136
|
+
"go": LanguageServerConfig(
|
|
137
|
+
command="gopls",
|
|
138
|
+
args=[],
|
|
139
|
+
extensions=[".go"],
|
|
140
|
+
language_id="go",
|
|
141
|
+
),
|
|
142
|
+
"json": LanguageServerConfig(
|
|
143
|
+
command="vscode-json-language-server",
|
|
144
|
+
args=["--stdio"],
|
|
145
|
+
extensions=[".json", ".jsonc"],
|
|
146
|
+
language_id="json",
|
|
147
|
+
),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
COMPLETION_KIND_MAP: dict[int, str] = {
|
|
151
|
+
1: "text", 2: "method", 3: "function", 4: "constructor",
|
|
152
|
+
5: "field", 6: "variable", 7: "class", 8: "interface",
|
|
153
|
+
9: "module", 10: "property", 14: "keyword", 15: "snippet",
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# =============================================================================
|
|
158
|
+
# LSP Client (Internal)
|
|
159
|
+
# =============================================================================
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class _LSPClient:
|
|
163
|
+
"""Internal LSP client for a single language server.
|
|
164
|
+
|
|
165
|
+
Communicates via JSON-RPC 2.0 over stdio using the
|
|
166
|
+
Content-Length header framing protocol.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
config: LanguageServerConfig,
|
|
172
|
+
root_uri: str,
|
|
173
|
+
timeout: float = 30.0,
|
|
174
|
+
on_diagnostics: Callable[[str, list[LSPDiagnostic]], None] | None = None,
|
|
175
|
+
) -> None:
|
|
176
|
+
self._config = config
|
|
177
|
+
self._root_uri = root_uri
|
|
178
|
+
self._timeout = timeout
|
|
179
|
+
self._on_diagnostics = on_diagnostics
|
|
180
|
+
|
|
181
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
182
|
+
self._request_id = 0
|
|
183
|
+
self._pending: dict[int, asyncio.Future[Any]] = {}
|
|
184
|
+
self._buffer = b""
|
|
185
|
+
self._notifications: set[asyncio.Task] = set()
|
|
186
|
+
self._initialized = False
|
|
187
|
+
self._reader_task: asyncio.Task[None] | None = None
|
|
188
|
+
self._stderr_task: asyncio.Task[None] | None = None
|
|
189
|
+
self._is_stopping = False # Suppress spurious errors during shutdown
|
|
190
|
+
self._trace_enabled = False # Protocol tracing (set via feature flag)
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def language_id(self) -> str:
|
|
194
|
+
return self._config.language_id
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def is_initialized(self) -> bool:
|
|
198
|
+
return self._initialized
|
|
199
|
+
|
|
200
|
+
def enable_protocol_trace(self, enabled: bool = True) -> None:
|
|
201
|
+
"""Enable verbose LSP protocol tracing (logs sent/received messages)."""
|
|
202
|
+
self._trace_enabled = enabled
|
|
203
|
+
|
|
204
|
+
async def start(self) -> None:
|
|
205
|
+
"""Start the language server and initialize it."""
|
|
206
|
+
self._process = await asyncio.create_subprocess_exec( # noqa: S603
|
|
207
|
+
self._config.command,
|
|
208
|
+
*self._config.args,
|
|
209
|
+
stdin=asyncio.subprocess.PIPE,
|
|
210
|
+
stdout=asyncio.subprocess.PIPE,
|
|
211
|
+
stderr=asyncio.subprocess.PIPE,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if not self._process.stdout or not self._process.stdin:
|
|
215
|
+
raise ToolError(f"Failed to spawn {self._config.command}", tool_name="lsp")
|
|
216
|
+
|
|
217
|
+
# Start reading responses in background
|
|
218
|
+
self._reader_task = asyncio.create_task(self._read_loop())
|
|
219
|
+
self._stderr_task = asyncio.create_task(self._drain_stderr())
|
|
220
|
+
|
|
221
|
+
# Initialize
|
|
222
|
+
await self._request("initialize", {
|
|
223
|
+
"processId": os.getpid(),
|
|
224
|
+
"rootUri": self._root_uri,
|
|
225
|
+
"capabilities": {
|
|
226
|
+
"textDocument": {
|
|
227
|
+
"completion": {"completionItem": {"snippetSupport": True}},
|
|
228
|
+
"hover": {},
|
|
229
|
+
"definition": {},
|
|
230
|
+
"references": {},
|
|
231
|
+
"publishDiagnostics": {},
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
self._notify("initialized", {})
|
|
236
|
+
self._initialized = True
|
|
237
|
+
|
|
238
|
+
async def stop(self) -> None:
|
|
239
|
+
"""Full LSP server shutdown lifecycle.
|
|
240
|
+
|
|
241
|
+
Sequence (CC pattern):
|
|
242
|
+
1. Send shutdown request (clean stop request to server)
|
|
243
|
+
2. Send exit notification (server should terminate gracefully)
|
|
244
|
+
3. Close stdin
|
|
245
|
+
4. Wait for process to exit (with timeout)
|
|
246
|
+
5. Kill if still alive
|
|
247
|
+
6. Cancel pending requests
|
|
248
|
+
|
|
249
|
+
The _is_stopping flag prevents spurious errors from pending requests
|
|
250
|
+
during the shutdown sequence.
|
|
251
|
+
"""
|
|
252
|
+
if not self._process or self._is_stopping:
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
self._is_stopping = True
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
# Step 1: Send shutdown request
|
|
259
|
+
try:
|
|
260
|
+
await self._request("shutdown", None)
|
|
261
|
+
logger.debug("LSP %s: shutdown request sent", self._config.language_id)
|
|
262
|
+
except TimeoutError:
|
|
263
|
+
logger.warning("LSP %s: shutdown request timed out", self._config.language_id)
|
|
264
|
+
except Exception as exc:
|
|
265
|
+
logger.debug(
|
|
266
|
+
"LSP %s: shutdown request error (non-fatal): %s",
|
|
267
|
+
self._config.language_id, exc,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Step 2: Send exit notification
|
|
271
|
+
try:
|
|
272
|
+
self._notify("exit", None)
|
|
273
|
+
logger.debug("LSP %s: exit notification sent", self._config.language_id)
|
|
274
|
+
except Exception as exc:
|
|
275
|
+
logger.debug(
|
|
276
|
+
"LSP %s: exit notification error (non-fatal): %s",
|
|
277
|
+
self._config.language_id, exc,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Brief pause to let server clean up
|
|
281
|
+
await asyncio.sleep(0.1)
|
|
282
|
+
|
|
283
|
+
# Step 3: Close stdin
|
|
284
|
+
try:
|
|
285
|
+
if self._process.stdin:
|
|
286
|
+
self._process.stdin.close()
|
|
287
|
+
except Exception:
|
|
288
|
+
pass
|
|
289
|
+
|
|
290
|
+
# Step 4: Wait for graceful exit
|
|
291
|
+
try:
|
|
292
|
+
retcode = await asyncio.wait_for(
|
|
293
|
+
self._process.wait(),
|
|
294
|
+
timeout=self._timeout,
|
|
295
|
+
)
|
|
296
|
+
logger.debug(
|
|
297
|
+
"LSP %s: exited gracefully with code %s",
|
|
298
|
+
self._config.language_id, retcode,
|
|
299
|
+
)
|
|
300
|
+
# Reader loop should drain EOF naturally, but cancel if still alive
|
|
301
|
+
try:
|
|
302
|
+
await asyncio.wait_for(self._reader_task, timeout=1.0)
|
|
303
|
+
except TimeoutError:
|
|
304
|
+
if self._reader_task and not self._reader_task.done():
|
|
305
|
+
self._reader_task.cancel()
|
|
306
|
+
try:
|
|
307
|
+
await self._reader_task
|
|
308
|
+
except asyncio.CancelledError:
|
|
309
|
+
pass
|
|
310
|
+
except TimeoutError:
|
|
311
|
+
logger.warning("LSP %s: did not exit gracefully, killing", self._config.language_id)
|
|
312
|
+
|
|
313
|
+
# Step 5: Kill if still alive, then cancel the reader
|
|
314
|
+
killed = False
|
|
315
|
+
try:
|
|
316
|
+
self._process.kill()
|
|
317
|
+
killed = True
|
|
318
|
+
except Exception as exc:
|
|
319
|
+
logger.warning("LSP %s: kill failed: %s", self._config.language_id, exc)
|
|
320
|
+
|
|
321
|
+
if killed:
|
|
322
|
+
# Give reader loop a moment to notice EOF, then cancel it
|
|
323
|
+
try:
|
|
324
|
+
await asyncio.wait_for(self._reader_task, timeout=1.0)
|
|
325
|
+
except TimeoutError:
|
|
326
|
+
if self._reader_task and not self._reader_task.done():
|
|
327
|
+
self._reader_task.cancel()
|
|
328
|
+
try:
|
|
329
|
+
await asyncio.wait_for(self._reader_task, timeout=2.0)
|
|
330
|
+
except (TimeoutError, asyncio.CancelledError):
|
|
331
|
+
pass
|
|
332
|
+
except asyncio.CancelledError:
|
|
333
|
+
pass
|
|
334
|
+
try:
|
|
335
|
+
await asyncio.wait_for(self._process.wait(), timeout=1.0)
|
|
336
|
+
logger.info("LSP %s: killed", self._config.language_id)
|
|
337
|
+
except TimeoutError:
|
|
338
|
+
logger.warning("LSP %s: process still alive after kill + cancel", self._config.language_id)
|
|
339
|
+
|
|
340
|
+
finally:
|
|
341
|
+
if self._stderr_task is not None:
|
|
342
|
+
self._stderr_task.cancel()
|
|
343
|
+
await asyncio.gather(self._stderr_task, return_exceptions=True)
|
|
344
|
+
self._stderr_task = None
|
|
345
|
+
self._process = None
|
|
346
|
+
self._initialized = False
|
|
347
|
+
self._reader_task = None
|
|
348
|
+
self._is_stopping = False
|
|
349
|
+
|
|
350
|
+
# Step 6: Cancel pending requests
|
|
351
|
+
for future in self._pending.values():
|
|
352
|
+
if not future.done():
|
|
353
|
+
future.cancel()
|
|
354
|
+
self._pending.clear()
|
|
355
|
+
|
|
356
|
+
async def get_definition(
|
|
357
|
+
self, uri: str, line: int, character: int
|
|
358
|
+
) -> LSPLocation | None:
|
|
359
|
+
"""Get symbol definition location."""
|
|
360
|
+
if not self._initialized:
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
result = await self._request("textDocument/definition", {
|
|
364
|
+
"textDocument": {"uri": uri},
|
|
365
|
+
"position": {"line": line, "character": character},
|
|
366
|
+
})
|
|
367
|
+
if not result:
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
loc = result[0] if isinstance(result, list) else result
|
|
371
|
+
if not loc or not isinstance(loc, dict):
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
return LSPLocation(
|
|
375
|
+
uri=loc["uri"],
|
|
376
|
+
range=_parse_range(loc["range"]),
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
async def get_completions(
|
|
380
|
+
self, uri: str, line: int, character: int
|
|
381
|
+
) -> list[LSPCompletion]:
|
|
382
|
+
"""Get completion items at position."""
|
|
383
|
+
if not self._initialized:
|
|
384
|
+
return []
|
|
385
|
+
|
|
386
|
+
result = await self._request("textDocument/completion", {
|
|
387
|
+
"textDocument": {"uri": uri},
|
|
388
|
+
"position": {"line": line, "character": character},
|
|
389
|
+
})
|
|
390
|
+
if not result:
|
|
391
|
+
return []
|
|
392
|
+
|
|
393
|
+
items: list[Any]
|
|
394
|
+
if isinstance(result, list):
|
|
395
|
+
items = result
|
|
396
|
+
elif isinstance(result, dict):
|
|
397
|
+
items = result.get("items", [])
|
|
398
|
+
else:
|
|
399
|
+
items = []
|
|
400
|
+
|
|
401
|
+
completions: list[LSPCompletion] = []
|
|
402
|
+
for item in items:
|
|
403
|
+
if not isinstance(item, dict):
|
|
404
|
+
continue
|
|
405
|
+
doc = item.get("documentation")
|
|
406
|
+
if isinstance(doc, dict):
|
|
407
|
+
doc = doc.get("value", "")
|
|
408
|
+
completions.append(LSPCompletion(
|
|
409
|
+
label=str(item.get("label", "")),
|
|
410
|
+
kind=COMPLETION_KIND_MAP.get(item.get("kind", 1), "text"),
|
|
411
|
+
detail=item.get("detail"),
|
|
412
|
+
documentation=doc if isinstance(doc, str) else None,
|
|
413
|
+
insert_text=item.get("insertText") or item.get("label"),
|
|
414
|
+
))
|
|
415
|
+
return completions
|
|
416
|
+
|
|
417
|
+
async def get_hover(
|
|
418
|
+
self, uri: str, line: int, character: int
|
|
419
|
+
) -> str | None:
|
|
420
|
+
"""Get hover information at position."""
|
|
421
|
+
if not self._initialized:
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
result = await self._request("textDocument/hover", {
|
|
425
|
+
"textDocument": {"uri": uri},
|
|
426
|
+
"position": {"line": line, "character": character},
|
|
427
|
+
})
|
|
428
|
+
if not result or not isinstance(result, dict):
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
contents = result.get("contents")
|
|
432
|
+
if not contents:
|
|
433
|
+
return None
|
|
434
|
+
if isinstance(contents, str):
|
|
435
|
+
return contents
|
|
436
|
+
if isinstance(contents, list):
|
|
437
|
+
parts = []
|
|
438
|
+
for c in contents:
|
|
439
|
+
if isinstance(c, str):
|
|
440
|
+
parts.append(c)
|
|
441
|
+
elif isinstance(c, dict):
|
|
442
|
+
parts.append(c.get("value", ""))
|
|
443
|
+
return "\n".join(parts)
|
|
444
|
+
if isinstance(contents, dict):
|
|
445
|
+
return contents.get("value")
|
|
446
|
+
return None
|
|
447
|
+
|
|
448
|
+
async def get_references(
|
|
449
|
+
self,
|
|
450
|
+
uri: str,
|
|
451
|
+
line: int,
|
|
452
|
+
character: int,
|
|
453
|
+
include_declaration: bool = True,
|
|
454
|
+
) -> list[LSPLocation]:
|
|
455
|
+
"""Get all references to a symbol."""
|
|
456
|
+
if not self._initialized:
|
|
457
|
+
return []
|
|
458
|
+
|
|
459
|
+
result = await self._request("textDocument/references", {
|
|
460
|
+
"textDocument": {"uri": uri},
|
|
461
|
+
"position": {"line": line, "character": character},
|
|
462
|
+
"context": {"includeDeclaration": include_declaration},
|
|
463
|
+
})
|
|
464
|
+
if not result or not isinstance(result, list):
|
|
465
|
+
return []
|
|
466
|
+
|
|
467
|
+
return [
|
|
468
|
+
LSPLocation(uri=loc["uri"], range=_parse_range(loc["range"]))
|
|
469
|
+
for loc in result
|
|
470
|
+
if isinstance(loc, dict) and "uri" in loc and "range" in loc
|
|
471
|
+
]
|
|
472
|
+
|
|
473
|
+
# LSP kind numbers: 1=text, 2=method, 3=function, 4=class, 5=interface,
|
|
474
|
+
# 6=enum, 7=keyword, 9=variable, 11=constant, 12=struct,
|
|
475
|
+
# 14=property, 23=namespace, 24=package, 25=typeParameter
|
|
476
|
+
_SYMBOL_KIND_MAP: dict[int, str] = {
|
|
477
|
+
1: "text", 2: "method", 3: "function", 4: "class", 5: "interface",
|
|
478
|
+
6: "enum", 7: "keyword", 9: "variable", 11: "constant", 12: "struct",
|
|
479
|
+
14: "property", 23: "namespace", 24: "package", 25: "typeParameter",
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async def search_symbols(
|
|
483
|
+
self, query: str, max_results: int = 50
|
|
484
|
+
) -> list[WSSymbol]:
|
|
485
|
+
"""Search workspace symbols by name using workspace/symbol.
|
|
486
|
+
|
|
487
|
+
Uses the LSP's indexed symbol search — much faster and more
|
|
488
|
+
accurate than grep for finding function/class/constant definitions.
|
|
489
|
+
|
|
490
|
+
Args:
|
|
491
|
+
query: Search query (partial name match).
|
|
492
|
+
max_results: Cap on results to avoid large responses.
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
List of matching WSSymbol objects.
|
|
496
|
+
"""
|
|
497
|
+
if not self._initialized:
|
|
498
|
+
return []
|
|
499
|
+
|
|
500
|
+
result = await self._request("workspace/symbol", {
|
|
501
|
+
"query": query,
|
|
502
|
+
})
|
|
503
|
+
if not result or not isinstance(result, list):
|
|
504
|
+
return []
|
|
505
|
+
|
|
506
|
+
symbols: list[WSSymbol] = []
|
|
507
|
+
for item in result[:max_results]:
|
|
508
|
+
if not isinstance(item, dict):
|
|
509
|
+
continue
|
|
510
|
+
symbols.append(WSSymbol(
|
|
511
|
+
name=str(item.get("name", "")),
|
|
512
|
+
kind=self._SYMBOL_KIND_MAP.get(
|
|
513
|
+
item.get("kind", 1), str(item.get("kind", 1))
|
|
514
|
+
),
|
|
515
|
+
uri=str(item.get("location", {}).get("uri", "")),
|
|
516
|
+
range=_parse_range(item.get("location", {}).get("range", {})),
|
|
517
|
+
container_name=item.get("containerName"),
|
|
518
|
+
detail=item.get("detail"),
|
|
519
|
+
))
|
|
520
|
+
return symbols
|
|
521
|
+
|
|
522
|
+
async def prepare_call_hierarchy(
|
|
523
|
+
self, uri: str, line: int, character: int
|
|
524
|
+
) -> list[dict[str, Any]] | None:
|
|
525
|
+
"""Step 1 of call hierarchy: prepare the symbol at position.
|
|
526
|
+
|
|
527
|
+
Returns raw call hierarchy items from the server, or None if
|
|
528
|
+
the server doesn't support callHierarchy.
|
|
529
|
+
"""
|
|
530
|
+
if not self._initialized:
|
|
531
|
+
return None
|
|
532
|
+
|
|
533
|
+
try:
|
|
534
|
+
result = await self._request("textDocument/prepareCallHierarchy", {
|
|
535
|
+
"textDocument": {"uri": uri},
|
|
536
|
+
"position": {"line": line, "character": character},
|
|
537
|
+
})
|
|
538
|
+
except Exception:
|
|
539
|
+
return None
|
|
540
|
+
|
|
541
|
+
if not result:
|
|
542
|
+
return None
|
|
543
|
+
items: list[dict[str, Any]] = result if isinstance(result, list) else [result]
|
|
544
|
+
return [item for item in items if isinstance(item, dict)]
|
|
545
|
+
|
|
546
|
+
async def incoming_calls(
|
|
547
|
+
self, item: dict[str, Any]
|
|
548
|
+
) -> list[dict[str, Any]]:
|
|
549
|
+
"""Step 2a: get incoming calls — who calls this symbol."""
|
|
550
|
+
if not self._initialized:
|
|
551
|
+
return []
|
|
552
|
+
|
|
553
|
+
try:
|
|
554
|
+
result = await self._request("callHierarchy/incomingCalls", {
|
|
555
|
+
"item": item,
|
|
556
|
+
})
|
|
557
|
+
except Exception:
|
|
558
|
+
return []
|
|
559
|
+
|
|
560
|
+
return result if isinstance(result, list) else []
|
|
561
|
+
|
|
562
|
+
async def outgoing_calls(
|
|
563
|
+
self, item: dict[str, Any]
|
|
564
|
+
) -> list[dict[str, Any]]:
|
|
565
|
+
"""Step 2b: get outgoing calls — what this symbol calls."""
|
|
566
|
+
if not self._initialized:
|
|
567
|
+
return []
|
|
568
|
+
|
|
569
|
+
try:
|
|
570
|
+
result = await self._request("callHierarchy/outgoingCalls", {
|
|
571
|
+
"item": item,
|
|
572
|
+
})
|
|
573
|
+
except Exception:
|
|
574
|
+
return []
|
|
575
|
+
|
|
576
|
+
return result if isinstance(result, list) else []
|
|
577
|
+
|
|
578
|
+
def notify_document_open(self, uri: str, text: str) -> None:
|
|
579
|
+
"""Notify server about file open."""
|
|
580
|
+
if not self._initialized:
|
|
581
|
+
return
|
|
582
|
+
self._notify("textDocument/didOpen", {
|
|
583
|
+
"textDocument": {
|
|
584
|
+
"uri": uri,
|
|
585
|
+
"languageId": ("typescriptreact" if uri.endswith(".tsx") else
|
|
586
|
+
"javascriptreact" if uri.endswith(".jsx") else
|
|
587
|
+
"javascript" if uri.endswith((".js", ".mjs", ".cjs")) else
|
|
588
|
+
self._config.language_id),
|
|
589
|
+
"version": 1,
|
|
590
|
+
"text": text,
|
|
591
|
+
},
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
def notify_document_change(
|
|
595
|
+
self, uri: str, text: str, version: int = 1
|
|
596
|
+
) -> None:
|
|
597
|
+
"""Notify server about file change."""
|
|
598
|
+
if not self._initialized:
|
|
599
|
+
return
|
|
600
|
+
self._notify("textDocument/didChange", {
|
|
601
|
+
"textDocument": {"uri": uri, "version": version},
|
|
602
|
+
"contentChanges": [{"text": text}],
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
def notify_document_close(self, uri: str) -> None:
|
|
606
|
+
"""Notify server about file close."""
|
|
607
|
+
if not self._initialized:
|
|
608
|
+
return
|
|
609
|
+
self._notify("textDocument/didClose", {
|
|
610
|
+
"textDocument": {"uri": uri},
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
# Internal protocol methods
|
|
614
|
+
|
|
615
|
+
async def _request(self, method: str, params: Any) -> Any:
|
|
616
|
+
"""Send a JSON-RPC request and wait for response."""
|
|
617
|
+
if not self._process or not self._process.stdin:
|
|
618
|
+
raise ToolError("LSP server not running", tool_name="lsp")
|
|
619
|
+
|
|
620
|
+
self._request_id += 1
|
|
621
|
+
req_id = self._request_id
|
|
622
|
+
|
|
623
|
+
loop = asyncio.get_running_loop()
|
|
624
|
+
future: asyncio.Future[Any] = loop.create_future()
|
|
625
|
+
self._pending[req_id] = future
|
|
626
|
+
|
|
627
|
+
message = {"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}
|
|
628
|
+
try:
|
|
629
|
+
# initialized/didOpen/didChange must reach the server before the query.
|
|
630
|
+
if self._notifications:
|
|
631
|
+
await asyncio.gather(*tuple(self._notifications))
|
|
632
|
+
await self._send_message(message)
|
|
633
|
+
return await asyncio.wait_for(future, timeout=self._timeout)
|
|
634
|
+
except TimeoutError as exc:
|
|
635
|
+
raise ToolError(f"Request {method} timed out", tool_name="lsp") from exc
|
|
636
|
+
finally:
|
|
637
|
+
self._pending.pop(req_id, None)
|
|
638
|
+
|
|
639
|
+
def _notify(self, method: str, params: Any) -> None:
|
|
640
|
+
"""Send a JSON-RPC notification (no response expected)."""
|
|
641
|
+
message = {"jsonrpc": "2.0", "method": method, "params": params}
|
|
642
|
+
task = asyncio.ensure_future(self._send_message(message))
|
|
643
|
+
self._notifications.add(task)
|
|
644
|
+
task.add_done_callback(self._notification_done)
|
|
645
|
+
|
|
646
|
+
def _notification_done(self, task: asyncio.Task) -> None:
|
|
647
|
+
self._notifications.discard(task)
|
|
648
|
+
if not task.cancelled() and (error := task.exception()) is not None:
|
|
649
|
+
logger.debug("LSP notification failed: %s", error)
|
|
650
|
+
|
|
651
|
+
async def _drain_stderr(self) -> None:
|
|
652
|
+
# An unread pipe can block an otherwise healthy language server.
|
|
653
|
+
if self._process is None or self._process.stderr is None:
|
|
654
|
+
return
|
|
655
|
+
try:
|
|
656
|
+
while chunk := await self._process.stderr.read(4096):
|
|
657
|
+
logger.debug("LSP stderr: %s", chunk.decode(errors="replace")[:1000])
|
|
658
|
+
except (OSError, asyncio.CancelledError):
|
|
659
|
+
pass
|
|
660
|
+
|
|
661
|
+
async def _send_message(self, message: dict[str, Any]) -> None:
|
|
662
|
+
"""Send a message with Content-Length header framing.
|
|
663
|
+
|
|
664
|
+
When _trace_enabled is True, logs the sent message at DEBUG level
|
|
665
|
+
(method name and params, no full content).
|
|
666
|
+
"""
|
|
667
|
+
if not self._process or not self._process.stdin:
|
|
668
|
+
return
|
|
669
|
+
content = json.dumps(message).encode()
|
|
670
|
+
header = f"Content-Length: {len(content)}\r\n\r\n".encode()
|
|
671
|
+
self._process.stdin.write(header + content)
|
|
672
|
+
await self._process.stdin.drain()
|
|
673
|
+
|
|
674
|
+
if self._trace_enabled:
|
|
675
|
+
method = message.get("method", "?")
|
|
676
|
+
params = message.get("params", {})
|
|
677
|
+
# Truncate params for logging
|
|
678
|
+
params_str = json.dumps(params, default=str)
|
|
679
|
+
if len(params_str) > 200:
|
|
680
|
+
params_str = params_str[:197] + "..."
|
|
681
|
+
logger.debug("LSP OUT: %s %s", method, params_str)
|
|
682
|
+
|
|
683
|
+
async def _read_loop(self) -> None:
|
|
684
|
+
"""Background loop reading LSP responses.
|
|
685
|
+
|
|
686
|
+
Also captures stderr for server diagnostics when tracing is enabled.
|
|
687
|
+
"""
|
|
688
|
+
if self._process is None:
|
|
689
|
+
return
|
|
690
|
+
|
|
691
|
+
# Start stderr reader if tracing is enabled
|
|
692
|
+
stderr_task: asyncio.Task[None] | None = None
|
|
693
|
+
if self._process.stderr:
|
|
694
|
+
async def _read_stderr() -> None:
|
|
695
|
+
try:
|
|
696
|
+
stderr_lines: list[str] = []
|
|
697
|
+
while True:
|
|
698
|
+
line = await self._process.stderr.readline()
|
|
699
|
+
if not line:
|
|
700
|
+
break
|
|
701
|
+
decoded = line.decode(errors="replace").strip()
|
|
702
|
+
if decoded:
|
|
703
|
+
stderr_lines.append(decoded)
|
|
704
|
+
if self._trace_enabled and stderr_lines:
|
|
705
|
+
logger.debug("LSP STDERR: %s", decoded)
|
|
706
|
+
except Exception:
|
|
707
|
+
pass
|
|
708
|
+
|
|
709
|
+
stderr_task = asyncio.create_task(_read_stderr())
|
|
710
|
+
|
|
711
|
+
try:
|
|
712
|
+
if self._process.stdout:
|
|
713
|
+
while True:
|
|
714
|
+
data = await self._process.stdout.read(8192)
|
|
715
|
+
if not data:
|
|
716
|
+
break
|
|
717
|
+
self._buffer += data
|
|
718
|
+
self._process_buffer()
|
|
719
|
+
except asyncio.CancelledError:
|
|
720
|
+
pass
|
|
721
|
+
finally:
|
|
722
|
+
if stderr_task:
|
|
723
|
+
stderr_task.cancel()
|
|
724
|
+
try:
|
|
725
|
+
await stderr_task
|
|
726
|
+
except asyncio.CancelledError:
|
|
727
|
+
pass
|
|
728
|
+
|
|
729
|
+
def _process_buffer(self) -> None:
|
|
730
|
+
"""Parse complete messages from the buffer."""
|
|
731
|
+
while True:
|
|
732
|
+
header_end = self._buffer.find(b"\r\n\r\n")
|
|
733
|
+
if header_end == -1:
|
|
734
|
+
break
|
|
735
|
+
|
|
736
|
+
header = self._buffer[:header_end].decode(errors="replace")
|
|
737
|
+
match = re.search(r"Content-Length:\s*(\d+)", header, re.IGNORECASE)
|
|
738
|
+
if not match:
|
|
739
|
+
self._buffer = self._buffer[header_end + 4:]
|
|
740
|
+
continue
|
|
741
|
+
|
|
742
|
+
content_length = int(match.group(1))
|
|
743
|
+
message_start = header_end + 4
|
|
744
|
+
message_end = message_start + content_length
|
|
745
|
+
|
|
746
|
+
if len(self._buffer) < message_end:
|
|
747
|
+
break # Not enough data yet
|
|
748
|
+
|
|
749
|
+
content = self._buffer[message_start:message_end]
|
|
750
|
+
self._buffer = self._buffer[message_end:]
|
|
751
|
+
|
|
752
|
+
try:
|
|
753
|
+
msg = json.loads(content)
|
|
754
|
+
self._handle_message(msg)
|
|
755
|
+
except json.JSONDecodeError:
|
|
756
|
+
pass
|
|
757
|
+
|
|
758
|
+
def _handle_message(self, message: dict[str, Any]) -> None:
|
|
759
|
+
"""Handle an incoming JSON-RPC message.
|
|
760
|
+
|
|
761
|
+
When _trace_enabled is True, logs received messages at DEBUG level.
|
|
762
|
+
"""
|
|
763
|
+
if self._trace_enabled:
|
|
764
|
+
method = message.get("method", "?")
|
|
765
|
+
result = message.get("result")
|
|
766
|
+
error = message.get("error")
|
|
767
|
+
msg_id = message.get("id")
|
|
768
|
+
if method == "?":
|
|
769
|
+
# Response — log result/error summary
|
|
770
|
+
if error:
|
|
771
|
+
logger.debug("LSP IN [resp]: id=%s error=%s", msg_id, error)
|
|
772
|
+
else:
|
|
773
|
+
result_str = json.dumps(result, default=str)
|
|
774
|
+
if len(result_str) > 200:
|
|
775
|
+
result_str = result_str[:197] + "..."
|
|
776
|
+
logger.debug("LSP IN [resp]: id=%s result=%s", msg_id, result_str)
|
|
777
|
+
else:
|
|
778
|
+
# Notification
|
|
779
|
+
logger.debug("LSP IN [notif]: %s", method)
|
|
780
|
+
|
|
781
|
+
msg_id = message.get("id")
|
|
782
|
+
if msg_id is not None and "method" in message:
|
|
783
|
+
method = message["method"]
|
|
784
|
+
params = message.get("params") or {}
|
|
785
|
+
response = {"jsonrpc": "2.0", "id": msg_id}
|
|
786
|
+
if method == "workspace/configuration":
|
|
787
|
+
response["result"] = [None for _ in params.get("items", [])]
|
|
788
|
+
elif method == "workspace/workspaceFolders":
|
|
789
|
+
response["result"] = [{"uri": self._root_uri, "name": "workspace"}]
|
|
790
|
+
elif method == "workspace/applyEdit":
|
|
791
|
+
response["result"] = {"applied": False, "failureReason": "Analysis client is read-only"}
|
|
792
|
+
elif method in {"client/registerCapability", "client/unregisterCapability", "window/workDoneProgress/create"}:
|
|
793
|
+
response["result"] = None
|
|
794
|
+
else:
|
|
795
|
+
response["error"] = {"code": -32601, "message": "Method not supported"}
|
|
796
|
+
task = asyncio.ensure_future(self._send_message(response))
|
|
797
|
+
self._notifications.add(task)
|
|
798
|
+
task.add_done_callback(self._notification_done)
|
|
799
|
+
elif msg_id is not None and msg_id in self._pending:
|
|
800
|
+
future = self._pending.pop(msg_id)
|
|
801
|
+
if future.done():
|
|
802
|
+
return
|
|
803
|
+
error = message.get("error")
|
|
804
|
+
if error:
|
|
805
|
+
future.set_exception(
|
|
806
|
+
ToolError(error.get("message", "Unknown error"), tool_name="lsp")
|
|
807
|
+
)
|
|
808
|
+
else:
|
|
809
|
+
future.set_result(message.get("result"))
|
|
810
|
+
elif "method" in message:
|
|
811
|
+
self._handle_notification(message["method"], message.get("params"))
|
|
812
|
+
|
|
813
|
+
def _handle_notification(self, method: str, params: Any) -> None:
|
|
814
|
+
"""Handle server notification."""
|
|
815
|
+
if method == "textDocument/publishDiagnostics" and self._on_diagnostics:
|
|
816
|
+
uri = params.get("uri", "")
|
|
817
|
+
raw_diags = params.get("diagnostics", [])
|
|
818
|
+
severity_map = {1: "error", 2: "warning", 3: "information", 4: "hint"}
|
|
819
|
+
diagnostics = []
|
|
820
|
+
for d in raw_diags:
|
|
821
|
+
r = d.get("range", {})
|
|
822
|
+
diagnostics.append(LSPDiagnostic(
|
|
823
|
+
range=_parse_range(r),
|
|
824
|
+
message=d.get("message", ""),
|
|
825
|
+
severity=severity_map.get(d.get("severity", 1), "error"),
|
|
826
|
+
source=d.get("source"),
|
|
827
|
+
code=d.get("code"),
|
|
828
|
+
))
|
|
829
|
+
self._on_diagnostics(uri, diagnostics)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
# Alias for external use
|
|
833
|
+
LSPClient = _LSPClient
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _parse_range(r: dict[str, Any]) -> LSPRange:
|
|
837
|
+
"""Parse an LSP range from a dict."""
|
|
838
|
+
start = r.get("start", {})
|
|
839
|
+
end = r.get("end", {})
|
|
840
|
+
return LSPRange(
|
|
841
|
+
start=LSPPosition(
|
|
842
|
+
line=start.get("line", 0),
|
|
843
|
+
character=start.get("character", 0),
|
|
844
|
+
),
|
|
845
|
+
end=LSPPosition(
|
|
846
|
+
line=end.get("line", 0),
|
|
847
|
+
character=end.get("character", 0),
|
|
848
|
+
),
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
# =============================================================================
|
|
853
|
+
# LSP Manager
|
|
854
|
+
# =============================================================================
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
class LSPManager:
|
|
858
|
+
"""Manages multiple LSP clients for different languages.
|
|
859
|
+
|
|
860
|
+
Auto-detects project languages and starts appropriate servers.
|
|
861
|
+
Provides unified API for definitions, completions, hover, and refs.
|
|
862
|
+
"""
|
|
863
|
+
|
|
864
|
+
def __init__(self, config: LSPConfig | None = None) -> None:
|
|
865
|
+
cfg = config or LSPConfig()
|
|
866
|
+
self._enabled = cfg.enabled
|
|
867
|
+
self._servers = {**BUILTIN_SERVERS, **cfg.servers}
|
|
868
|
+
self._auto_detect = cfg.auto_detect
|
|
869
|
+
self._timeout = cfg.timeout
|
|
870
|
+
self._root_uri = cfg.root_uri or f"file://{os.getcwd()}"
|
|
871
|
+
|
|
872
|
+
self._clients: dict[str, _LSPClient] = {}
|
|
873
|
+
self._listeners: set[LSPEventListener] = set()
|
|
874
|
+
self._diagnostics_cache: dict[str, list[LSPDiagnostic]] = {}
|
|
875
|
+
# Optional callback: (tool_name, file_path, results, query) -> None.
|
|
876
|
+
# Fired after get_definition/get_references to feed results back
|
|
877
|
+
# into the cross-reference index. ``query`` carries the original
|
|
878
|
+
# request position (``{"line": int, "col": int}``) so the consumer
|
|
879
|
+
# can resolve which symbol was queried — needed to attribute LSP
|
|
880
|
+
# references to caller→callee call-graph edges.
|
|
881
|
+
self.on_result_callback: (
|
|
882
|
+
Callable[[str, str, list, dict | None], None] | None
|
|
883
|
+
) = None
|
|
884
|
+
|
|
885
|
+
def has_clients(self) -> bool:
|
|
886
|
+
"""Return True if any LSP clients are running."""
|
|
887
|
+
return bool(self._clients)
|
|
888
|
+
|
|
889
|
+
async def auto_start(self, workspace_root: str | None = None) -> list[str]:
|
|
890
|
+
"""Auto-detect and start LSP servers for detected languages."""
|
|
891
|
+
if not self._enabled or not self._auto_detect:
|
|
892
|
+
return []
|
|
893
|
+
|
|
894
|
+
root_uri = f"file://{workspace_root}" if workspace_root else self._root_uri
|
|
895
|
+
root_path = workspace_root or os.getcwd()
|
|
896
|
+
|
|
897
|
+
detected = self._detect_languages(root_path)
|
|
898
|
+
started: list[str] = []
|
|
899
|
+
|
|
900
|
+
for lang_id in detected:
|
|
901
|
+
try:
|
|
902
|
+
await self.start_server(lang_id, root_uri)
|
|
903
|
+
started.append(lang_id)
|
|
904
|
+
except Exception as exc:
|
|
905
|
+
self._emit("lsp.error", {
|
|
906
|
+
"language_id": lang_id, "error": str(exc),
|
|
907
|
+
})
|
|
908
|
+
|
|
909
|
+
return started
|
|
910
|
+
|
|
911
|
+
async def start_server(
|
|
912
|
+
self, language_id: str, root_uri: str | None = None
|
|
913
|
+
) -> None:
|
|
914
|
+
"""Start a specific language server."""
|
|
915
|
+
if language_id in self._clients:
|
|
916
|
+
return
|
|
917
|
+
|
|
918
|
+
server_config = self._servers.get(language_id)
|
|
919
|
+
if not server_config:
|
|
920
|
+
raise ConfigurationError(
|
|
921
|
+
f"No server configuration for language: {language_id}"
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
if not shutil.which(server_config.command):
|
|
925
|
+
raise ConfigurationError(
|
|
926
|
+
f"Language server not found: {server_config.command}"
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
def on_diags(uri: str, diags: list[LSPDiagnostic]) -> None:
|
|
930
|
+
self._diagnostics_cache[uri] = diags
|
|
931
|
+
self._emit("lsp.diagnostics", {"uri": uri, "diagnostics": diags})
|
|
932
|
+
|
|
933
|
+
client = _LSPClient(
|
|
934
|
+
server_config,
|
|
935
|
+
root_uri or self._root_uri,
|
|
936
|
+
self._timeout,
|
|
937
|
+
on_diags,
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
# Wire protocol tracing from feature flag
|
|
941
|
+
from attocode_intel._internal.integrations.feature_flags import feature
|
|
942
|
+
if feature("LSP_PROTOCOL_TRACE"):
|
|
943
|
+
client.enable_protocol_trace(True)
|
|
944
|
+
|
|
945
|
+
try:
|
|
946
|
+
await client.start()
|
|
947
|
+
except BaseException:
|
|
948
|
+
await client.stop()
|
|
949
|
+
raise
|
|
950
|
+
self._clients[language_id] = client
|
|
951
|
+
self._emit("lsp.started", {
|
|
952
|
+
"language_id": language_id,
|
|
953
|
+
"command": server_config.command,
|
|
954
|
+
})
|
|
955
|
+
|
|
956
|
+
async def stop_server(self, language_id: str) -> None:
|
|
957
|
+
"""Stop a specific language server."""
|
|
958
|
+
client = self._clients.pop(language_id, None)
|
|
959
|
+
if client:
|
|
960
|
+
await client.stop()
|
|
961
|
+
self._emit("lsp.stopped", {"language_id": language_id})
|
|
962
|
+
|
|
963
|
+
async def stop_all(self) -> None:
|
|
964
|
+
"""Stop all language servers."""
|
|
965
|
+
tasks = [self.stop_server(lid) for lid in list(self._clients)]
|
|
966
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
967
|
+
|
|
968
|
+
async def get_definition(
|
|
969
|
+
self, file: str, line: int, col: int
|
|
970
|
+
) -> LSPLocation | None:
|
|
971
|
+
"""Get symbol definition."""
|
|
972
|
+
client = self._get_client_for_file(file)
|
|
973
|
+
if not client:
|
|
974
|
+
return None
|
|
975
|
+
uri = self._to_uri(file)
|
|
976
|
+
result = await client.get_definition(uri, line, col)
|
|
977
|
+
if result and self.on_result_callback:
|
|
978
|
+
try:
|
|
979
|
+
self.on_result_callback(
|
|
980
|
+
"definition", file, [result], {"line": line, "col": col},
|
|
981
|
+
)
|
|
982
|
+
except Exception:
|
|
983
|
+
pass # don't let callback errors break LSP flow
|
|
984
|
+
return result
|
|
985
|
+
|
|
986
|
+
async def get_completions(
|
|
987
|
+
self, file: str, line: int, col: int
|
|
988
|
+
) -> list[LSPCompletion]:
|
|
989
|
+
"""Get completions at position."""
|
|
990
|
+
client = self._get_client_for_file(file)
|
|
991
|
+
if not client:
|
|
992
|
+
return []
|
|
993
|
+
uri = self._to_uri(file)
|
|
994
|
+
return await client.get_completions(uri, line, col)
|
|
995
|
+
|
|
996
|
+
async def get_hover(
|
|
997
|
+
self, file: str, line: int, col: int
|
|
998
|
+
) -> str | None:
|
|
999
|
+
"""Get hover information."""
|
|
1000
|
+
client = self._get_client_for_file(file)
|
|
1001
|
+
if not client:
|
|
1002
|
+
return None
|
|
1003
|
+
uri = self._to_uri(file)
|
|
1004
|
+
return await client.get_hover(uri, line, col)
|
|
1005
|
+
|
|
1006
|
+
async def get_references(
|
|
1007
|
+
self, file: str, line: int, col: int,
|
|
1008
|
+
include_declaration: bool = True,
|
|
1009
|
+
) -> list[LSPLocation]:
|
|
1010
|
+
"""Get all references to a symbol."""
|
|
1011
|
+
client = self._get_client_for_file(file)
|
|
1012
|
+
if not client:
|
|
1013
|
+
return []
|
|
1014
|
+
uri = self._to_uri(file)
|
|
1015
|
+
results = await client.get_references(uri, line, col, include_declaration)
|
|
1016
|
+
if results and self.on_result_callback:
|
|
1017
|
+
try:
|
|
1018
|
+
self.on_result_callback(
|
|
1019
|
+
"references", file, results, {"line": line, "col": col},
|
|
1020
|
+
)
|
|
1021
|
+
except Exception:
|
|
1022
|
+
pass # don't let callback errors break LSP flow
|
|
1023
|
+
return results
|
|
1024
|
+
|
|
1025
|
+
async def search_symbols(
|
|
1026
|
+
self, query: str, max_results: int = 50
|
|
1027
|
+
) -> list[WSSymbol]:
|
|
1028
|
+
"""Search workspace symbols across all running language servers.
|
|
1029
|
+
|
|
1030
|
+
Queries each active LSP client and aggregates results.
|
|
1031
|
+
Falls back to the first available client if no file is specified.
|
|
1032
|
+
|
|
1033
|
+
Args:
|
|
1034
|
+
query: Search string (partial name match, case-insensitive).
|
|
1035
|
+
max_results: Maximum results per server.
|
|
1036
|
+
|
|
1037
|
+
Returns:
|
|
1038
|
+
Combined list of matching WSSymbol objects, sorted by name.
|
|
1039
|
+
"""
|
|
1040
|
+
all_symbols: list[WSSymbol] = []
|
|
1041
|
+
|
|
1042
|
+
for lang_id, client in self._clients.items():
|
|
1043
|
+
if not client.is_initialized:
|
|
1044
|
+
continue
|
|
1045
|
+
try:
|
|
1046
|
+
symbols = await client.search_symbols(query, max_results)
|
|
1047
|
+
all_symbols.extend(symbols)
|
|
1048
|
+
except Exception:
|
|
1049
|
+
# One server failed — keep going
|
|
1050
|
+
pass
|
|
1051
|
+
|
|
1052
|
+
# Sort by name for consistent output
|
|
1053
|
+
all_symbols.sort(key=lambda s: s.name)
|
|
1054
|
+
return all_symbols[:max_results]
|
|
1055
|
+
|
|
1056
|
+
def get_diagnostics(self, file: str) -> list[LSPDiagnostic]:
|
|
1057
|
+
"""Get cached diagnostics for a file."""
|
|
1058
|
+
uri = self._to_uri(file)
|
|
1059
|
+
return self._diagnostics_cache.get(uri, [])
|
|
1060
|
+
|
|
1061
|
+
def notify_file_opened(self, file: str, content: str) -> None:
|
|
1062
|
+
"""Notify about file open."""
|
|
1063
|
+
client = self._get_client_for_file(file)
|
|
1064
|
+
if client:
|
|
1065
|
+
client.notify_document_open(self._to_uri(file), content)
|
|
1066
|
+
|
|
1067
|
+
def notify_file_changed(
|
|
1068
|
+
self, file: str, content: str, version: int = 1
|
|
1069
|
+
) -> None:
|
|
1070
|
+
"""Notify about file change."""
|
|
1071
|
+
client = self._get_client_for_file(file)
|
|
1072
|
+
if client:
|
|
1073
|
+
client.notify_document_change(self._to_uri(file), content, version)
|
|
1074
|
+
|
|
1075
|
+
def notify_file_closed(self, file: str) -> None:
|
|
1076
|
+
"""Notify about file close."""
|
|
1077
|
+
client = self._get_client_for_file(file)
|
|
1078
|
+
if client:
|
|
1079
|
+
client.notify_document_close(self._to_uri(file))
|
|
1080
|
+
|
|
1081
|
+
def get_active_servers(self) -> list[str]:
|
|
1082
|
+
"""Get list of active language server IDs."""
|
|
1083
|
+
return list(self._clients.keys())
|
|
1084
|
+
|
|
1085
|
+
def is_server_running(self, language_id: str) -> bool:
|
|
1086
|
+
"""Check if a language server is running."""
|
|
1087
|
+
client = self._clients.get(language_id)
|
|
1088
|
+
return client.is_initialized if client else False
|
|
1089
|
+
|
|
1090
|
+
def on(self, listener: LSPEventListener) -> Callable[[], None]:
|
|
1091
|
+
"""Subscribe to LSP events. Returns unsubscribe function."""
|
|
1092
|
+
self._listeners.add(listener)
|
|
1093
|
+
return lambda: self._listeners.discard(listener)
|
|
1094
|
+
|
|
1095
|
+
async def cleanup(self) -> None:
|
|
1096
|
+
"""Stop all servers and clean up resources."""
|
|
1097
|
+
await self.stop_all()
|
|
1098
|
+
self._listeners.clear()
|
|
1099
|
+
self._diagnostics_cache.clear()
|
|
1100
|
+
|
|
1101
|
+
# Internal helpers
|
|
1102
|
+
|
|
1103
|
+
@staticmethod
|
|
1104
|
+
def _to_uri(file: str) -> str:
|
|
1105
|
+
"""Convert file path to URI."""
|
|
1106
|
+
if file.startswith("file://"):
|
|
1107
|
+
return file
|
|
1108
|
+
return f"file://{os.path.abspath(file)}"
|
|
1109
|
+
|
|
1110
|
+
def _get_client_for_file(self, file: str) -> _LSPClient | None:
|
|
1111
|
+
"""Find the appropriate client for a file extension."""
|
|
1112
|
+
ext = Path(file).suffix.lower()
|
|
1113
|
+
for lang_id, server_config in self._servers.items():
|
|
1114
|
+
if ext in server_config.extensions:
|
|
1115
|
+
return self._clients.get(lang_id)
|
|
1116
|
+
return None
|
|
1117
|
+
|
|
1118
|
+
@staticmethod
|
|
1119
|
+
def _detect_languages(root_path: str) -> list[str]:
|
|
1120
|
+
"""Detect languages based on project files."""
|
|
1121
|
+
detected: set[str] = set()
|
|
1122
|
+
check_files = [
|
|
1123
|
+
("package.json", "typescript"),
|
|
1124
|
+
("tsconfig.json", "typescript"),
|
|
1125
|
+
("pyproject.toml", "python"),
|
|
1126
|
+
("requirements.txt", "python"),
|
|
1127
|
+
("Cargo.toml", "rust"),
|
|
1128
|
+
("go.mod", "go"),
|
|
1129
|
+
]
|
|
1130
|
+
for filename, language in check_files:
|
|
1131
|
+
if (Path(root_path) / filename).exists():
|
|
1132
|
+
detected.add(language)
|
|
1133
|
+
return list(detected)
|
|
1134
|
+
|
|
1135
|
+
def _emit(self, event: str, data: dict[str, Any]) -> None:
|
|
1136
|
+
for listener in self._listeners:
|
|
1137
|
+
try:
|
|
1138
|
+
listener(event, data)
|
|
1139
|
+
except Exception:
|
|
1140
|
+
pass
|