From 6b75b9649666ae7c45aef17d2f25a35b208b6000 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 00:26:39 +0800 Subject: [PATCH 1/3] release: prepare a3s-code 5.3.0 --- .github/setup-workspace.sh | 2 +- .github/workflows/publish-node.yml | 3 +- .../workflows/publish-python-bootstrap.yml | 2 +- .github/workflows/publish-python.yml | 8 +- .github/workflows/release.yml | 31 +- CHANGELOG.md | 22 + Cargo.lock | 50 +- README.md | 38 +- check-version.sh | 2 +- core/Cargo.toml | 4 +- core/src/agent/memory_extraction_runtime.rs | 10 +- .../agent/memory_extraction_runtime/tests.rs | 15 + core/src/agent/tests.rs | 5 +- core/src/agent/tool_memory_runtime.rs | 13 +- core/src/agent_api.rs | 3 +- core/src/agent_api/agent_facade.rs | 3 +- core/src/agent_api/run_admission.rs | 81 +- core/src/agent_api/session_facade.rs | 27 +- core/src/agent_api/session_options.rs | 3 +- core/src/agent_api/tests.rs | 19 + core/src/budget.rs | 4 +- core/src/code_intelligence/diagnostics.rs | 280 ++++++ core/src/code_intelligence/document_store.rs | 404 ++++++++ core/src/code_intelligence/error.rs | 87 ++ .../src/code_intelligence/language_profile.rs | 309 ++++++ .../src/code_intelligence/language_runtime.rs | 625 ++++++++++++ .../language_runtime/diagnostic_runtime.rs | 243 +++++ .../language_runtime/integration_tests.rs | 481 +++++++++ .../language_runtime/paths.rs | 228 +++++ .../language_runtime/protocol.rs | 432 +++++++++ .../language_runtime/tests.rs | 219 +++++ core/src/code_intelligence/local_provider.rs | 514 ++++++++++ core/src/code_intelligence/lsp/client.rs | 910 ++++++++++++++++++ core/src/code_intelligence/lsp/codec.rs | 363 +++++++ core/src/code_intelligence/lsp/initialize.rs | 673 +++++++++++++ core/src/code_intelligence/lsp/mapping.rs | 761 +++++++++++++++ core/src/code_intelligence/lsp/message.rs | 418 ++++++++ core/src/code_intelligence/lsp/mod.rs | 9 + core/src/code_intelligence/lsp/process.rs | 418 ++++++++ core/src/code_intelligence/lsp/router.rs | 291 ++++++ core/src/code_intelligence/mod.rs | 28 + core/src/code_intelligence/project_layout.rs | 329 +++++++ core/src/code_intelligence/registry.rs | 494 ++++++++++ .../code_intelligence/registry/lifecycle.rs | 216 +++++ core/src/code_intelligence/registry/tests.rs | 358 +++++++ core/src/code_intelligence/service.rs | 70 ++ core/src/code_intelligence/types.rs | 302 ++++++ .../code_intelligence/workspace_runtime.rs | 897 +++++++++++++++++ .../workspace_runtime/integration_tests.rs | 474 +++++++++ .../workspace_runtime/support.rs | 301 ++++++ .../workspace_runtime/tests.rs | 167 ++++ core/src/event_protocol.rs | 2 +- core/src/hitl/tests.rs | 16 +- core/src/host_env.rs | 4 +- core/src/language.rs | 112 +++ core/src/lib.rs | 30 +- core/src/mcp/manager.rs | 3 +- core/src/queue.rs | 6 +- core/src/sandbox.rs | 5 +- core/src/tools/builtin/bash.rs | 6 +- .../builtin/code_intelligence/diagnostics.rs | 70 ++ .../tools/builtin/code_intelligence/format.rs | 131 +++ .../tools/builtin/code_intelligence/mod.rs | 85 ++ .../builtin/code_intelligence/navigation.rs | 126 +++ .../builtin/code_intelligence/symbols.rs | 135 +++ .../tools/builtin/code_intelligence/tests.rs | 440 +++++++++ core/src/tools/builtin/mod.rs | 7 +- core/src/tools/mod.rs | 8 +- core/src/tools/process.rs | 68 +- core/src/tools/selector.rs | 9 + core/src/tools/skill.rs | 2 +- core/src/tools/task/parallel_execution.rs | 2 +- core/src/workspace/manifest.rs | 144 ++- core/src/workspace/manifest/scanner.rs | 252 +++-- core/src/workspace/manifest/tests.rs | 148 +++ core/src/workspace/manifest/watcher.rs | 329 +++++++ core/src/workspace/mod.rs | 477 +-------- core/src/workspace/remote_git.rs | 5 +- core/src/workspace/services.rs | 554 +++++++++++ core/src/workspace/tests.rs | 122 +++ .../fixtures/code_intelligence_fake_lsp.rs | 264 +++++ manual/CODE_INTELLIGENCE_DESIGN.md | 175 ++++ scripts/check_release_versions.sh | 8 + scripts/check_semver.sh | 70 ++ sdk/node/Cargo.lock | 52 +- sdk/node/Cargo.toml | 4 +- sdk/node/examples/package-lock.json | 14 +- sdk/node/generated.d.ts | 8 + sdk/node/package-lock.json | 16 +- sdk/node/package.json | 14 +- sdk/node/src/session.rs | 21 + sdk/python-bootstrap/pyproject.toml | 2 +- .../src/a3s_code/_bootstrap.py | 2 +- sdk/python/CHANGELOG.md | 7 + sdk/python/Cargo.lock | 52 +- sdk/python/Cargo.toml | 4 +- sdk/python/pyproject.toml | 2 +- sdk/python/src/session.rs | 15 + 98 files changed, 14901 insertions(+), 773 deletions(-) create mode 100644 core/src/code_intelligence/diagnostics.rs create mode 100644 core/src/code_intelligence/document_store.rs create mode 100644 core/src/code_intelligence/error.rs create mode 100644 core/src/code_intelligence/language_profile.rs create mode 100644 core/src/code_intelligence/language_runtime.rs create mode 100644 core/src/code_intelligence/language_runtime/diagnostic_runtime.rs create mode 100644 core/src/code_intelligence/language_runtime/integration_tests.rs create mode 100644 core/src/code_intelligence/language_runtime/paths.rs create mode 100644 core/src/code_intelligence/language_runtime/protocol.rs create mode 100644 core/src/code_intelligence/language_runtime/tests.rs create mode 100644 core/src/code_intelligence/local_provider.rs create mode 100644 core/src/code_intelligence/lsp/client.rs create mode 100644 core/src/code_intelligence/lsp/codec.rs create mode 100644 core/src/code_intelligence/lsp/initialize.rs create mode 100644 core/src/code_intelligence/lsp/mapping.rs create mode 100644 core/src/code_intelligence/lsp/message.rs create mode 100644 core/src/code_intelligence/lsp/mod.rs create mode 100644 core/src/code_intelligence/lsp/process.rs create mode 100644 core/src/code_intelligence/lsp/router.rs create mode 100644 core/src/code_intelligence/mod.rs create mode 100644 core/src/code_intelligence/project_layout.rs create mode 100644 core/src/code_intelligence/registry.rs create mode 100644 core/src/code_intelligence/registry/lifecycle.rs create mode 100644 core/src/code_intelligence/registry/tests.rs create mode 100644 core/src/code_intelligence/service.rs create mode 100644 core/src/code_intelligence/types.rs create mode 100644 core/src/code_intelligence/workspace_runtime.rs create mode 100644 core/src/code_intelligence/workspace_runtime/integration_tests.rs create mode 100644 core/src/code_intelligence/workspace_runtime/support.rs create mode 100644 core/src/code_intelligence/workspace_runtime/tests.rs create mode 100644 core/src/language.rs create mode 100644 core/src/tools/builtin/code_intelligence/diagnostics.rs create mode 100644 core/src/tools/builtin/code_intelligence/format.rs create mode 100644 core/src/tools/builtin/code_intelligence/mod.rs create mode 100644 core/src/tools/builtin/code_intelligence/navigation.rs create mode 100644 core/src/tools/builtin/code_intelligence/symbols.rs create mode 100644 core/src/tools/builtin/code_intelligence/tests.rs create mode 100644 core/src/workspace/manifest/watcher.rs create mode 100644 core/src/workspace/services.rs create mode 100644 core/tests/fixtures/code_intelligence_fake_lsp.rs create mode 100644 manual/CODE_INTELLIGENCE_DESIGN.md create mode 100644 scripts/check_semver.sh diff --git a/.github/setup-workspace.sh b/.github/setup-workspace.sh index 2bd7cf77..432c4fc2 100644 --- a/.github/setup-workspace.sh +++ b/.github/setup-workspace.sh @@ -15,7 +15,7 @@ sed -i.bak \ -e 's|a3s-memory = { version = "0.1.1", path = "../../memory" }|a3s-memory = "0.1.1"|' \ -e 's|a3s-lane = { version = "0.5", path = "../../lane" }|a3s-lane = "0.5"|' \ -e 's|a3s-lane = { version = "0.4", path = "../../lane" }|a3s-lane = "0.4"|' \ - -e 's|a3s-search = { version = "1.4.1", path = "../../search", default-features = false, features = \["lightpanda"\] }|a3s-search = { version = "1.4.1", default-features = false, features = ["lightpanda"] }|' \ + -e 's|a3s-search = { version = "1.4.1", path = "../../search", default-features = false, features = \["lightpanda"\] }|a3s-search = { version = "=1.4.1", default-features = false, features = ["lightpanda"] }|' \ -e 's|a3s-search = { version = "1.3.0", path = "../../search", default-features = false, features = \["lightpanda"\] }|a3s-search = { version = "1.3.0", default-features = false, features = ["lightpanda"] }|' \ -e 's|a3s-search = { version = "1.2.3", path = "../../search", default-features = false, features = \["lightpanda"\] }|a3s-search = { version = "1.2.3", default-features = false, features = ["lightpanda"] }|' \ -e 's|a3s-flow = { version = "0.4.1", path = "../../flow" }|a3s-flow = "0.4.1"|' \ diff --git a/.github/workflows/publish-node.yml b/.github/workflows/publish-node.yml index 1055c11f..47e9eebe 100644 --- a/.github/workflows/publish-node.yml +++ b/.github/workflows/publish-node.yml @@ -2,10 +2,10 @@ name: Publish Node SDK on: workflow_call: - workflow_dispatch: jobs: build: + if: startsWith(github.ref, 'refs/tags/v') strategy: fail-fast: false matrix: @@ -90,6 +90,7 @@ jobs: publish: name: Publish to npm + if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest needs: build steps: diff --git a/.github/workflows/publish-python-bootstrap.yml b/.github/workflows/publish-python-bootstrap.yml index 6c54fd88..ec03b6e9 100644 --- a/.github/workflows/publish-python-bootstrap.yml +++ b/.github/workflows/publish-python-bootstrap.yml @@ -7,11 +7,11 @@ name: Publish Python Bootstrap on: workflow_call: - workflow_dispatch: jobs: publish: name: Publish Python Bootstrap to PyPI + if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/publish-python.yml b/.github/workflows/publish-python.yml index 3340be36..153cf44a 100644 --- a/.github/workflows/publish-python.yml +++ b/.github/workflows/publish-python.yml @@ -12,12 +12,6 @@ on: required: false type: string description: "Specific target to build (e.g., x86_64-unknown-linux-gnu). If empty, builds all targets." - workflow_dispatch: - inputs: - target: - required: false - type: string - description: "Specific target to build (e.g., x86_64-unknown-linux-gnu). If empty, builds all targets." permissions: contents: write @@ -28,6 +22,7 @@ jobs: # These are uploaded to GitHub Releases, NOT to PyPI # ───────────────────────────────────────────────────────────────── build: + if: startsWith(github.ref, 'refs/tags/v') strategy: fail-fast: false matrix: @@ -101,6 +96,7 @@ jobs: # ───────────────────────────────────────────────────────────────── publish: name: Publish Python SDK + if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest needs: build steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00d9dbf3..a1aa7eec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,6 @@ on: push: tags: - "v*" - workflow_dispatch: permissions: contents: write @@ -21,6 +20,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup workspace context run: bash .github/setup-workspace.sh @@ -39,14 +40,34 @@ jobs: - name: Clippy run: cargo clippy --workspace --lib --bins -- -D warnings - - name: Check version alignment - run: bash check-version.sh + - name: Validate release tag and version alignment + shell: bash + run: | + set -euo pipefail + case "$GITHUB_REF" in + refs/tags/v*) ;; + *) echo "Release publishing requires a v* tag" >&2; exit 1 ;; + esac + VERSION="${GITHUB_REF_NAME#v}" + test -n "$VERSION" + bash check-version.sh "$VERSION" + + - name: Check public API compatibility with v5.2.4 + run: | + cargo install cargo-semver-checks --version 0.48.0 --locked + bash scripts/check_semver.sh 5.2.4 - name: Check SDK protocol and API alignment run: | node scripts/generate_event_protocol_artifacts.mjs --check node scripts/sdk_api_alignment_check.mjs + - name: Check Node SDK + run: cargo check --manifest-path sdk/node/Cargo.toml + + - name: Check Python SDK + run: cargo check --manifest-path sdk/python/Cargo.toml + - name: Default tests run: cargo test --workspace @@ -122,7 +143,7 @@ jobs: # ─────────────────────────────────────────────── publish-node: name: Node SDK - needs: [ci, ci-windows] + needs: [publish-crate] uses: ./.github/workflows/publish-node.yml secrets: inherit @@ -133,7 +154,7 @@ jobs: # ─────────────────────────────────────────────── publish-python: name: Python SDK (native wheels → GH Release) - needs: [ci, ci-windows] + needs: [publish-crate] uses: ./.github/workflows/publish-python.yml secrets: inherit diff --git a/CHANGELOG.md b/CHANGELOG.md index 69d5f30e..d4ab9c11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [5.3.0] - 2026-07-15 + +### Added + +- Added workspace-scoped Code Intelligence for saved-file symbols, + definitions, declarations, references, implementations, and diagnostics, + with native Rust and TypeScript/JavaScript language-server profiles. +- Added `AgentSession::cancel_and_settle(...)` and matching Node.js and Python + SDK methods so hosts can cooperatively cancel a run, bound streaming-worker + cleanup, and know when a session is safe to reuse. + +### Fixed + +- Made workspace manifest discovery cancellation-aware and kept watcher setup, + ownership, and teardown outside Tokio's blocking pool so host shutdown does + not wait indefinitely on filesystem work. +- Stabilized the first navigation query for each saved document revision after + language-server initialization, preventing cold empty or partial reference + results without treating a legitimate empty result as an error. +- Bounded language-service shutdown and force-reaped its dedicated process + group when a server closed protocol streams without exiting. + ## [5.2.4] - 2026-07-14 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index d0cc7e3e..990986a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,7 @@ checksum = "35b83be97f61abdd33096446eae063f9d65d98e84c446560b59d3c744194287e" [[package]] name = "a3s-code-core" -version = "5.2.4" +version = "5.3.0" dependencies = [ "a3s-acl 0.2.0", "a3s-common", @@ -46,6 +46,7 @@ dependencies = [ "jsonschema", "libc", "lopdf", + "lsp-types", "notify", "opentelemetry 0.27.1", "opentelemetry-otlp", @@ -73,6 +74,7 @@ dependencies = [ "tracing", "tracing-opentelemetry", "tracing-subscriber", + "url", "uuid", "wiremock", "zip 0.6.6", @@ -155,7 +157,6 @@ dependencies = [ "clap", "dom_smoothie", "futures", - "quick-xml", "reqwest 0.12.28", "scraper", "serde", @@ -1815,6 +1816,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "fluent-uri" version = "0.4.1" @@ -2873,6 +2883,19 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri 0.1.4", + "serde", + "serde_json", + "serde_repr", +] + [[package]] name = "mac" version = "0.1.1" @@ -3623,16 +3646,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "quinn" version = "0.11.9" @@ -3854,7 +3867,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "348e860aeb0b7bd035778fd11dd9cd5290d32e4aed3b8f2274a00287a9fd362b" dependencies = [ "ahash", - "fluent-uri", + "fluent-uri 0.4.1", "getrandom 0.3.4", "hashbrown 0.17.1", "itoa", @@ -4332,6 +4345,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_spanned" version = "0.6.9" diff --git a/README.md b/README.md index ac745d8b..a454f85a 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,9 @@ the session for its next transcript operation. approval, apply budgets and hooks, sanitize events, and propagate cancellation - **Bounded Workspace Tools**: Read, search, edit, patch, execute, fetch, batch, and paginate without placing unbounded observations into model context +- **Native Code Intelligence**: Query saved-file symbols, definitions, + declarations, references, implementations, and diagnostics through one + workspace-scoped semantic runtime - **Managed Context and Memory**: Assemble ranked context, compact repeatedly, preserve tool evidence, recall durable memory, and extract significant facts - **Model and MCP Adapters**: Use OpenAI-compatible, Anthropic, Zhipu, custom @@ -101,6 +104,7 @@ explicit even when the supporting types are available. | Providers | Baseline | OpenAI-compatible, Anthropic, Zhipu, and custom `LlmClient` implementations | | Structured output | Baseline | Native or prompted JSON generation, JSON Schema validation, partial parsing, and repair | | Workspace tools | Baseline | Capability-gated file, search, shell, Git, web, batch, program, skill, and delegation tools | +| Code Intelligence | Host-selected local workspace | Saved-file symbols, semantic navigation, diagnostics, typed status, and bounded result evidence | | Context | Baseline and host-selected | Ranked filesystem, recent-file, ripgrep, memory, prompt-slot, skill, project, and custom sources | | Memory | Baseline and configurable | Three-tier memory, a default workspace file store, typed overrides, recall, extraction, relations, and pruning | | Persistence | Configured store | Atomic `SessionSnapshotV1`, file or memory stores, run replay, traces, tasks, and checkpoints | @@ -215,6 +219,7 @@ context providers, policies, stores, and active child work. The runtime can: - resume complete snapshots from a `SessionStore`; - atomically replace an idle persisted session with new runtime options; - list and close live sessions owned by an `Agent`; +- cancel and settle an active operation before safely reusing its session; - save, cancel, or close without abandoning active child work; and - inspect runs, paginated run events, active tools, tasks, and verification. @@ -251,6 +256,7 @@ object-only backend that cannot execute it. | --- | --- | --- | | Files and directories | `read`, `write`, `edit`, `patch`, `ls` | Ranged reads, resumable writes, compare-and-swap edits, strict unified patches, and bounded listings | | Workspace search | `glob`, `grep` | Bounded matching with explicit result metadata and continuation | +| Code Intelligence | `code_symbols`, `code_navigation`, `code_diagnostics` | Saved-file semantic metadata and locations with bounded results; source retrieval and mutation stay in the existing file tools | | Commands and source control | `bash`, `git` | Bounded output, cancellation, process-group termination on Unix, and typed Git operations | | Web evidence | `web_search`, `web_fetch` | Ranked multi-engine search, normalized sources, SSRF protections, extraction, and bounded pages | | Structured output | `generate_object` | Schema-constrained model generation with validation and repair | @@ -265,6 +271,36 @@ directories, `AgentDir`, inline host input, or live registration. The model-visible `program` tool executes JavaScript in QuickJS, not arbitrary Python or a shell-script catalog. +### Code Intelligence + +Local hosts can attach native Code Intelligence with +`WorkspaceServices::local_with_code_intelligence` or the shared-backend +variant. The provider reuses the existing workspace manifest, filesystem, +path resolver, cancellation, and tool policy. It does not create a competing +file index, search implementation, editing path, memory store, or MCP server. + +The initial profiles require `rust-analyzer` for Rust and +`typescript-language-server --stdio` for TypeScript and JavaScript. Install the +executables before launching the host: + +```sh +rustup component add rust-analyzer +npm install --global typescript typescript-language-server +``` + +Queries use saved files only. Public lines and characters are zero-based, and +characters count UTF-16 code units. Document results include the saved-content +revision and hash plus a stale flag when the file changes while a query is in +flight. The first navigation request for a saved revision includes a bounded, +cancellable stabilization pass so a protocol-ready but still-indexing server +does not leak a cold partial result. The semantic tools return metadata and +workspace-relative locations; agents continue to use `read`, `grep`, `edit`, +and `patch` for source text and mutations. + +See the [Code Intelligence design](manual/CODE_INTELLIGENCE_DESIGN.md) for the +capability boundary, shared runtime architecture, lifecycle, and verification +plan. + ### Tool governance Every invocation carries declared `ToolCapabilities`, including read-only, @@ -476,7 +512,7 @@ Core and the shared [A3S TUI](https://github.com/A3S-Lab/TUI) framework. | Area | TUI capability | | --- | --- | | Coding workspace | Streaming text, reasoning, tool cards, approvals, task progress, image/file input, session resume/fork, and bounded Markdown/diff rendering | -| Workspace UI | Full-screen `/ide` file browser/editor and `/config` ACL editor with shared workspace and permission boundaries | +| Workspace UI | Full-screen `/ide` file browser/editor with saved-file symbols, semantic navigation, and diagnostics, plus a `/config` ACL editor with shared workspace and permission boundaries | | Models and effort | ACL providers, signed-in Claude Code, Codex, and WorkBuddy accounts, host-provided models, and effort profiles from `low` through `ultracode` | | Long sessions | A single footer context meter, repeated model-aware compaction, past-session search, durable memory, daily consolidation, and local knowledge | | Orchestration | Planning, goals, tracked child tasks, parallel work, Ultracode workflows, bounded DeepResearch, and persistent maker/checker loops | diff --git a/check-version.sh b/check-version.sh index e6108279..976923ef 100755 --- a/check-version.sh +++ b/check-version.sh @@ -6,4 +6,4 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" -scripts/check_release_versions.sh +scripts/check_release_versions.sh "${1:-}" diff --git a/core/Cargo.toml b/core/Cargo.toml index a7b62c47..0d83d000 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-code-core" -version = "5.2.4" +version = "5.3.0" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" @@ -30,6 +30,8 @@ tokio-util = { version = "0.7", features = ["codec"] } # Serialization serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +lsp-types = "=0.97.0" +url = "=2.5.8" # Full JSON Schema validation for structured model output. Remote/file # resolvers stay disabled so an untrusted schema cannot trigger ambient I/O. jsonschema = { version = "0.47", default-features = false } diff --git a/core/src/agent/memory_extraction_runtime.rs b/core/src/agent/memory_extraction_runtime.rs index 0dfcfc37..84e935a7 100644 --- a/core/src/agent/memory_extraction_runtime.rs +++ b/core/src/agent/memory_extraction_runtime.rs @@ -893,7 +893,15 @@ fn tool_signature_is_error(signature: &str) -> bool { fn is_read_only_memory_tool(tool_name: &str) -> bool { matches!( tool_name.to_ascii_lowercase().as_str(), - "read" | "grep" | "glob" | "ls" | "web_fetch" | "web_search" + "read" + | "grep" + | "glob" + | "ls" + | "web_fetch" + | "web_search" + | "code_symbols" + | "code_navigation" + | "code_diagnostics" ) } diff --git a/core/src/agent/memory_extraction_runtime/tests.rs b/core/src/agent/memory_extraction_runtime/tests.rs index 4d2d0534..9723df02 100644 --- a/core/src/agent/memory_extraction_runtime/tests.rs +++ b/core/src/agent/memory_extraction_runtime/tests.rs @@ -330,6 +330,21 @@ fn extraction_gate_skips_short_successful_read_only_tool_turns() { )); } +#[test] +fn extraction_gate_skips_short_successful_code_intelligence_turns() { + for tool in ["code_symbols", "code_navigation", "code_diagnostics"] { + let mut state = ExecutionLoopState::new(&[]); + state.tool_calls_count = 1; + state.remember_tool_signature(tool, &serde_json::json!({}), false); + + assert!(!should_attempt_llm_memory_extraction( + &snapshot(&state), + "inspect code", + "Structured result" + )); + } +} + #[test] fn extraction_gate_accepts_read_only_tool_turns_with_memory_language() { let mut state = ExecutionLoopState::new(&[]); diff --git a/core/src/agent/tests.rs b/core/src/agent/tests.rs index 43d60d79..2fd7d2d8 100644 --- a/core/src/agent/tests.rs +++ b/core/src/agent/tests.rs @@ -4087,7 +4087,10 @@ async function run(ctx, inputs) { .await }); - tokio::time::timeout(std::time::Duration::from_secs(2), started.notified()) + // This deadline protects test setup only. QuickJS initialization can + // take several seconds on a loaded Windows runner; cancellation latency + // remains independently bounded below. + tokio::time::timeout(std::time::Duration::from_secs(10), started.notified()) .await .expect("nested tool must start before cancellation"); cancellation.cancel(); diff --git a/core/src/agent/tool_memory_runtime.rs b/core/src/agent/tool_memory_runtime.rs index ec8c4959..88ce9d8f 100644 --- a/core/src/agent/tool_memory_runtime.rs +++ b/core/src/agent/tool_memory_runtime.rs @@ -86,7 +86,15 @@ fn should_skip_success_memory(tool_name: &str, output: &str) -> bool { let name = tool_name.to_ascii_lowercase(); matches!( name.as_str(), - "read" | "grep" | "glob" | "ls" | "web_fetch" | "web_search" + "read" + | "grep" + | "glob" + | "ls" + | "web_fetch" + | "web_search" + | "code_symbols" + | "code_navigation" + | "code_diagnostics" ) } @@ -110,6 +118,9 @@ mod tests { #[test] fn read_only_success_memory_is_skipped() { assert!(should_skip_success_memory("grep", "match")); + assert!(should_skip_success_memory("code_symbols", "symbol")); + assert!(should_skip_success_memory("code_navigation", "location")); + assert!(should_skip_success_memory("code_diagnostics", "diagnostic")); assert!(!should_skip_success_memory("bash", "built")); } } diff --git a/core/src/agent_api.rs b/core/src/agent_api.rs index 22955c6d..e5518ec5 100644 --- a/core/src/agent_api.rs +++ b/core/src/agent_api.rs @@ -319,7 +319,8 @@ pub struct SessionOptions { /// Per-session tool round limit override. /// /// When set, overrides the agent-level `max_tool_rounds` for this session only. - /// Maps directly from [`AgentDefinition::max_steps`] when creating sessions + /// Maps directly from [`AgentDefinition::max_steps`](crate::subagent::AgentDefinition::max_steps) + /// when creating sessions /// via [`Agent::session_for_agent`]. pub max_tool_rounds: Option, /// Per-session parallel fan-out limit override. diff --git a/core/src/agent_api/agent_facade.rs b/core/src/agent_api/agent_facade.rs index a33eacf0..58495b3c 100644 --- a/core/src/agent_api/agent_facade.rs +++ b/core/src/agent_api/agent_facade.rs @@ -135,7 +135,8 @@ impl Agent { agent_sessions::create_session(self, workspace, options) } - /// Create a session pre-configured from an [`AgentDefinition`]. + /// Create a session pre-configured from an + /// [`AgentDefinition`](crate::subagent::AgentDefinition). /// /// Maps the definition's `permissions`, `prompt`, `model`, and `max_steps` /// directly into [`SessionOptions`], so markdown/YAML-defined subagents can diff --git a/core/src/agent_api/run_admission.rs b/core/src/agent_api/run_admission.rs index f1077afb..411a3a89 100644 --- a/core/src/agent_api/run_admission.rs +++ b/core/src/agent_api/run_admission.rs @@ -6,12 +6,26 @@ use crate::error::{CodeError, Result}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use tokio::task::JoinHandle; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::sync::Notify; +use tokio::task::{AbortHandle, JoinHandle}; -#[derive(Debug, Default)] +#[derive(Debug)] pub(super) struct RunAdmission { active: AtomicBool, + stream_abort: Mutex>, + idle: Notify, +} + +impl Default for RunAdmission { + fn default() -> Self { + Self { + active: AtomicBool::new(false), + stream_abort: Mutex::new(None), + idle: Notify::new(), + } + } } impl RunAdmission { @@ -26,6 +40,49 @@ impl RunAdmission { admission: Arc::clone(self), }) } + + fn register_stream_worker(&self, abort: AbortHandle) { + *self + .stream_abort + .lock() + .unwrap_or_else(|poison| poison.into_inner()) = Some(abort); + } + + fn clear_stream_worker(&self) { + self.stream_abort + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .take(); + } + + /// Abort only the real streaming worker registered for the current lease. + /// Blocking sends have no registered worker and remain cooperative-only. + pub(super) fn abort_stream_worker(&self) -> bool { + let abort = self + .stream_abort + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .clone(); + if let Some(abort) = abort { + abort.abort(); + true + } else { + false + } + } + + pub(super) async fn wait_until_idle(&self, timeout: Duration) -> bool { + let idle = async { + loop { + let notified = self.idle.notified(); + if !self.active.load(Ordering::Acquire) { + return; + } + notified.await; + } + }; + tokio::time::timeout(timeout, idle).await.is_ok() + } } /// RAII lease for one admitted session operation. @@ -36,6 +93,7 @@ pub(super) struct RunAdmissionLease { impl Drop for RunAdmissionLease { fn drop(&mut self) { self.admission.active.store(false, Ordering::Release); + self.admission.idle.notify_waiters(); } } @@ -49,9 +107,13 @@ pub(super) fn guard_stream_handle( handle: JoinHandle<()>, lease: RunAdmissionLease, ) -> JoinHandle<()> { + lease + .admission + .register_stream_worker(handle.abort_handle()); let (finished_tx, finished_rx) = tokio::sync::oneshot::channel(); tokio::spawn(async move { let _ = handle.await; + lease.admission.clear_stream_worker(); drop(lease); let _ = finished_tx.send(()); }); @@ -114,4 +176,17 @@ mod tests { "guardian must release after inner cleanup" ); } + + #[tokio::test] + async fn registered_stream_worker_can_be_force_settled() { + let admission = Arc::new(RunAdmission::default()); + let lease = admission.try_acquire("session-1").unwrap(); + let inner = tokio::spawn(std::future::pending::<()>()); + let proxy = guard_stream_handle(inner, lease); + + assert!(admission.abort_stream_worker()); + assert!(admission.wait_until_idle(Duration::from_secs(1)).await); + assert!(proxy.await.is_ok()); + assert!(admission.try_acquire("session-1").is_ok()); + } } diff --git a/core/src/agent_api/session_facade.rs b/core/src/agent_api/session_facade.rs index 10c149fb..78e7e851 100644 --- a/core/src/agent_api/session_facade.rs +++ b/core/src/agent_api/session_facade.rs @@ -184,6 +184,29 @@ impl AgentSession { RunControl::from_session(self).cancel_current().await } + /// Cancel the current operation and wait for its single-flight lease to be + /// released. Streaming workers first receive cooperative cancellation; a + /// worker that exceeds `grace` is aborted, then given `abort_grace` to run + /// destructors and release admission ownership. + /// + /// Returns `true` once the session is safe to reuse. A blocking `send` + /// future cannot be force-aborted by the session and may return `false` if + /// its caller does not poll it to completion. + pub async fn cancel_and_settle( + &self, + grace: std::time::Duration, + abort_grace: std::time::Duration, + ) -> bool { + let _ = self.cancel().await; + if self.run_admission.wait_until_idle(grace).await { + return true; + } + if !self.run_admission.abort_stream_worker() { + return false; + } + self.run_admission.wait_until_idle(abort_grace).await + } + /// Return a snapshot of the session's conversation history. pub fn history(&self) -> Vec { SessionView::from_session(self).history() @@ -235,8 +258,8 @@ impl AgentSession { /// Return the names of all tools currently registered on this session. /// /// Equivalent to `tool_definitions().into_iter().map(|t| t.name).collect()`. - /// Tools added via [`add_mcp_server`] appear immediately; tools removed via - /// [`remove_mcp_server`] disappear immediately. + /// Tools added via [`Self::add_mcp_server`] appear immediately; tools + /// removed via [`Self::remove_mcp_server`] disappear immediately. pub fn tool_names(&self) -> Vec { DirectToolRuntime::from_session(self).names() } diff --git a/core/src/agent_api/session_options.rs b/core/src/agent_api/session_options.rs index 9c034c4d..344a3a50 100644 --- a/core/src/agent_api/session_options.rs +++ b/core/src/agent_api/session_options.rs @@ -240,7 +240,8 @@ impl SessionOptions { } /// Add skill directories to scan for skill files (*.md). - /// Merged with any global `skill_dirs` from [`CodeConfig`] at session build time. + /// Merged with any global `skill_dirs` from + /// [`CodeConfig`](crate::config::CodeConfig) at session build time. pub fn with_skill_dirs(mut self, dirs: impl IntoIterator>) -> Self { self.skill_dirs.extend(dirs.into_iter().map(Into::into)); self diff --git a/core/src/agent_api/tests.rs b/core/src/agent_api/tests.rs index 659c12c1..dca617ad 100644 --- a/core/src/agent_api/tests.rs +++ b/core/src/agent_api/tests.rs @@ -5517,6 +5517,19 @@ async function run(ctx, inputs) { async fn test_dynamic_workflow_parallel_deep_research_inherits_parent_permissions() { let dir = tempfile::tempdir().unwrap(); let agent = Agent::from_config(test_config()).await.unwrap(); + let workspace_fs: Arc = + Arc::new(TestWorkspaceFs::default()); + let runner = Arc::new(TestWorkspaceRunner::default()); + let runner_backend: Arc = runner.clone(); + let services = crate::workspace::WorkspaceServices::builder( + crate::workspace::WorkspaceRef::new( + "deep-research-permission-inheritance", + dir.path().to_string_lossy(), + ), + workspace_fs, + ) + .command_runner(runner_backend) + .build(); let client = Arc::new(ScriptedStreamingClient::new(vec![ scripted_tool_call_response( "bash-1", @@ -5529,6 +5542,7 @@ async fn test_dynamic_workflow_parallel_deep_research_inherits_parent_permission let opts = SessionOptions::new() .with_llm_client(client) .with_permission_policy(policy) + .with_workspace_backend(services) .with_max_parallel_tasks(2) .with_manual_delegation_enabled(true); let session = agent @@ -5599,6 +5613,11 @@ async function run(ctx, inputs) { "deep-research must inherit the parent confirmation context: {}", result.output ); + assert_eq!( + runner.commands.read().unwrap().as_slice(), + ["echo inherited-dynamic-workflow-deep-research"], + "the child must execute through the parent workspace runner" + ); } #[tokio::test] diff --git a/core/src/budget.rs b/core/src/budget.rs index 97283d78..1e6ad25b 100644 --- a/core/src/budget.rs +++ b/core/src/budget.rs @@ -4,13 +4,13 @@ //! decision points and emits structured events. The host //! implements [`BudgetGuard`] with whatever backend it likes //! (per-tenant counters in Redis, per-day USD caps in Postgres, etc.) -//! and plugs it into [`SessionOptions::with_budget_guard`]. +//! and plugs it into [`SessionOptions::with_budget_guard`](crate::SessionOptions::with_budget_guard). //! //! Decision points wired today: //! //! 1. **Before each LLM call** — [`BudgetGuard::check_before_llm`]. //! A `Deny` aborts the call; a `SoftLimit` lets the call proceed but -//! triggers an [`AgentEvent::BudgetThresholdHit`] so in-session +//! triggers an [`AgentEvent::BudgetThresholdHit`](crate::AgentEvent::BudgetThresholdHit) so in-session //! policy (hooks, custom prompts) can react. //! 2. **After each LLM call** — [`BudgetGuard::record_after_llm`]. //! The host updates its running spend total with the actual usage. diff --git a/core/src/code_intelligence/diagnostics.rs b/core/src/code_intelligence/diagnostics.rs new file mode 100644 index 00000000..bae142a6 --- /dev/null +++ b/core/src/code_intelligence/diagnostics.rs @@ -0,0 +1,280 @@ +use std::collections::HashMap; + +use tokio::sync::{futures::Notified, Mutex, Notify}; + +use super::{CodeDiagnostic, DocumentRevision}; +use crate::workspace::WorkspacePath; + +/// Result of looking up diagnostics for one saved document. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum DiagnosticsLookup { + /// No diagnostics notification has been received for this document. + NotReceived, + /// A notification was received. An empty vector explicitly clears prior + /// diagnostics and must not be collapsed into [`Self::NotReceived`]. + Received { + diagnostics: Vec, + revision: Option, + }, +} + +#[derive(Debug, Clone)] +struct DiagnosticsEntry { + diagnostics: Vec, + revision: Option, + last_touch: u64, +} + +#[derive(Debug)] +struct DiagnosticsState { + entries: HashMap, + clock: u64, +} + +impl DiagnosticsState { + fn next_tick(&mut self) -> u64 { + if self.clock == u64::MAX { + self.renumber_clocks(); + } + self.clock += 1; + self.clock + } + + fn renumber_clocks(&mut self) { + let mut touches = self + .entries + .iter() + .map(|(path, entry)| (path.clone(), entry.last_touch)) + .collect::>(); + touches.sort_by_key(|(_, touch)| *touch); + for (index, (path, _)) in touches.into_iter().enumerate() { + if let Some(entry) = self.entries.get_mut(&path) { + entry.last_touch = index as u64; + } + } + self.clock = self.entries.len() as u64; + } + + fn least_recent_path(&self) -> Option { + self.entries + .iter() + .min_by_key(|(_, entry)| entry.last_touch) + .map(|(path, _)| path.clone()) + } +} + +/// Bounded diagnostics received from language servers for saved documents. +#[derive(Debug)] +pub(crate) struct DiagnosticsStore { + capacity: usize, + state: Mutex, + updates: Notify, +} + +impl DiagnosticsStore { + pub(crate) fn new(capacity: usize) -> Self { + Self { + capacity: capacity.max(1), + state: Mutex::new(DiagnosticsState { + entries: HashMap::new(), + clock: 0, + }), + updates: Notify::new(), + } + } + + #[cfg(test)] + pub(crate) const fn capacity(&self) -> usize { + self.capacity + } + + #[cfg(test)] + pub(crate) async fn len(&self) -> usize { + self.state.lock().await.entries.len() + } + + /// Publish the complete current diagnostics set for one document. + /// + /// Returns the least-recently-used path evicted to stay within capacity. + pub(crate) async fn publish( + &self, + path: &WorkspacePath, + diagnostics: Vec, + revision: Option, + ) -> Option { + let evicted = { + let mut state = self.state.lock().await; + let tick = state.next_tick(); + let evicted = + if !state.entries.contains_key(path) && state.entries.len() >= self.capacity { + let evicted = state.least_recent_path(); + if let Some(path) = &evicted { + state.entries.remove(path); + } + evicted + } else { + None + }; + + state.entries.insert( + path.clone(), + DiagnosticsEntry { + diagnostics, + revision, + last_touch: tick, + }, + ); + evicted + }; + self.updates.notify_waiters(); + evicted + } + + /// Subscribe before checking the store to avoid missing a concurrent + /// publish. Callers must pin and enable the returned future before lookup. + pub(crate) fn notified(&self) -> Notified<'_> { + self.updates.notified() + } + + pub(crate) async fn lookup(&self, path: &WorkspacePath) -> DiagnosticsLookup { + let mut state = self.state.lock().await; + let tick = state.next_tick(); + let Some(entry) = state.entries.get_mut(path) else { + return DiagnosticsLookup::NotReceived; + }; + entry.last_touch = tick; + DiagnosticsLookup::Received { + diagnostics: entry.diagnostics.clone(), + revision: entry.revision, + } + } + + pub(crate) async fn remove(&self, path: &WorkspacePath) -> DiagnosticsLookup { + let mut state = self.state.lock().await; + match state.entries.remove(path) { + Some(entry) => DiagnosticsLookup::Received { + diagnostics: entry.diagnostics, + revision: entry.revision, + }, + None => DiagnosticsLookup::NotReceived, + } + } + + pub(crate) async fn invalidate(&self, path: &WorkspacePath) -> bool { + !matches!(self.remove(path).await, DiagnosticsLookup::NotReceived) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::code_intelligence::{CodeLocation, CodePosition, CodeRange}; + + fn path(value: &str) -> WorkspacePath { + WorkspacePath::from_normalized(value) + } + + fn diagnostic(path: &WorkspacePath, message: &str) -> CodeDiagnostic { + CodeDiagnostic { + location: CodeLocation { + path: path.clone(), + range: CodeRange::new(CodePosition::new(0, 0), CodePosition::new(0, 1)), + }, + severity: None, + code: None, + source: Some("test".to_owned()), + message: message.to_owned(), + } + } + + #[tokio::test] + async fn empty_received_is_distinct_from_not_received() { + let store = DiagnosticsStore::new(2); + let document = path("src/lib.rs"); + assert_eq!( + store.lookup(&document).await, + DiagnosticsLookup::NotReceived + ); + + store.publish(&document, Vec::new(), None).await; + assert_eq!( + store.lookup(&document).await, + DiagnosticsLookup::Received { + diagnostics: Vec::new(), + revision: None, + } + ); + } + + #[tokio::test] + async fn publish_preserves_document_revision() { + let store = DiagnosticsStore::new(2); + let document = path("src/lib.rs"); + let revision = DocumentRevision::new(42); + let item = diagnostic(&document, "error"); + store + .publish(&document, vec![item.clone()], Some(revision)) + .await; + + assert_eq!( + store.lookup(&document).await, + DiagnosticsLookup::Received { + diagnostics: vec![item], + revision: Some(revision), + } + ); + } + + #[tokio::test] + async fn least_recent_entry_is_evicted() { + let store = DiagnosticsStore::new(2); + let a = path("a.rs"); + let b = path("b.rs"); + let c = path("c.rs"); + store.publish(&a, vec![diagnostic(&a, "a")], None).await; + store.publish(&b, vec![diagnostic(&b, "b")], None).await; + let _ = store.lookup(&a).await; + + assert_eq!( + store.publish(&c, vec![diagnostic(&c, "c")], None).await, + Some(b.clone()) + ); + assert_eq!(store.lookup(&b).await, DiagnosticsLookup::NotReceived); + assert!(matches!( + store.lookup(&a).await, + DiagnosticsLookup::Received { .. } + )); + assert_eq!(store.len().await, 2); + assert_eq!(DiagnosticsStore::new(0).capacity(), 1); + } + + #[tokio::test] + async fn enabled_notification_observes_a_concurrent_publish() { + let store = DiagnosticsStore::new(1); + let document = path("a.rs"); + let notified = store.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + + store.publish(&document, Vec::new(), None).await; + + tokio::time::timeout(std::time::Duration::from_secs(1), notified) + .await + .expect("enabled subscribers must be notified"); + } + + #[tokio::test] + async fn invalidate_restores_not_received_state() { + let store = DiagnosticsStore::new(1); + let document = path("a.rs"); + store + .publish(&document, vec![diagnostic(&document, "old")], None) + .await; + assert!(store.invalidate(&document).await); + assert!(!store.invalidate(&document).await); + assert_eq!( + store.lookup(&document).await, + DiagnosticsLookup::NotReceived + ); + } +} diff --git a/core/src/code_intelligence/document_store.rs b/core/src/code_intelligence/document_store.rs new file mode 100644 index 00000000..b5551219 --- /dev/null +++ b/core/src/code_intelligence/document_store.rs @@ -0,0 +1,404 @@ +use std::collections::HashMap; + +use tokio::sync::Mutex; + +use super::{DocumentRevision, DocumentSnapshot}; +use crate::workspace::WorkspacePath; + +const INITIAL_LSP_VERSION: i32 = 1; + +/// Saved-document transition the protocol runtime must apply. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DocumentObservationKind { + Opened, + Changed, + Unchanged, + /// The previous protocol version could not be incremented. Callers must + /// close and reopen the document with the returned reset version. + Reopened, +} + +/// Result of observing the latest saved contents of one document. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DocumentObservation { + pub(crate) snapshot: DocumentSnapshot, + pub(crate) lsp_version: i32, + pub(crate) kind: DocumentObservationKind, + pub(crate) evicted: Option, +} + +/// Metadata returned when a saved document is removed from the bounded store. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RemovedDocument { + pub(crate) snapshot: DocumentSnapshot, + pub(crate) lsp_version: i32, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub(crate) enum DocumentStoreError { + #[error("saved-document revision counter is exhausted")] + RevisionExhausted, +} + +#[derive(Debug, Clone)] +struct DocumentEntry { + content_hash: String, + revision: DocumentRevision, + lsp_version: i32, + last_touch: u64, +} + +impl DocumentEntry { + fn snapshot(&self) -> DocumentSnapshot { + DocumentSnapshot { + revision: self.revision, + content_hash: self.content_hash.clone(), + stale: false, + } + } +} + +#[derive(Debug)] +struct DocumentStoreState { + entries: HashMap, + last_revision: u64, + touch_clock: u64, +} + +impl DocumentStoreState { + fn next_revision(&mut self) -> Result { + self.last_revision = self + .last_revision + .checked_add(1) + .ok_or(DocumentStoreError::RevisionExhausted)?; + Ok(DocumentRevision::new(self.last_revision)) + } + + fn next_touch(&mut self) -> u64 { + // Touch ordering only chooses an eviction candidate. Renumbering keeps + // that ordering intact without allowing the clock to wrap. + if self.touch_clock == u64::MAX { + let mut ordered = self + .entries + .iter() + .map(|(path, entry)| (path.clone(), entry.last_touch)) + .collect::>(); + ordered.sort_by_key(|(_, touch)| *touch); + for (index, (path, _)) in ordered.into_iter().enumerate() { + if let Some(entry) = self.entries.get_mut(&path) { + entry.last_touch = index as u64; + } + } + self.touch_clock = self.entries.len() as u64; + } + self.touch_clock += 1; + self.touch_clock + } + + fn least_recent_path(&self) -> Option { + self.entries + .iter() + .min_by_key(|(_, entry)| entry.last_touch) + .map(|(path, _)| path.clone()) + } +} + +/// Bounded, in-memory metadata for saved documents. +/// +/// Source text is hashed before the mutex is acquired and is never retained. +#[derive(Debug)] +pub(crate) struct DocumentStore { + capacity: usize, + state: Mutex, +} + +impl DocumentStore { + pub(crate) fn new(capacity: usize) -> Self { + Self { + capacity: capacity.max(1), + state: Mutex::new(DocumentStoreState { + entries: HashMap::new(), + last_revision: 0, + touch_clock: 0, + }), + } + } + + #[cfg(test)] + pub(crate) const fn capacity(&self) -> usize { + self.capacity + } + + #[cfg(test)] + pub(crate) async fn len(&self) -> usize { + self.state.lock().await.entries.len() + } + + /// Observe saved text and return the protocol transition needed to make a + /// language server reflect it. + pub(crate) async fn observe( + &self, + path: &WorkspacePath, + saved_content: &str, + ) -> Result { + let content_hash = sha256::digest(saved_content.as_bytes()); + let mut state = self.state.lock().await; + let touch = state.next_touch(); + + if let Some(existing) = state.entries.get(path) { + if existing.content_hash == content_hash { + let mut snapshot = existing.snapshot(); + let lsp_version = existing.lsp_version; + if let Some(existing) = state.entries.get_mut(path) { + existing.last_touch = touch; + snapshot.stale = false; + } + return Ok(DocumentObservation { + snapshot, + lsp_version, + kind: DocumentObservationKind::Unchanged, + evicted: None, + }); + } + + let previous_lsp_version = existing.lsp_version; + let revision = state.next_revision()?; + let (lsp_version, kind) = advance_lsp_version(previous_lsp_version); + state.entries.insert( + path.clone(), + DocumentEntry { + content_hash: content_hash.clone(), + revision, + lsp_version, + last_touch: touch, + }, + ); + return Ok(DocumentObservation { + snapshot: DocumentSnapshot { + revision, + content_hash, + stale: false, + }, + lsp_version, + kind, + evicted: None, + }); + } + + // Allocate the monotonic revision before mutating the bounded set so + // exhaustion cannot evict a valid entry on a failed observation. + let revision = state.next_revision()?; + let evicted = if state.entries.len() >= self.capacity { + let evicted = state.least_recent_path(); + if let Some(path) = &evicted { + state.entries.remove(path); + } + evicted + } else { + None + }; + state.entries.insert( + path.clone(), + DocumentEntry { + content_hash: content_hash.clone(), + revision, + lsp_version: INITIAL_LSP_VERSION, + last_touch: touch, + }, + ); + Ok(DocumentObservation { + snapshot: DocumentSnapshot { + revision, + content_hash, + stale: false, + }, + lsp_version: INITIAL_LSP_VERSION, + kind: DocumentObservationKind::Opened, + evicted, + }) + } + + /// Return the current saved snapshot and mark it as recently used. + #[cfg(test)] + pub(crate) async fn snapshot(&self, path: &WorkspacePath) -> Option { + let mut state = self.state.lock().await; + let touch = state.next_touch(); + let entry = state.entries.get_mut(path)?; + entry.last_touch = touch; + Some(entry.snapshot()) + } + + #[cfg(test)] + pub(crate) async fn touch(&self, path: &WorkspacePath) -> bool { + let mut state = self.state.lock().await; + let touch = state.next_touch(); + let Some(entry) = state.entries.get_mut(path) else { + return false; + }; + entry.last_touch = touch; + true + } + + /// Resolve a diagnostics version only when it still identifies the + /// currently saved revision. + pub(crate) async fn revision_for_lsp_version( + &self, + path: &WorkspacePath, + lsp_version: i32, + ) -> Option { + let mut state = self.state.lock().await; + let touch = state.next_touch(); + let entry = state.entries.get_mut(path)?; + if entry.lsp_version != lsp_version { + return None; + } + entry.last_touch = touch; + Some(entry.revision) + } + + /// Compare a query's starting snapshot with the currently saved document. + pub(crate) async fn complete_query( + &self, + path: &WorkspacePath, + mut snapshot: DocumentSnapshot, + ) -> DocumentSnapshot { + let state = self.state.lock().await; + let current_revision = state.entries.get(path).map(|entry| entry.revision); + snapshot.stale |= current_revision != Some(snapshot.revision); + snapshot + } + + pub(crate) async fn remove(&self, path: &WorkspacePath) -> Option { + let mut state = self.state.lock().await; + state.entries.remove(path).map(|entry| RemovedDocument { + snapshot: entry.snapshot(), + lsp_version: entry.lsp_version, + }) + } + + /// Forget metadata after an external saved-file change. A later observe is + /// treated as a fresh open. + pub(crate) async fn invalidate(&self, path: &WorkspacePath) -> Option { + self.remove(path).await + } +} + +fn advance_lsp_version(current: i32) -> (i32, DocumentObservationKind) { + match current.checked_add(1) { + Some(version) => (version, DocumentObservationKind::Changed), + None => (INITIAL_LSP_VERSION, DocumentObservationKind::Reopened), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn path(value: &str) -> WorkspacePath { + WorkspacePath::from_normalized(value) + } + + #[tokio::test] + async fn unchanged_hash_keeps_revision_and_change_advances_it() { + let store = DocumentStore::new(4); + let first = store.observe(&path("src/lib.rs"), "hello").await.unwrap(); + assert_eq!(first.kind, DocumentObservationKind::Opened); + assert_eq!(first.lsp_version, INITIAL_LSP_VERSION); + assert_eq!( + first.snapshot.content_hash, + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + ); + + let unchanged = store.observe(&path("src/lib.rs"), "hello").await.unwrap(); + assert_eq!(unchanged.kind, DocumentObservationKind::Unchanged); + assert_eq!(unchanged.snapshot, first.snapshot); + assert_eq!(unchanged.lsp_version, first.lsp_version); + + let changed = store + .observe(&path("src/lib.rs"), "hello again") + .await + .unwrap(); + assert_eq!(changed.kind, DocumentObservationKind::Changed); + assert!(changed.snapshot.revision > first.snapshot.revision); + assert_eq!(changed.lsp_version, first.lsp_version + 1); + + let other = store.observe(&path("src/main.rs"), "main").await.unwrap(); + assert!(other.snapshot.revision > changed.snapshot.revision); + } + + #[tokio::test] + async fn least_recent_document_is_evicted_and_capacity_is_at_least_one() { + let store = DocumentStore::new(2); + store.observe(&path("a.rs"), "a").await.unwrap(); + store.observe(&path("b.rs"), "b").await.unwrap(); + assert!(store.touch(&path("a.rs")).await); + + let observed = store.observe(&path("c.rs"), "c").await.unwrap(); + assert_eq!(observed.evicted, Some(path("b.rs"))); + assert!(store.snapshot(&path("a.rs")).await.is_some()); + assert!(store.snapshot(&path("b.rs")).await.is_none()); + assert_eq!(store.len().await, 2); + + let minimum = DocumentStore::new(0); + assert_eq!(minimum.capacity(), 1); + } + + #[test] + fn version_overflow_requires_reopen_instead_of_saturation() { + assert_eq!( + advance_lsp_version(i32::MAX - 1), + (i32::MAX, DocumentObservationKind::Changed) + ); + assert_eq!( + advance_lsp_version(i32::MAX), + (INITIAL_LSP_VERSION, DocumentObservationKind::Reopened) + ); + } + + #[tokio::test] + async fn lsp_version_only_resolves_the_current_revision() { + let store = DocumentStore::new(2); + let opened = store.observe(&path("a.rs"), "a").await.unwrap(); + assert_eq!( + store + .revision_for_lsp_version(&path("a.rs"), opened.lsp_version) + .await, + Some(opened.snapshot.revision) + ); + + let changed = store.observe(&path("a.rs"), "b").await.unwrap(); + assert_eq!( + store + .revision_for_lsp_version(&path("a.rs"), opened.lsp_version) + .await, + None + ); + assert_eq!( + store + .revision_for_lsp_version(&path("a.rs"), changed.lsp_version) + .await, + Some(changed.snapshot.revision) + ); + } + + #[tokio::test] + async fn query_completion_marks_changed_or_removed_document_stale() { + let store = DocumentStore::new(2); + let first = store.observe(&path("a.rs"), "a").await.unwrap(); + let fresh = store + .complete_query(&path("a.rs"), first.snapshot.clone()) + .await; + assert!(!fresh.stale); + + store.observe(&path("a.rs"), "b").await.unwrap(); + let stale = store + .complete_query(&path("a.rs"), first.snapshot.clone()) + .await; + assert!(stale.stale); + + let current = store.snapshot(&path("a.rs")).await.unwrap(); + let removed = store.invalidate(&path("a.rs")).await.unwrap(); + assert_eq!(removed.snapshot, current); + assert!(store.complete_query(&path("a.rs"), current).await.stale); + } +} diff --git a/core/src/code_intelligence/error.rs b/core/src/code_intelligence/error.rs new file mode 100644 index 00000000..094ebd41 --- /dev/null +++ b/core/src/code_intelligence/error.rs @@ -0,0 +1,87 @@ +use super::{CodePosition, LanguageId}; +use crate::workspace::WorkspacePath; +use std::time::Duration; + +/// Result type for semantic code intelligence operations. +pub type CodeIntelligenceResult = std::result::Result; + +/// Typed failure returned by a code intelligence runtime. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum CodeIntelligenceError { + #[error("Code Intelligence is unavailable: {message}")] + Unavailable { message: String }, + + #[error("Code Intelligence operation '{operation}' is unsupported: {message}")] + Unsupported { operation: String, message: String }, + + #[error("workspace path {path:?} cannot be used for Code Intelligence: {message}")] + InvalidPath { + path: WorkspacePath, + message: String, + }, + + #[error("invalid position {position:?} for workspace document {path:?}")] + InvalidPosition { + path: WorkspacePath, + position: CodePosition, + }, + + #[error("Code Intelligence operation was cancelled")] + Cancelled, + + #[error("Code Intelligence operation '{operation}' timed out after {duration:?}")] + Timeout { + operation: String, + duration: Duration, + }, + + #[error("Code Intelligence process for '{language}' exited: {message}")] + ProcessExited { + language: LanguageId, + message: String, + }, + + #[error("Code Intelligence protocol error: {message}")] + Protocol { message: String }, +} + +impl CodeIntelligenceError { + /// Stable machine-readable code for API and tool adapters. + pub const fn code(&self) -> &'static str { + match self { + Self::Unavailable { .. } => "CODE_INTELLIGENCE_UNAVAILABLE", + Self::Unsupported { .. } => "CODE_INTELLIGENCE_UNSUPPORTED", + Self::InvalidPath { .. } => "CODE_INTELLIGENCE_INVALID_PATH", + Self::InvalidPosition { .. } => "CODE_INTELLIGENCE_INVALID_POSITION", + Self::Cancelled => "CODE_INTELLIGENCE_CANCELLED", + Self::Timeout { .. } => "CODE_INTELLIGENCE_TIMEOUT", + Self::ProcessExited { .. } => "CODE_INTELLIGENCE_PROCESS_EXITED", + Self::Protocol { .. } => "CODE_INTELLIGENCE_PROTOCOL_ERROR", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_send_sync() {} + + #[test] + fn errors_are_send_and_sync() { + assert_send_sync::(); + } + + #[test] + fn error_codes_are_stable_and_messages_keep_context() { + let error = CodeIntelligenceError::Timeout { + operation: "document_symbols".to_string(), + duration: Duration::from_secs(2), + }; + + assert_eq!(error.code(), "CODE_INTELLIGENCE_TIMEOUT"); + assert!(error.to_string().contains("document_symbols")); + assert!(error.to_string().contains("2s")); + } +} diff --git a/core/src/code_intelligence/language_profile.rs b/core/src/code_intelligence/language_profile.rs new file mode 100644 index 00000000..622effed --- /dev/null +++ b/core/src/code_intelligence/language_profile.rs @@ -0,0 +1,309 @@ +//! Built-in language server process profiles. + +use super::project_layout::{ProjectLanguageProfile, ProjectLayout, ProjectMarkerKind}; +use crate::language::LanguageCatalog; +use crate::workspace::WorkspacePath; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +const DEFAULT_INITIALIZATION_SETTLE_DELAY: Duration = Duration::from_millis(750); +const DEFAULT_NAVIGATION_SETTLE_DELAY: Duration = Duration::from_millis(250); + +/// Process topology used by a language server profile. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ServerTopology { + MultiFolder, +} + +/// Typed command used to launch one language server process. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct LanguageServerCommand { + pub(crate) program: PathBuf, + pub(crate) args: Vec, + pub(crate) env: BTreeMap, +} + +impl LanguageServerCommand { + fn new( + program: impl Into, + args: impl IntoIterator>, + ) -> Self { + Self { + program: program.into(), + args: args.into_iter().map(Into::into).collect(), + env: BTreeMap::new(), + } + } +} + +/// A resolved, typed language server profile. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct LanguageServerProfile { + id: ProjectLanguageProfile, + topology: ServerTopology, + command: LanguageServerCommand, + initialization_settle_delay: Duration, + navigation_settle_delay: Duration, +} + +impl LanguageServerProfile { + pub(crate) fn rust(program: impl Into) -> Self { + Self { + id: ProjectLanguageProfile::Rust, + topology: ServerTopology::MultiFolder, + command: LanguageServerCommand::new(program, std::iter::empty::()), + initialization_settle_delay: DEFAULT_INITIALIZATION_SETTLE_DELAY, + navigation_settle_delay: DEFAULT_NAVIGATION_SETTLE_DELAY, + } + } + + pub(crate) fn typescript_javascript(program: impl Into) -> Self { + Self { + id: ProjectLanguageProfile::TypeScriptJavaScript, + topology: ServerTopology::MultiFolder, + command: LanguageServerCommand::new(program, ["--stdio"]), + initialization_settle_delay: DEFAULT_INITIALIZATION_SETTLE_DELAY, + navigation_settle_delay: DEFAULT_NAVIGATION_SETTLE_DELAY, + } + } + + pub(crate) fn built_in_defaults() -> Vec { + vec![ + Self::rust("rust-analyzer"), + Self::typescript_javascript("typescript-language-server"), + ] + } + + pub(crate) fn id(&self) -> ProjectLanguageProfile { + self.id + } + + #[cfg(test)] + pub(crate) fn topology(&self) -> ServerTopology { + self.topology + } + + pub(crate) fn command(&self) -> &LanguageServerCommand { + &self.command + } + + pub(crate) fn initialization_settle_delay(&self) -> Duration { + self.initialization_settle_delay + } + + pub(crate) fn navigation_settle_delay(&self) -> Duration { + self.navigation_settle_delay + } + + #[cfg(test)] + pub(crate) fn with_settle_delays( + mut self, + initialization: Duration, + navigation: Duration, + ) -> Self { + self.initialization_settle_delay = initialization; + self.navigation_settle_delay = navigation; + self + } + + pub(crate) fn language_ids(&self) -> &'static [&'static str] { + match self.id { + ProjectLanguageProfile::Rust => &["rust"], + ProjectLanguageProfile::TypeScriptJavaScript => &[ + "javascript", + "javascript-react", + "typescript", + "typescript-react", + ], + } + } + + pub(crate) fn supports_path(&self, path: &Path) -> bool { + LanguageCatalog::id_for_path(path) + .is_some_and(|language| self.language_ids().contains(&language)) + } + + /// Return stable, deduplicated workspace folders for this profile. + pub(crate) fn project_roots(&self, layout: &ProjectLayout) -> Vec { + let roots = layout + .markers + .iter() + .filter(|marker| marker.profile == self.id) + .map(|marker| marker.root.as_str().to_string()) + .collect::>(); + + if roots.is_empty() { + vec![WorkspacePath::root()] + } else { + roots + .into_iter() + .map(WorkspacePath::from_normalized) + .collect() + } + } + + /// Initialization options sent once when the process starts. + pub(crate) fn initialization_options( + &self, + canonical_root: &Path, + layout: &ProjectLayout, + ) -> Value { + match self.id { + ProjectLanguageProfile::Rust => self.rust_configuration(canonical_root, layout), + ProjectLanguageProfile::TypeScriptJavaScript => Value::Null, + } + } + + /// Section-addressable values returned from `workspace/configuration`. + pub(crate) fn workspace_settings( + &self, + canonical_root: &Path, + layout: &ProjectLayout, + ) -> BTreeMap { + match self.id { + ProjectLanguageProfile::Rust => BTreeMap::from([( + "rust-analyzer".to_string(), + self.rust_configuration(canonical_root, layout), + )]), + ProjectLanguageProfile::TypeScriptJavaScript => BTreeMap::new(), + } + } + + fn rust_configuration(&self, canonical_root: &Path, layout: &ProjectLayout) -> Value { + json!({ + "linkedProjects": self.rust_project_manifests(canonical_root, layout), + "cargo": { + "buildScripts": { + "enable": false, + }, + }, + "procMacro": { + "enable": false, + }, + "checkOnSave": false, + }) + } + + fn rust_project_manifests(&self, canonical_root: &Path, layout: &ProjectLayout) -> Vec { + layout + .markers + .iter() + .filter(|marker| { + marker.profile == ProjectLanguageProfile::Rust + && marker.kind == ProjectMarkerKind::CargoManifest + }) + .map(|marker| { + canonical_root + .join(marker.path.as_str()) + .to_string_lossy() + .into_owned() + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::{LanguageServerProfile, ServerTopology}; + use crate::code_intelligence::project_layout::ProjectLayoutResolver; + use crate::workspace::{ + LocalWorkspaceFile, LocalWorkspaceFileStatus, LocalWorkspaceManifestSnapshot, + }; + use serde_json::json; + use std::path::{Path, PathBuf}; + + fn file(path: &str) -> LocalWorkspaceFile { + LocalWorkspaceFile { + path: path.to_string(), + size: 1, + modified_ms: Some(1), + language: None, + status: LocalWorkspaceFileStatus::Tracked, + binary: false, + generated: false, + } + } + + fn layout(paths: &[&str]) -> super::ProjectLayout { + ProjectLayoutResolver::resolve(&LocalWorkspaceManifestSnapshot { + version: 1, + root: PathBuf::from("/workspace"), + files: paths.iter().map(|path| file(path)).collect(), + scanned_at_ms: 1, + }) + } + + #[test] + fn built_in_commands_and_languages_are_explicit() { + let profiles = LanguageServerProfile::built_in_defaults(); + let rust = &profiles[0]; + let web = &profiles[1]; + + assert_eq!(rust.command().program, PathBuf::from("rust-analyzer")); + assert!(rust.command().args.is_empty()); + assert_eq!(rust.topology(), ServerTopology::MultiFolder); + assert!(rust.supports_path(Path::new("src/lib.rs"))); + assert!(!rust.supports_path(Path::new("src/main.ts"))); + + assert_eq!( + web.command().program, + PathBuf::from("typescript-language-server") + ); + assert_eq!(web.command().args, ["--stdio"]); + assert!(web.supports_path(Path::new("src/main.tsx"))); + assert!(web.supports_path(Path::new("src/main.jsx"))); + } + + #[test] + fn project_roots_are_stable_and_deduplicated() { + let profile = LanguageServerProfile::typescript_javascript("server"); + let layout = layout(&[ + "apps/web/package.json", + "apps/web/tsconfig.json", + "packages/ui/package.json", + ]); + + assert_eq!( + profile + .project_roots(&layout) + .into_iter() + .map(|path| path.as_str().to_string()) + .collect::>(), + ["apps/web", "packages/ui"] + ); + assert_eq!( + LanguageServerProfile::rust("server").project_roots(&layout), + [crate::workspace::WorkspacePath::root()] + ); + } + + #[test] + fn rust_settings_use_discovered_manifests_and_disable_workspace_code_execution() { + let profile = LanguageServerProfile::rust("server"); + let layout = layout(&["crates/a/Cargo.toml", "crates/b/Cargo.toml"]); + let root = Path::new("/workspace"); + let linked_projects = ["crates/a/Cargo.toml", "crates/b/Cargo.toml"] + .map(|manifest| root.join(manifest).to_string_lossy().into_owned()); + let expected = json!({ + "linkedProjects": linked_projects, + "cargo": { + "buildScripts": { + "enable": false, + }, + }, + "procMacro": { + "enable": false, + }, + "checkOnSave": false, + }); + + assert_eq!(profile.initialization_options(root, &layout), expected); + assert_eq!( + profile.workspace_settings(root, &layout)["rust-analyzer"], + profile.initialization_options(root, &layout) + ); + } +} diff --git a/core/src/code_intelligence/language_runtime.rs b/core/src/code_intelligence/language_runtime.rs new file mode 100644 index 00000000..10ce8cc8 --- /dev/null +++ b/core/src/code_intelligence/language_runtime.rs @@ -0,0 +1,625 @@ +//! One saved-document language runtime. + +mod diagnostic_runtime; +#[cfg(test)] +mod integration_tests; +mod paths; +mod protocol; +#[cfg(test)] +mod tests; + +use std::{ + collections::{HashMap, HashSet}, + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; + +use lsp_types::{ + DocumentSymbolParams, DocumentSymbolResponse as LspDocumentSymbolResponse, + GotoDefinitionParams, GotoDefinitionResponse, Location, PartialResultParams, Position, + ReferenceContext, ReferenceParams, TextDocumentIdentifier, TextDocumentPositionParams, + WorkDoneProgressParams, WorkspaceSymbolParams, WorkspaceSymbolResponse, +}; +use tokio::sync::watch; +use tokio::{sync::Mutex, task::JoinHandle}; +use tokio_util::sync::CancellationToken; + +use self::{ + diagnostic_runtime::{consume_notifications, DiagnosticRuntimeState}, + paths::{directory_url, valid_utf16_position, validate_canonical_root, workspace_folders}, + protocol::{bound_items, ensure_not_cancelled, navigation_request}, +}; +use super::{ + diagnostics::DiagnosticsStore, + document_store::{DocumentStore, DocumentStoreError}, + language_profile::LanguageServerProfile, + lsp::{ + client::LspClientError, + initialize::{initialize, InitializeConfig, InitializedServer}, + mapping::{self, MappingError}, + process::{LspProcess, LspProcessError}, + router::{ServerRequestRouter, ServerRequestRouterConfig, WorkspaceSettings}, + }, + project_layout::ProjectLayout, + CodeDiagnostic, CodeIntelligenceCapabilities, CodeLocation, CodePosition, CodeQueryResult, + DocumentRevision, DocumentSymbol, NavigationKind, SymbolInformation, +}; +use crate::workspace::WorkspacePath; + +const MAX_NAVIGATION_RESULTS: usize = 1_000; +const MAX_DOCUMENT_SYMBOLS: usize = 2_000; + +/// Failure from one initialized language runtime. +#[derive(Debug, thiserror::Error)] +pub(crate) enum LanguageRuntimeError { + #[error("invalid canonical workspace root {root:?}: {message}")] + InvalidRoot { root: PathBuf, message: String }, + + #[error("invalid normalized workspace path {path:?}: {message}")] + InvalidPath { + path: WorkspacePath, + message: String, + }, + + #[error("language runtime does not support path {path:?}")] + UnsupportedPath { path: WorkspacePath }, + + #[error("language runtime does not support operation '{operation}'")] + Unsupported { operation: &'static str }, + + #[error("invalid UTF-16 position {position:?} for saved document {path:?}")] + InvalidPosition { + path: WorkspacePath, + position: CodePosition, + }, + + #[error("diagnostics for saved document {path:?} have not been received yet")] + PendingDiagnostics { path: WorkspacePath }, + + #[error("language runtime operation was cancelled")] + Cancelled, + + #[error("language runtime operation '{operation}' timed out after {duration:?}")] + Timeout { + operation: &'static str, + duration: Duration, + }, + + #[error("language server process failed while attempting to {operation}: {source}")] + Process { + operation: &'static str, + #[source] + source: LspProcessError, + }, + + #[error("language server client failed during '{operation}': {source}")] + Client { + operation: &'static str, + #[source] + source: LspClientError, + }, + + #[error("language server response mapping failed during '{operation}': {source}")] + Mapping { + operation: &'static str, + #[source] + source: MappingError, + }, + + #[error("language server JSON failed during '{operation}': {source}")] + Serde { + operation: &'static str, + #[source] + source: serde_json::Error, + }, + + #[error("saved-document metadata failed: {0}")] + DocumentStore(#[from] DocumentStoreError), +} + +/// An initialized process serving one language profile and workspace layout. +pub(crate) struct LanguageRuntime { + profile: LanguageServerProfile, + canonical_root: PathBuf, + layout: ProjectLayout, + process: LspProcess, + initialized: InitializedServer, + documents: Arc, + diagnostics: Arc, + diagnostic_state: Arc>, + diagnostic_updates: Arc>, + open_documents: Mutex>, + document_sync: Mutex<()>, + navigation_revisions: Mutex>, + navigation_stabilization: Mutex<()>, + notification_task: Mutex>>, + timeout: Duration, +} + +impl std::fmt::Debug for LanguageRuntime { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("LanguageRuntime") + .field("profile", &self.profile.id()) + .field("canonical_root", &self.canonical_root) + .field("layout_hash", &self.layout.layout_hash) + .field("process", &self.process) + .field("capabilities", &self.initialized.capabilities) + .finish_non_exhaustive() + } +} + +impl LanguageRuntime { + pub(crate) async fn start( + profile: LanguageServerProfile, + canonical_root: PathBuf, + layout: ProjectLayout, + documents: Arc, + diagnostics: Arc, + cancellation: CancellationToken, + timeout: Duration, + ) -> Result { + validate_canonical_root(&canonical_root).await?; + let root_url = directory_url(&canonical_root)?; + let workspace_folders = workspace_folders(&profile, &canonical_root, &layout).await?; + let router = ServerRequestRouter::new(ServerRequestRouterConfig::new( + workspace_folders.clone(), + WorkspaceSettings::new(profile.workspace_settings(&canonical_root, &layout)), + )); + let process = + LspProcess::spawn(profile.command(), &canonical_root, router).map_err(|source| { + LanguageRuntimeError::Process { + operation: "start", + source, + } + })?; + let client = process.client(); + let notifications = + client + .take_notifications() + .ok_or_else(|| LanguageRuntimeError::Client { + operation: "take_notifications", + source: LspClientError::Protocol { + message: "language server notification stream is already owned".to_owned(), + }, + })?; + let diagnostic_state = Arc::new(Mutex::new(DiagnosticRuntimeState::default())); + let diagnostic_updates = Arc::new(Mutex::new(())); + let notification_task = tokio::spawn(consume_notifications( + notifications, + canonical_root.clone(), + Arc::clone(&documents), + Arc::clone(&diagnostics), + Arc::clone(&diagnostic_state), + Arc::clone(&diagnostic_updates), + )); + + let initialization_options = profile.initialization_options(&canonical_root, &layout); + let initialization_options = + (!initialization_options.is_null()).then_some(initialization_options); + let config = InitializeConfig::new( + root_url, + workspace_folders, + initialization_options, + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION"), + ); + let initialized = match initialize(&client, &config, cancellation.clone(), timeout).await { + Ok(initialized) => initialized, + Err(source) => { + let _ = process.shutdown(timeout, timeout).await; + notification_task.abort(); + let _ = notification_task.await; + return if cancellation.is_cancelled() { + Err(LanguageRuntimeError::Cancelled) + } else { + Err(LanguageRuntimeError::Client { + operation: "initialize", + source, + }) + }; + } + }; + + let runtime = Self { + profile, + canonical_root, + layout, + process, + initialized, + documents, + diagnostics, + diagnostic_state, + diagnostic_updates, + open_documents: Mutex::new(HashSet::new()), + document_sync: Mutex::new(()), + navigation_revisions: Mutex::new(HashMap::new()), + navigation_stabilization: Mutex::new(()), + notification_task: Mutex::new(Some(notification_task)), + timeout, + }; + if let Err(error) = runtime + .wait_for_settle(runtime.profile.initialization_settle_delay(), &cancellation) + .await + { + let _ = runtime.shutdown().await; + return Err(error); + } + Ok(runtime) + } + + pub(crate) fn supports_path(&self, path: &WorkspacePath) -> bool { + self.profile.supports_path(Path::new(path.as_str())) + } + + pub(crate) fn capabilities(&self) -> CodeIntelligenceCapabilities { + self.initialized.capabilities + } + + pub(crate) async fn document_symbols( + &self, + path: &WorkspacePath, + saved_content: &str, + cancellation: CancellationToken, + ) -> Result, LanguageRuntimeError> { + self.require_path(path)?; + self.require_capability( + self.initialized.capabilities.document_symbols, + "document_symbols", + )?; + let (uri, snapshot) = self + .sync_saved_document(path, saved_content, &cancellation) + .await?; + let params = DocumentSymbolParams { + text_document: TextDocumentIdentifier::new(uri), + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }; + let response: Option = self + .request_typed( + "document_symbols", + "textDocument/documentSymbol", + params, + cancellation.clone(), + ) + .await?; + ensure_not_cancelled(&cancellation)?; + let items = match response { + Some(response) => mapping::map_document_symbol_response(&self.canonical_root, response) + .await + .map_err(|source| LanguageRuntimeError::Mapping { + operation: "document_symbols", + source, + })?, + None => Vec::new(), + }; + ensure_not_cancelled(&cancellation)?; + let (items, truncated) = bound_document_symbols(items, MAX_DOCUMENT_SYMBOLS); + Ok(self.document_result(path, snapshot, items, truncated).await) + } + + pub(crate) async fn search_symbols( + &self, + query: &str, + limit: usize, + cancellation: CancellationToken, + ) -> Result, LanguageRuntimeError> { + self.require_capability( + self.initialized.capabilities.workspace_symbols, + "search_symbols", + )?; + let params = WorkspaceSymbolParams { + query: query.to_owned(), + partial_result_params: PartialResultParams::default(), + work_done_progress_params: WorkDoneProgressParams::default(), + }; + let response: Option = self + .request_typed( + "search_symbols", + "workspace/symbol", + params, + cancellation.clone(), + ) + .await?; + ensure_not_cancelled(&cancellation)?; + let items = match response { + Some(response) => { + mapping::map_workspace_symbol_response(&self.canonical_root, response) + .await + .map_err(|source| LanguageRuntimeError::Mapping { + operation: "search_symbols", + source, + })? + } + None => Vec::new(), + }; + ensure_not_cancelled(&cancellation)?; + let (items, truncated) = bound_items(items, limit); + Ok(CodeQueryResult { + items, + truncated, + workspace_revision: self.layout.workspace_revision, + document: None, + }) + } + + pub(crate) async fn navigate( + &self, + kind: NavigationKind, + path: &WorkspacePath, + position: CodePosition, + saved_content: &str, + cancellation: CancellationToken, + ) -> Result, LanguageRuntimeError> { + self.require_path(path)?; + self.require_navigation(kind)?; + if !valid_utf16_position(saved_content, position) { + return Err(LanguageRuntimeError::InvalidPosition { + path: path.clone(), + position, + }); + } + let (uri, snapshot) = self + .sync_saved_document(path, saved_content, &cancellation) + .await?; + let items = if self + .navigation_revision_is_settled(path, snapshot.revision) + .await + { + self.navigation_items(kind, &uri, position, &cancellation) + .await? + } else { + let lock = self.navigation_stabilization.lock(); + tokio::pin!(lock); + let _stabilization = tokio::select! { + biased; + _ = cancellation.cancelled() => return Err(LanguageRuntimeError::Cancelled), + guard = &mut lock => guard, + }; + if self + .navigation_revision_is_settled(path, snapshot.revision) + .await + { + self.navigation_items(kind, &uri, position, &cancellation) + .await? + } else { + // The protocol handshake can finish before a server has made + // its first saved document visible to cross-file navigation. + // Always discard one warmup response instead of guessing from + // whether it happens to be empty or how many locations it has. + let _ = self + .navigation_items(kind, &uri, position, &cancellation) + .await?; + self.wait_for_settle(self.profile.navigation_settle_delay(), &cancellation) + .await?; + let items = self + .navigation_items(kind, &uri, position, &cancellation) + .await?; + self.navigation_revisions + .lock() + .await + .insert(path.clone(), snapshot.revision); + items + } + }; + ensure_not_cancelled(&cancellation)?; + let (items, truncated) = bound_items(items, MAX_NAVIGATION_RESULTS); + Ok(self.document_result(path, snapshot, items, truncated).await) + } + + async fn navigation_items( + &self, + kind: NavigationKind, + uri: &lsp_types::Uri, + position: CodePosition, + cancellation: &CancellationToken, + ) -> Result, LanguageRuntimeError> { + let text_position = TextDocumentPositionParams::new( + TextDocumentIdentifier::new(uri.clone()), + Position::new(position.line, position.character), + ); + match kind { + NavigationKind::References => { + let response: Option> = self + .request_typed( + "references", + "textDocument/references", + ReferenceParams { + text_document_position: text_position, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: ReferenceContext { + include_declaration: true, + }, + }, + cancellation.clone(), + ) + .await?; + let mut mapped = Vec::new(); + for location in response.unwrap_or_default() { + ensure_not_cancelled(cancellation)?; + mapped.push( + mapping::map_location(&self.canonical_root, location) + .await + .map_err(|source| LanguageRuntimeError::Mapping { + operation: "references", + source, + })?, + ); + } + Ok(mapped) + } + NavigationKind::Definition + | NavigationKind::Declaration + | NavigationKind::Implementations => { + let (operation, method) = navigation_request(kind); + let response: Option = self + .request_typed( + operation, + method, + GotoDefinitionParams { + text_document_position_params: text_position, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }, + cancellation.clone(), + ) + .await?; + mapping::map_definition_response(&self.canonical_root, response) + .await + .map_err(|source| LanguageRuntimeError::Mapping { operation, source }) + } + } + } + + async fn navigation_revision_is_settled( + &self, + path: &WorkspacePath, + revision: DocumentRevision, + ) -> bool { + self.navigation_revisions.lock().await.get(path) == Some(&revision) + } + + async fn wait_for_settle( + &self, + delay: Duration, + cancellation: &CancellationToken, + ) -> Result<(), LanguageRuntimeError> { + ensure_not_cancelled(cancellation)?; + let delay = delay.min(self.timeout); + if delay.is_zero() { + return Ok(()); + } + tokio::select! { + biased; + _ = cancellation.cancelled() => Err(LanguageRuntimeError::Cancelled), + _ = tokio::time::sleep(delay) => Ok(()), + } + } + + pub(crate) async fn diagnostics( + &self, + path: &WorkspacePath, + saved_content: &str, + cancellation: CancellationToken, + ) -> Result, LanguageRuntimeError> { + self.require_path(path)?; + self.require_capability(self.initialized.capabilities.diagnostics, "diagnostics")?; + let (uri, snapshot) = self + .sync_saved_document(path, saved_content, &cancellation) + .await?; + if self.initialized.supports_pull_diagnostics { + self.pull_diagnostics(path, uri, snapshot, cancellation) + .await + } else { + self.published_diagnostics(path, snapshot, &cancellation) + .await + } + } + + pub(crate) async fn shutdown(&self) -> Result<(), LanguageRuntimeError> { + let paths = { + let _sync = self.document_sync.lock().await; + self.open_documents + .lock() + .await + .iter() + .cloned() + .collect::>() + }; + for path in paths { + let _ = self.invalidate_document(&path).await; + } + + let process_result = self.process.shutdown(self.timeout, self.timeout).await; + if let Some(mut task) = self.notification_task.lock().await.take() { + if tokio::time::timeout(self.timeout, &mut task).await.is_err() { + task.abort(); + let _ = task.await; + } + } + process_result + .map(|_| ()) + .map_err(|source| LanguageRuntimeError::Process { + operation: "shutdown", + source, + }) + } + + pub(crate) fn unavailable_message(&self) -> Option { + use super::lsp::process::LspProcessState; + + let state = self.process.state(); + let message = match state { + LspProcessState::Running if !self.process.client().is_closed() => return None, + LspProcessState::Running => "the protocol connection closed unexpectedly".to_owned(), + LspProcessState::Exited { code, forced } => format!( + "the language server exited{}{}", + code.map(|code| format!(" with code {code}")) + .unwrap_or_default(), + if forced { + " after forced termination" + } else { + "" + } + ), + LspProcessState::Failed { message } => { + format!("the language server process failed: {message}") + } + }; + let stderr = self.process.stderr_snapshot(); + Some(if stderr.trim().is_empty() { + message + } else { + format!("{message}; stderr: {}", stderr.trim()) + }) + } + + pub(crate) fn subscribe_process_state( + &self, + ) -> watch::Receiver { + self.process.subscribe_state() + } +} + +/// Bound a hierarchical document outline by total node count while retaining +/// the server's preorder, each retained node's ancestors, and child nesting. +fn bound_document_symbols( + symbols: Vec, + limit: usize, +) -> (Vec, bool) { + fn retain_preorder( + symbols: Vec, + remaining: &mut usize, + truncated: &mut bool, + ) -> Vec { + let mut retained = Vec::new(); + for mut symbol in symbols { + if *remaining == 0 { + *truncated = true; + break; + } + *remaining -= 1; + symbol.children = retain_preorder(symbol.children, remaining, truncated); + retained.push(symbol); + } + retained + } + + let mut remaining = limit; + let mut truncated = false; + let symbols = retain_preorder(symbols, &mut remaining, &mut truncated); + (symbols, truncated) +} + +impl Drop for LanguageRuntime { + fn drop(&mut self) { + self.process.force_kill(); + if let Ok(mut task) = self.notification_task.try_lock() { + if let Some(task) = task.take() { + task.abort(); + } + } + } +} diff --git a/core/src/code_intelligence/language_runtime/diagnostic_runtime.rs b/core/src/code_intelligence/language_runtime/diagnostic_runtime.rs new file mode 100644 index 00000000..01596b54 --- /dev/null +++ b/core/src/code_intelligence/language_runtime/diagnostic_runtime.rs @@ -0,0 +1,243 @@ +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, + sync::Arc, +}; + +use lsp_types::{ + DocumentDiagnosticParams, DocumentDiagnosticReport, DocumentDiagnosticReportResult, + PartialResultParams, PublishDiagnosticsParams, TextDocumentIdentifier, Uri, + WorkDoneProgressParams, +}; +use tokio::sync::{mpsc, Mutex}; +use tokio_util::sync::CancellationToken; + +use super::super::{ + diagnostics::{DiagnosticsLookup, DiagnosticsStore}, + document_store::DocumentStore, + lsp::{client::LspClientError, mapping, message::JsonRpcNotification}, + CodeDiagnostic, CodeQueryResult, DocumentSnapshot, +}; +use super::{ + protocol::{bound_items, ensure_not_cancelled}, + LanguageRuntime, LanguageRuntimeError, +}; +use crate::workspace::WorkspacePath; + +const MAX_DIAGNOSTICS_PER_DOCUMENT: usize = 1_000; + +#[derive(Debug, Default)] +pub(super) struct DiagnosticRuntimeState { + result_ids: HashMap, + truncated: HashSet, +} + +impl LanguageRuntime { + pub(super) async fn pull_diagnostics( + &self, + path: &WorkspacePath, + uri: Uri, + snapshot: DocumentSnapshot, + cancellation: CancellationToken, + ) -> Result, LanguageRuntimeError> { + let previous_result_id = self + .diagnostic_state + .lock() + .await + .result_ids + .get(path) + .cloned(); + let response: DocumentDiagnosticReportResult = self + .request_typed( + "diagnostics", + "textDocument/diagnostic", + DocumentDiagnosticParams { + text_document: TextDocumentIdentifier::new(uri.clone()), + identifier: None, + previous_result_id: previous_result_id.clone(), + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }, + cancellation.clone(), + ) + .await?; + ensure_not_cancelled(&cancellation)?; + + match response { + DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(report)) => { + let full = report.full_document_diagnostic_report; + let items = mapping::map_diagnostics(&self.canonical_root, &uri, full.items) + .await + .map_err(|source| LanguageRuntimeError::Mapping { + operation: "diagnostics", + source, + })?; + ensure_not_cancelled(&cancellation)?; + let (items, truncated) = bound_items(items, MAX_DIAGNOSTICS_PER_DOCUMENT); + let snapshot = self.documents.complete_query(path, snapshot).await; + if !snapshot.stale { + let _update = self.diagnostic_updates.lock().await; + self.diagnostics + .publish(path, items.clone(), Some(snapshot.revision)) + .await; + let mut state = self.diagnostic_state.lock().await; + match full.result_id { + Some(result_id) => { + state.result_ids.insert(path.clone(), result_id); + } + None => { + state.result_ids.remove(path); + } + } + set_truncated(&mut state, path, truncated); + } + Ok(self.query_result(items, truncated, Some(snapshot))) + } + DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Unchanged(report)) => { + if previous_result_id.is_none() { + return Err(LanguageRuntimeError::PendingDiagnostics { path: path.clone() }); + } + let result_id = report.unchanged_document_diagnostic_report.result_id; + let _update = self.diagnostic_updates.lock().await; + let (items, revision) = match self.diagnostics.lookup(path).await { + DiagnosticsLookup::NotReceived => { + return Err(LanguageRuntimeError::PendingDiagnostics { + path: path.clone(), + }); + } + DiagnosticsLookup::Received { + diagnostics, + revision, + } => (diagnostics, revision), + }; + if revision.is_some_and(|revision| revision != snapshot.revision) { + return Err(LanguageRuntimeError::PendingDiagnostics { path: path.clone() }); + } + let mut state = self.diagnostic_state.lock().await; + state.result_ids.insert(path.clone(), result_id); + let truncated = state.truncated.contains(path); + drop(state); + drop(_update); + let snapshot = self.documents.complete_query(path, snapshot).await; + Ok(self.query_result(items, truncated, Some(snapshot))) + } + DocumentDiagnosticReportResult::Partial(_) => Err(LanguageRuntimeError::Client { + operation: "diagnostics", + source: LspClientError::Protocol { + message: "server returned a partial diagnostics object as the final response" + .to_owned(), + }, + }), + } + } + + pub(super) async fn published_diagnostics( + &self, + path: &WorkspacePath, + snapshot: DocumentSnapshot, + cancellation: &CancellationToken, + ) -> Result, LanguageRuntimeError> { + ensure_not_cancelled(cancellation)?; + let wait_for_publish = async { + loop { + // Register before lookup so a publish between lookup and wait + // cannot be lost. The update lock is released before waiting. + let notified = self.diagnostics.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + let received = { + let _update = self.diagnostic_updates.lock().await; + match self.diagnostics.lookup(path).await { + DiagnosticsLookup::NotReceived => None, + DiagnosticsLookup::Received { + revision: Some(revision), + .. + } if revision != snapshot.revision => None, + DiagnosticsLookup::Received { diagnostics, .. } => { + let truncated = + self.diagnostic_state.lock().await.truncated.contains(path); + Some((diagnostics, truncated)) + } + } + }; + if let Some(received) = received { + return received; + } + notified.await; + } + }; + let (items, truncated) = tokio::select! { + _ = cancellation.cancelled() => return Err(LanguageRuntimeError::Cancelled), + result = tokio::time::timeout(self.timeout, wait_for_publish) => { + result.map_err(|_| LanguageRuntimeError::Timeout { + operation: "diagnostics", + duration: self.timeout, + })? + } + }; + ensure_not_cancelled(cancellation)?; + let snapshot = self.documents.complete_query(path, snapshot).await; + Ok(self.query_result(items, truncated, Some(snapshot))) + } + + pub(super) async fn clear_diagnostics(&self, path: &WorkspacePath) { + let _update = self.diagnostic_updates.lock().await; + self.diagnostics.invalidate(path).await; + let mut state = self.diagnostic_state.lock().await; + state.result_ids.remove(path); + state.truncated.remove(path); + } +} + +pub(super) async fn consume_notifications( + mut notifications: mpsc::Receiver, + canonical_root: PathBuf, + documents: Arc, + diagnostics: Arc, + diagnostic_state: Arc>, + diagnostic_updates: Arc>, +) { + while let Some(notification) = notifications.recv().await { + if notification.method != "textDocument/publishDiagnostics" { + continue; + } + let Some(params) = notification.params else { + continue; + }; + let Ok(params) = serde_json::from_value::(params) else { + continue; + }; + let Ok(mapped) = + mapping::map_diagnostics(&canonical_root, ¶ms.uri, params.diagnostics).await + else { + continue; + }; + let path = match mapped.first() { + Some(diagnostic) => diagnostic.location.path.clone(), + None => match mapping::file_uri_to_workspace_path(&canonical_root, ¶ms.uri).await { + Ok(path) => path, + Err(_) => continue, + }, + }; + let (mapped, truncated) = bound_items(mapped, MAX_DIAGNOSTICS_PER_DOCUMENT); + let _update = diagnostic_updates.lock().await; + let revision = match params.version { + Some(version) => match documents.revision_for_lsp_version(&path, version).await { + Some(revision) => Some(revision), + None => continue, + }, + None => None, + }; + diagnostics.publish(&path, mapped, revision).await; + let mut state = diagnostic_state.lock().await; + set_truncated(&mut state, &path, truncated); + } +} + +fn set_truncated(state: &mut DiagnosticRuntimeState, path: &WorkspacePath, truncated: bool) { + if truncated { + state.truncated.insert(path.clone()); + } else { + state.truncated.remove(path); + } +} diff --git a/core/src/code_intelligence/language_runtime/integration_tests.rs b/core/src/code_intelligence/language_runtime/integration_tests.rs new file mode 100644 index 00000000..76ecac5f --- /dev/null +++ b/core/src/code_intelligence/language_runtime/integration_tests.rs @@ -0,0 +1,481 @@ +use std::{path::Path, process::Command, sync::Arc, time::Duration}; + +use tokio_util::sync::CancellationToken; + +use super::LanguageRuntime; +use crate::{ + code_intelligence::{ + diagnostics::DiagnosticsStore, document_store::DocumentStore, + language_profile::LanguageServerProfile, project_layout::ProjectLayoutResolver, + CodePosition, NavigationKind, + }, + workspace::{ + LocalWorkspaceFile, LocalWorkspaceFileStatus, LocalWorkspaceManifestSnapshot, + WorkspaceFileChange, WorkspaceFileChangeKind, WorkspacePath, + }, +}; + +fn manifest_file(path: &str) -> LocalWorkspaceFile { + LocalWorkspaceFile { + path: path.to_owned(), + size: 1, + modified_ms: Some(1), + language: None, + status: LocalWorkspaceFileStatus::Tracked, + binary: false, + generated: false, + } +} + +fn compile_fake_server(output: &Path) { + let source = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/code_intelligence_fake_lsp.rs"); + let result = Command::new("rustc") + .arg("--edition=2021") + .arg(source) + .arg("-o") + .arg(output) + .output() + .expect("rustc must be available while Cargo tests are running"); + assert!( + result.status.success(), + "failed to compile fake language server: {}", + String::from_utf8_lossy(&result.stderr) + ); +} + +#[tokio::test] +async fn saved_document_runtime_completes_a_real_process_protocol_lifecycle() { + let workspace = tempfile::tempdir().unwrap(); + let source_dir = workspace.path().join("src"); + std::fs::create_dir(&source_dir).unwrap(); + std::fs::write( + workspace.path().join("Cargo.toml"), + "[package]\nname='fixture'\n", + ) + .unwrap(); + let source_path = source_dir.join("lib.rs"); + let first_saved = "pub fn answer() -> u32 { 42 }\n"; + std::fs::write(&source_path, first_saved).unwrap(); + let canonical_root = std::fs::canonicalize(workspace.path()).unwrap(); + + let server_dir = tempfile::tempdir().unwrap(); + let server = server_dir.path().join(if cfg!(windows) { + "code-intelligence-fake-lsp.exe" + } else { + "code-intelligence-fake-lsp" + }); + compile_fake_server(&server); + + let snapshot = LocalWorkspaceManifestSnapshot { + version: 7, + root: canonical_root.clone(), + files: vec![manifest_file("Cargo.toml"), manifest_file("src/lib.rs")], + scanned_at_ms: 1, + }; + let layout = ProjectLayoutResolver::resolve(&snapshot); + let runtime = LanguageRuntime::start( + LanguageServerProfile::rust(&server), + canonical_root, + layout, + Arc::new(DocumentStore::new(8)), + Arc::new(DiagnosticsStore::new(8)), + CancellationToken::new(), + Duration::from_secs(5), + ) + .await + .unwrap(); + let path = WorkspacePath::from_normalized("src/lib.rs"); + + let symbols = runtime + .document_symbols(&path, first_saved, CancellationToken::new()) + .await + .unwrap(); + assert_eq!(symbols.items.len(), 1); + assert_eq!(symbols.items[0].name, "answer"); + assert_eq!(symbols.workspace_revision, 7); + assert!(symbols.document.is_some()); + + let workspace_symbols = runtime + .search_symbols("answer", 10, CancellationToken::new()) + .await + .unwrap(); + assert_eq!(workspace_symbols.items[0].location.path, path); + + let definition = runtime + .navigate( + NavigationKind::Definition, + &path, + CodePosition::new(0, 7), + first_saved, + CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(definition.items[0].path, path); + + let diagnostics = runtime + .diagnostics(&path, first_saved, CancellationToken::new()) + .await + .unwrap(); + assert_eq!(diagnostics.items.len(), 1); + assert_eq!(diagnostics.items[0].message, "fixture warning"); + + let second_saved = "pub fn answer() -> u32 { 43 }\n"; + std::fs::write(&source_path, second_saved).unwrap(); + let changed = runtime + .document_symbols(&path, second_saved, CancellationToken::new()) + .await + .unwrap(); + assert!( + changed.document.unwrap().revision > symbols.document.unwrap().revision, + "a newly saved body must advance the document revision" + ); + + runtime + .notify_file_changes(&[WorkspaceFileChange { + path: path.clone(), + kind: WorkspaceFileChangeKind::Changed, + }]) + .await + .unwrap(); + runtime.shutdown().await.unwrap(); + + let protocol_log = std::fs::read_to_string(server.with_extension("log")).unwrap(); + for method in [ + "initialize", + "initialized", + "textDocument/didOpen", + "textDocument/didSave", + "textDocument/didChange", + "textDocument/documentSymbol", + "workspace/symbol", + "textDocument/definition", + "textDocument/diagnostic", + "textDocument/didClose", + "workspace/didChangeWatchedFiles", + "shutdown", + "exit", + ] { + assert!( + protocol_log.contains(&format!("\"method\":\"{method}\"")), + "protocol log did not contain {method}: {protocol_log}" + ); + } +} + +#[tokio::test] +async fn publish_only_diagnostics_wait_for_the_current_document_revision() { + let workspace = tempfile::tempdir().unwrap(); + let source_dir = workspace.path().join("src"); + std::fs::create_dir(&source_dir).unwrap(); + std::fs::write( + workspace.path().join("Cargo.toml"), + "[package]\nname='fixture'\n", + ) + .unwrap(); + let saved = "pub fn answer() -> u32 { 42 }\n"; + std::fs::write(source_dir.join("lib.rs"), saved).unwrap(); + let canonical_root = std::fs::canonicalize(workspace.path()).unwrap(); + + let server_dir = tempfile::tempdir().unwrap(); + let server = server_dir.path().join(if cfg!(windows) { + "push-diagnostics-fake-lsp.exe" + } else { + "push-diagnostics-fake-lsp" + }); + compile_fake_server(&server); + let snapshot = LocalWorkspaceManifestSnapshot { + version: 1, + root: canonical_root.clone(), + files: vec![manifest_file("Cargo.toml"), manifest_file("src/lib.rs")], + scanned_at_ms: 1, + }; + let runtime = LanguageRuntime::start( + LanguageServerProfile::rust(&server), + canonical_root, + ProjectLayoutResolver::resolve(&snapshot), + Arc::new(DocumentStore::new(1)), + Arc::new(DiagnosticsStore::new(1)), + CancellationToken::new(), + Duration::from_secs(5), + ) + .await + .unwrap(); + let path = WorkspacePath::from_normalized("src/lib.rs"); + + let query = runtime + .diagnostics(&path, saved, CancellationToken::new()) + .await; + runtime.shutdown().await.unwrap(); + + let result = query.unwrap(); + assert_eq!(result.items.len(), 1); + assert_eq!(result.items[0].message, "fixture push warning"); + assert_eq!(result.document.unwrap().revision.value(), 1); + let protocol_log = std::fs::read_to_string(server.with_extension("log")).unwrap(); + assert!(protocol_log.contains("\"method\":\"textDocument/didOpen\"")); + assert!(!protocol_log.contains("\"method\":\"textDocument/diagnostic\"")); +} + +#[tokio::test] +async fn initialization_settle_delays_readiness_and_honors_cancellation() { + let workspace = tempfile::tempdir().unwrap(); + std::fs::write(workspace.path().join("Cargo.toml"), "[workspace]\n").unwrap(); + let canonical_root = std::fs::canonicalize(workspace.path()).unwrap(); + let server_dir = tempfile::tempdir().unwrap(); + let server = server_dir.path().join(if cfg!(windows) { + "code-intelligence-initialization-settle-lsp.exe" + } else { + "code-intelligence-initialization-settle-lsp" + }); + compile_fake_server(&server); + let snapshot = LocalWorkspaceManifestSnapshot { + version: 1, + root: canonical_root.clone(), + files: vec![manifest_file("Cargo.toml"), manifest_file("src/lib.rs")], + scanned_at_ms: 1, + }; + let layout = ProjectLayoutResolver::resolve(&snapshot); + + let started = tokio::time::Instant::now(); + let runtime = LanguageRuntime::start( + LanguageServerProfile::rust(&server) + .with_settle_delays(Duration::from_millis(75), Duration::ZERO), + canonical_root.clone(), + layout.clone(), + Arc::new(DocumentStore::new(1)), + Arc::new(DiagnosticsStore::new(1)), + CancellationToken::new(), + Duration::from_secs(5), + ) + .await + .unwrap(); + assert!(started.elapsed() >= Duration::from_millis(60)); + runtime.shutdown().await.unwrap(); + + let cancellation = CancellationToken::new(); + let trigger = cancellation.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + trigger.cancel(); + }); + let cancelled = tokio::time::timeout( + Duration::from_secs(1), + LanguageRuntime::start( + LanguageServerProfile::rust(&server) + .with_settle_delays(Duration::from_secs(5), Duration::ZERO), + canonical_root, + layout, + Arc::new(DocumentStore::new(1)), + Arc::new(DiagnosticsStore::new(1)), + cancellation, + Duration::from_secs(5), + ), + ) + .await + .expect("cancellation must interrupt initialization settling"); + assert!(matches!( + cancelled, + Err(super::LanguageRuntimeError::Cancelled) + )); +} + +#[tokio::test] +async fn first_navigation_waits_for_empty_and_partial_cold_results_to_settle() { + for mode in ["cold-empty", "cold-partial"] { + let workspace = tempfile::tempdir().unwrap(); + let source_dir = workspace.path().join("src"); + std::fs::create_dir(&source_dir).unwrap(); + std::fs::write( + workspace.path().join("Cargo.toml"), + "[package]\nname='fixture'\n", + ) + .unwrap(); + let saved = "pub fn answer() -> u32 { 42 }\n"; + std::fs::write(source_dir.join("lib.rs"), saved).unwrap(); + let canonical_root = std::fs::canonicalize(workspace.path()).unwrap(); + + let server_dir = tempfile::tempdir().unwrap(); + let server = server_dir.path().join(if cfg!(windows) { + format!("code-intelligence-{mode}-lsp.exe") + } else { + format!("code-intelligence-{mode}-lsp") + }); + compile_fake_server(&server); + let snapshot = LocalWorkspaceManifestSnapshot { + version: 1, + root: canonical_root.clone(), + files: vec![manifest_file("Cargo.toml"), manifest_file("src/lib.rs")], + scanned_at_ms: 1, + }; + let profile = LanguageServerProfile::rust(&server) + .with_settle_delays(Duration::ZERO, Duration::from_millis(25)); + let runtime = LanguageRuntime::start( + profile, + canonical_root, + ProjectLayoutResolver::resolve(&snapshot), + Arc::new(DocumentStore::new(1)), + Arc::new(DiagnosticsStore::new(1)), + CancellationToken::new(), + Duration::from_secs(5), + ) + .await + .unwrap(); + let path = WorkspacePath::from_normalized("src/lib.rs"); + + let first = runtime + .navigate( + NavigationKind::References, + &path, + CodePosition::new(0, 7), + saved, + CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(first.items.len(), 3, "{mode} did not settle"); + + let second = runtime + .navigate( + NavigationKind::References, + &path, + CodePosition::new(0, 7), + saved, + CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(second.items.len(), 3); + runtime.shutdown().await.unwrap(); + + let protocol_log = std::fs::read_to_string(server.with_extension("log")).unwrap(); + assert_eq!( + protocol_log + .matches("\"method\":\"textDocument/references\"") + .count(), + 3, + "{mode} should retry only the first saved revision" + ); + } +} + +#[tokio::test] +async fn navigation_stabilization_is_cancellable() { + let workspace = tempfile::tempdir().unwrap(); + let source_dir = workspace.path().join("src"); + std::fs::create_dir(&source_dir).unwrap(); + std::fs::write( + workspace.path().join("Cargo.toml"), + "[package]\nname='fixture'\n", + ) + .unwrap(); + let saved = "pub fn answer() -> u32 { 42 }\n"; + std::fs::write(source_dir.join("lib.rs"), saved).unwrap(); + let canonical_root = std::fs::canonicalize(workspace.path()).unwrap(); + + let server_dir = tempfile::tempdir().unwrap(); + let server = server_dir.path().join(if cfg!(windows) { + "code-intelligence-cancellable-lsp.exe" + } else { + "code-intelligence-cancellable-lsp" + }); + compile_fake_server(&server); + let snapshot = LocalWorkspaceManifestSnapshot { + version: 1, + root: canonical_root.clone(), + files: vec![manifest_file("Cargo.toml"), manifest_file("src/lib.rs")], + scanned_at_ms: 1, + }; + let profile = LanguageServerProfile::rust(&server) + .with_settle_delays(Duration::ZERO, Duration::from_secs(5)); + let runtime = LanguageRuntime::start( + profile, + canonical_root, + ProjectLayoutResolver::resolve(&snapshot), + Arc::new(DocumentStore::new(1)), + Arc::new(DiagnosticsStore::new(1)), + CancellationToken::new(), + Duration::from_secs(5), + ) + .await + .unwrap(); + let path = WorkspacePath::from_normalized("src/lib.rs"); + let cancellation = CancellationToken::new(); + let trigger = cancellation.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + trigger.cancel(); + }); + + let result = tokio::time::timeout( + Duration::from_secs(1), + runtime.navigate( + NavigationKind::References, + &path, + CodePosition::new(0, 7), + saved, + cancellation, + ), + ) + .await + .expect("cancellation must interrupt the settle delay"); + assert!(matches!( + result, + Err(super::LanguageRuntimeError::Cancelled) + )); + runtime.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn unexpected_process_exit_exposes_state_and_bounded_stderr() { + let workspace = tempfile::tempdir().unwrap(); + std::fs::write(workspace.path().join("Cargo.toml"), "[workspace]\n").unwrap(); + let canonical_root = std::fs::canonicalize(workspace.path()).unwrap(); + let server_dir = tempfile::tempdir().unwrap(); + let server = server_dir.path().join(if cfg!(windows) { + "code-intelligence-crashing-lsp.exe" + } else { + "code-intelligence-crashing-lsp" + }); + compile_fake_server(&server); + let snapshot = LocalWorkspaceManifestSnapshot { + version: 1, + root: canonical_root.clone(), + files: vec![manifest_file("Cargo.toml"), manifest_file("src/lib.rs")], + scanned_at_ms: 1, + }; + let runtime = LanguageRuntime::start( + LanguageServerProfile::rust(&server), + canonical_root, + ProjectLayoutResolver::resolve(&snapshot), + Arc::new(DocumentStore::new(1)), + Arc::new(DiagnosticsStore::new(1)), + CancellationToken::new(), + Duration::from_secs(5), + ) + .await + .unwrap(); + + assert!(runtime + .search_symbols("terminate-process", 1, CancellationToken::new()) + .await + .is_err()); + let message = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(message) = runtime.unavailable_message().filter(|message| { + message.contains("code 12") && message.contains("terminated unexpectedly") + }) { + break message; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("process state and stderr should settle"); + assert!( + message.contains("code 12"), + "unexpected health message: {message}" + ); + runtime.shutdown().await.unwrap(); +} diff --git a/core/src/code_intelligence/language_runtime/paths.rs b/core/src/code_intelligence/language_runtime/paths.rs new file mode 100644 index 00000000..fd30bf88 --- /dev/null +++ b/core/src/code_intelligence/language_runtime/paths.rs @@ -0,0 +1,228 @@ +use std::{ + path::{Component, Path}, + str::FromStr, +}; + +use lsp_types::Uri; +use url::Url; + +use super::super::{ + language_profile::LanguageServerProfile, lsp::router::WorkspaceFolder, + project_layout::ProjectLayout, CodePosition, +}; +use super::LanguageRuntimeError; +use crate::workspace::WorkspacePath; + +pub(super) async fn validate_canonical_root(root: &Path) -> Result<(), LanguageRuntimeError> { + if !root.is_absolute() { + return Err(LanguageRuntimeError::InvalidRoot { + root: root.to_path_buf(), + message: "the path is not absolute".to_owned(), + }); + } + let resolved = + tokio::fs::canonicalize(root) + .await + .map_err(|error| LanguageRuntimeError::InvalidRoot { + root: root.to_path_buf(), + message: format!("the path cannot be resolved: {error}"), + })?; + if resolved != root { + return Err(LanguageRuntimeError::InvalidRoot { + root: root.to_path_buf(), + message: format!("the canonical path is {resolved:?}"), + }); + } + let metadata = + tokio::fs::metadata(root) + .await + .map_err(|error| LanguageRuntimeError::InvalidRoot { + root: root.to_path_buf(), + message: format!("the path metadata cannot be read: {error}"), + })?; + if !metadata.is_dir() { + return Err(LanguageRuntimeError::InvalidRoot { + root: root.to_path_buf(), + message: "the path is not a directory".to_owned(), + }); + } + Ok(()) +} + +pub(super) fn directory_url(path: &Path) -> Result { + Url::from_directory_path(path).map_err(|()| LanguageRuntimeError::InvalidRoot { + root: path.to_path_buf(), + message: "the path cannot be represented as a file URI".to_owned(), + }) +} + +pub(super) async fn workspace_folders( + profile: &LanguageServerProfile, + canonical_root: &Path, + layout: &ProjectLayout, +) -> Result, LanguageRuntimeError> { + let mut folders = Vec::new(); + for root in profile.project_roots(layout) { + validate_workspace_path_or_root(&root)?; + let directory = if root.is_root() { + canonical_root.to_path_buf() + } else { + canonical_root.join(root.as_str()) + }; + let resolved = tokio::fs::canonicalize(&directory).await.map_err(|error| { + LanguageRuntimeError::InvalidPath { + path: root.clone(), + message: format!("the project root cannot be resolved: {error}"), + } + })?; + if !resolved.starts_with(canonical_root) { + return Err(LanguageRuntimeError::InvalidPath { + path: root, + message: "the project root resolves outside the workspace".to_owned(), + }); + } + if !tokio::fs::metadata(&resolved) + .await + .map_err(|error| LanguageRuntimeError::InvalidPath { + path: root.clone(), + message: format!("the project root metadata cannot be read: {error}"), + })? + .is_dir() + { + return Err(LanguageRuntimeError::InvalidPath { + path: root, + message: "the project root is not a directory".to_owned(), + }); + } + let name = resolved + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("workspace"); + folders.push(WorkspaceFolder::new(directory_url(&resolved)?, name)); + } + Ok(folders) +} + +pub(super) fn validate_workspace_path(path: &WorkspacePath) -> Result<(), LanguageRuntimeError> { + if path.is_root() { + return Err(LanguageRuntimeError::InvalidPath { + path: path.clone(), + message: "a source document path cannot be the workspace root".to_owned(), + }); + } + validate_workspace_path_or_root(path) +} + +fn validate_workspace_path_or_root(path: &WorkspacePath) -> Result<(), LanguageRuntimeError> { + let raw = Path::new(path.as_str()); + let valid = path.is_root() + || (!raw.is_absolute() + && !path.as_str().is_empty() + && raw + .components() + .all(|component| matches!(component, Component::Normal(_)))); + if valid { + Ok(()) + } else { + Err(LanguageRuntimeError::InvalidPath { + path: path.clone(), + message: "the path is not a normalized workspace-relative path".to_owned(), + }) + } +} + +/// Construct a lexical URI for watcher events, including deleted paths. +pub(super) fn workspace_file_uri( + canonical_root: &Path, + path: &WorkspacePath, +) -> Result { + validate_workspace_path(path)?; + let file = canonical_root.join(path.as_str()); + let url = Url::from_file_path(&file).map_err(|()| LanguageRuntimeError::InvalidPath { + path: path.clone(), + message: "the path cannot be represented as a file URI".to_owned(), + })?; + Uri::from_str(url.as_str()).map_err(|error| LanguageRuntimeError::InvalidPath { + path: path.clone(), + message: format!("the generated file URI is invalid: {error}"), + }) +} + +/// Validate an existing saved source before exposing its lexical URI. +pub(super) async fn existing_workspace_file_uri( + canonical_root: &Path, + path: &WorkspacePath, +) -> Result { + validate_workspace_path(path)?; + let lexical_path = canonical_root.join(path.as_str()); + let resolved = tokio::fs::canonicalize(&lexical_path) + .await + .map_err(|error| LanguageRuntimeError::InvalidPath { + path: path.clone(), + message: format!("the saved source path cannot be resolved: {error}"), + })?; + if !resolved.starts_with(canonical_root) { + return Err(LanguageRuntimeError::InvalidPath { + path: path.clone(), + message: "the saved source path resolves outside the workspace".to_owned(), + }); + } + let metadata = tokio::fs::metadata(&resolved).await.map_err(|error| { + LanguageRuntimeError::InvalidPath { + path: path.clone(), + message: format!("the saved source metadata cannot be read: {error}"), + } + })?; + if !metadata.is_file() { + return Err(LanguageRuntimeError::InvalidPath { + path: path.clone(), + message: "the saved source path is not a regular file".to_owned(), + }); + } + workspace_file_uri(canonical_root, path) +} + +pub(super) fn valid_utf16_position(content: &str, position: CodePosition) -> bool { + let Some(line) = text_line(content, position.line) else { + return false; + }; + let target = u64::from(position.character); + let mut offset = 0_u64; + if target == 0 { + return true; + } + for character in line.chars() { + offset += character.len_utf16() as u64; + if offset == target { + return true; + } + if offset > target { + return false; + } + } + false +} + +fn text_line(content: &str, requested: u32) -> Option<&str> { + let bytes = content.as_bytes(); + let mut line = 0_u32; + let mut start = 0_usize; + let mut index = 0_usize; + while index < bytes.len() { + if !matches!(bytes[index], b'\n' | b'\r') { + index += 1; + continue; + } + if line == requested { + return Some(&content[start..index]); + } + if bytes[index] == b'\r' && bytes.get(index + 1) == Some(&b'\n') { + index += 1; + } + index += 1; + start = index; + line = line.checked_add(1)?; + } + (line == requested).then_some(&content[start..]) +} diff --git a/core/src/code_intelligence/language_runtime/protocol.rs b/core/src/code_intelligence/language_runtime/protocol.rs new file mode 100644 index 00000000..0992dc78 --- /dev/null +++ b/core/src/code_intelligence/language_runtime/protocol.rs @@ -0,0 +1,432 @@ +use std::path::Path; + +use lsp_types::{ + DidChangeTextDocumentParams, DidChangeWatchedFilesParams, DidCloseTextDocumentParams, + DidOpenTextDocumentParams, DidSaveTextDocumentParams, FileChangeType, FileEvent, + TextDocumentContentChangeEvent, TextDocumentIdentifier, TextDocumentItem, Uri, + VersionedTextDocumentIdentifier, +}; +use serde::{de::DeserializeOwned, Serialize}; +use tokio_util::sync::CancellationToken; + +use super::super::{ + document_store::DocumentObservationKind, lsp::initialize::ServerTextSyncMode, CodeQueryResult, + DocumentSnapshot, NavigationKind, +}; +use super::{ + paths::{existing_workspace_file_uri, validate_workspace_path, workspace_file_uri}, + LanguageRuntime, LanguageRuntimeError, +}; +use crate::{ + language::LanguageCatalog, + workspace::{WorkspaceFileChange, WorkspaceFileChangeKind, WorkspacePath}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum DocumentSyncStep { + Close, + Open, + Change, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct DocumentSyncPlan { + pub(super) steps: &'static [DocumentSyncStep], + pub(super) send_did_save: bool, +} + +impl LanguageRuntime { + pub(crate) async fn notify_file_changes( + &self, + changes: &[WorkspaceFileChange], + ) -> Result<(), LanguageRuntimeError> { + let mut events = Vec::with_capacity(changes.len()); + for change in changes { + validate_workspace_path(&change.path)?; + events.push(FileEvent::new( + workspace_file_uri(&self.canonical_root, &change.path)?, + file_change_type(change.kind), + )); + } + if events.is_empty() { + return Ok(()); + } + + let cancellation = CancellationToken::new(); + let _sync = self.document_sync.lock().await; + for change in changes { + self.invalidate_document_locked(&change.path, &cancellation) + .await?; + } + self.notify_typed( + "file_changes", + "workspace/didChangeWatchedFiles", + DidChangeWatchedFilesParams { changes: events }, + &cancellation, + ) + .await + } + + pub(crate) async fn invalidate_document( + &self, + path: &WorkspacePath, + ) -> Result<(), LanguageRuntimeError> { + validate_workspace_path(path)?; + let cancellation = CancellationToken::new(); + let _sync = self.document_sync.lock().await; + self.invalidate_document_locked(path, &cancellation).await + } + + pub(super) async fn sync_saved_document( + &self, + path: &WorkspacePath, + saved_content: &str, + cancellation: &CancellationToken, + ) -> Result<(Uri, DocumentSnapshot), LanguageRuntimeError> { + validate_workspace_path(path)?; + let uri = existing_workspace_file_uri(&self.canonical_root, path).await?; + let _sync = self.document_sync.lock().await; + let observation = self.documents.observe(path, saved_content).await?; + + if let Some(evicted) = &observation.evicted { + if self.open_documents.lock().await.contains(evicted) { + if let Err(error) = self.close_document(evicted, cancellation).await { + self.documents.invalidate(path).await; + self.open_documents.lock().await.remove(path); + return Err(error); + } + } + self.open_documents.lock().await.remove(evicted); + self.clear_diagnostics(evicted).await; + } + + let was_open = self.open_documents.lock().await.contains(path); + if matches!( + observation.kind, + DocumentObservationKind::Changed | DocumentObservationKind::Reopened + ) { + self.clear_diagnostics(path).await; + } + let plan = document_sync_plan( + self.initialized.text_sync_mode, + self.initialized.supports_open_close, + self.initialized.supports_did_save, + observation.kind, + was_open, + ); + for step in plan.steps { + let result = match step { + DocumentSyncStep::Close => self.close_document(path, cancellation).await, + DocumentSyncStep::Open => { + self.open_document( + path, + &uri, + observation.lsp_version, + saved_content, + cancellation, + ) + .await + } + DocumentSyncStep::Change => { + self.change_document(&uri, observation.lsp_version, saved_content, cancellation) + .await + } + }; + if let Err(error) = result { + self.documents.invalidate(path).await; + self.open_documents.lock().await.remove(path); + self.clear_diagnostics(path).await; + return Err(error); + } + match step { + DocumentSyncStep::Close => { + self.open_documents.lock().await.remove(path); + } + DocumentSyncStep::Open | DocumentSyncStep::Change => { + self.open_documents.lock().await.insert(path.clone()); + } + } + } + if plan.send_did_save { + self.save_document(&uri, cancellation).await?; + } + Ok((uri, observation.snapshot)) + } + + async fn open_document( + &self, + path: &WorkspacePath, + uri: &Uri, + version: i32, + content: &str, + cancellation: &CancellationToken, + ) -> Result<(), LanguageRuntimeError> { + let language_id = LanguageCatalog::id_for_path(Path::new(path.as_str())) + .ok_or_else(|| LanguageRuntimeError::UnsupportedPath { path: path.clone() })?; + self.notify_typed( + "did_open", + "textDocument/didOpen", + DidOpenTextDocumentParams { + text_document: TextDocumentItem::new( + uri.clone(), + language_id.to_owned(), + version, + content.to_owned(), + ), + }, + cancellation, + ) + .await + } + + async fn change_document( + &self, + uri: &Uri, + version: i32, + content: &str, + cancellation: &CancellationToken, + ) -> Result<(), LanguageRuntimeError> { + self.notify_typed( + "did_change", + "textDocument/didChange", + DidChangeTextDocumentParams { + text_document: VersionedTextDocumentIdentifier::new(uri.clone(), version), + content_changes: vec![TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: content.to_owned(), + }], + }, + cancellation, + ) + .await + } + + async fn close_document( + &self, + path: &WorkspacePath, + cancellation: &CancellationToken, + ) -> Result<(), LanguageRuntimeError> { + self.notify_typed( + "did_close", + "textDocument/didClose", + DidCloseTextDocumentParams { + text_document: TextDocumentIdentifier::new(workspace_file_uri( + &self.canonical_root, + path, + )?), + }, + cancellation, + ) + .await + } + + async fn save_document( + &self, + uri: &Uri, + cancellation: &CancellationToken, + ) -> Result<(), LanguageRuntimeError> { + self.notify_typed( + "did_save", + "textDocument/didSave", + did_save_params(uri), + cancellation, + ) + .await + } + + pub(super) async fn invalidate_document_locked( + &self, + path: &WorkspacePath, + cancellation: &CancellationToken, + ) -> Result<(), LanguageRuntimeError> { + self.documents.invalidate(path).await; + self.navigation_revisions.lock().await.remove(path); + if self.open_documents.lock().await.contains(path) { + self.close_document(path, cancellation).await?; + self.open_documents.lock().await.remove(path); + } + self.clear_diagnostics(path).await; + Ok(()) + } + + pub(super) async fn request_typed( + &self, + operation: &'static str, + method: &'static str, + params: P, + cancellation: CancellationToken, + ) -> Result + where + P: Serialize, + R: DeserializeOwned, + { + let params = serde_json::to_value(params) + .map_err(|source| LanguageRuntimeError::Serde { operation, source })?; + let response = self + .process + .client() + .request(method, Some(params), cancellation, self.timeout) + .await + .map_err(|source| LanguageRuntimeError::Client { operation, source })?; + serde_json::from_value(response) + .map_err(|source| LanguageRuntimeError::Serde { operation, source }) + } + + async fn notify_typed( + &self, + operation: &'static str, + method: &'static str, + params: P, + cancellation: &CancellationToken, + ) -> Result<(), LanguageRuntimeError> { + let params = serde_json::to_value(params) + .map_err(|source| LanguageRuntimeError::Serde { operation, source })?; + let client = self.process.client(); + let notify = client.notify(method, Some(params)); + tokio::pin!(notify); + tokio::select! { + biased; + _ = cancellation.cancelled() => Err(LanguageRuntimeError::Cancelled), + result = tokio::time::timeout(self.timeout, &mut notify) => match result { + Ok(Ok(())) => Ok(()), + Ok(Err(source)) => Err(LanguageRuntimeError::Client { operation, source }), + Err(_) => Err(LanguageRuntimeError::Timeout { + operation, + duration: self.timeout, + }), + } + } + } + + pub(super) async fn document_result( + &self, + path: &WorkspacePath, + snapshot: DocumentSnapshot, + items: Vec, + truncated: bool, + ) -> CodeQueryResult { + let snapshot = self.documents.complete_query(path, snapshot).await; + self.query_result(items, truncated, Some(snapshot)) + } + + pub(super) fn query_result( + &self, + items: Vec, + truncated: bool, + document: Option, + ) -> CodeQueryResult { + CodeQueryResult { + items, + truncated, + workspace_revision: self.layout.workspace_revision, + document, + } + } + + pub(super) fn require_path(&self, path: &WorkspacePath) -> Result<(), LanguageRuntimeError> { + validate_workspace_path(path)?; + if self.supports_path(path) { + Ok(()) + } else { + Err(LanguageRuntimeError::UnsupportedPath { path: path.clone() }) + } + } + + pub(super) fn require_capability( + &self, + supported: bool, + operation: &'static str, + ) -> Result<(), LanguageRuntimeError> { + if supported { + Ok(()) + } else { + Err(LanguageRuntimeError::Unsupported { operation }) + } + } + + pub(super) fn require_navigation( + &self, + kind: NavigationKind, + ) -> Result<(), LanguageRuntimeError> { + let supported = match kind { + NavigationKind::Definition => self.initialized.capabilities.definition, + NavigationKind::Declaration => self.initialized.capabilities.declaration, + NavigationKind::References => self.initialized.capabilities.references, + NavigationKind::Implementations => self.initialized.capabilities.implementations, + }; + self.require_capability(supported, navigation_request(kind).0) + } +} + +pub(super) fn navigation_request(kind: NavigationKind) -> (&'static str, &'static str) { + match kind { + NavigationKind::Definition => ("definition", "textDocument/definition"), + NavigationKind::Declaration => ("declaration", "textDocument/declaration"), + NavigationKind::References => ("references", "textDocument/references"), + NavigationKind::Implementations => ("implementations", "textDocument/implementation"), + } +} + +fn file_change_type(kind: WorkspaceFileChangeKind) -> FileChangeType { + match kind { + WorkspaceFileChangeKind::Created => FileChangeType::CREATED, + WorkspaceFileChangeKind::Changed => FileChangeType::CHANGED, + WorkspaceFileChangeKind::Deleted => FileChangeType::DELETED, + } +} + +pub(super) fn document_sync_plan( + mode: ServerTextSyncMode, + supports_open_close: bool, + supports_did_save: bool, + observation: DocumentObservationKind, + already_open: bool, +) -> DocumentSyncPlan { + use DocumentObservationKind::{Changed, Opened, Reopened, Unchanged}; + use DocumentSyncStep::{Change, Close, Open}; + + let steps: &'static [DocumentSyncStep] = + if !supports_open_close || mode == ServerTextSyncMode::None { + &[] + } else { + match (mode, observation, already_open) { + (_, Opened, false) | (_, Unchanged, false) => &[Open], + (_, Opened, true) | (_, Reopened, _) => &[Close, Open], + (ServerTextSyncMode::Full, Changed, true) => &[Change], + (ServerTextSyncMode::Full, Changed, false) => &[Open], + (ServerTextSyncMode::Incremental, Changed, _) => &[Close, Open], + (_, Unchanged, true) => &[], + (ServerTextSyncMode::None, _, _) => unreachable!(), + } + }; + DocumentSyncPlan { + steps, + send_did_save: supports_did_save, + } +} + +pub(super) fn did_save_params(uri: &Uri) -> DidSaveTextDocumentParams { + DidSaveTextDocumentParams { + text_document: TextDocumentIdentifier::new(uri.clone()), + // The negotiated capability currently records support, but not an + // include-text request. Omitting text avoids duplicating saved source. + text: None, + } +} + +pub(super) fn bound_items(mut items: Vec, limit: usize) -> (Vec, bool) { + let truncated = items.len() > limit; + items.truncate(limit); + (items, truncated) +} + +pub(super) fn ensure_not_cancelled( + cancellation: &CancellationToken, +) -> Result<(), LanguageRuntimeError> { + if cancellation.is_cancelled() { + Err(LanguageRuntimeError::Cancelled) + } else { + Ok(()) + } +} diff --git a/core/src/code_intelligence/language_runtime/tests.rs b/core/src/code_intelligence/language_runtime/tests.rs new file mode 100644 index 00000000..eeb76054 --- /dev/null +++ b/core/src/code_intelligence/language_runtime/tests.rs @@ -0,0 +1,219 @@ +use std::{path::Path, str::FromStr}; + +use lsp_types::Uri; +use serde_json::json; + +use super::super::{ + document_store::DocumentObservationKind, lsp::initialize::ServerTextSyncMode, CodePosition, + CodeRange, CodeSymbolKind, DocumentSymbol, +}; +use super::{ + bound_document_symbols, + paths::{ + existing_workspace_file_uri, valid_utf16_position, validate_canonical_root, + validate_workspace_path, + }, + protocol::{bound_items, did_save_params, document_sync_plan, DocumentSyncStep}, + MAX_DOCUMENT_SYMBOLS, +}; +use crate::workspace::WorkspacePath; + +fn document_symbol(name: impl Into, children: Vec) -> DocumentSymbol { + DocumentSymbol { + name: name.into(), + detail: None, + kind: CodeSymbolKind::Function, + range: CodeRange::new(CodePosition::new(0, 0), CodePosition::new(0, 1)), + selection_range: CodeRange::new(CodePosition::new(0, 0), CodePosition::new(0, 1)), + children, + } +} + +#[test] +fn sync_matrix_respects_full_incremental_disk_only_and_save() { + assert_eq!( + document_sync_plan( + ServerTextSyncMode::Full, + true, + false, + DocumentObservationKind::Opened, + false, + ) + .steps, + [DocumentSyncStep::Open] + ); + assert_eq!( + document_sync_plan( + ServerTextSyncMode::Full, + true, + false, + DocumentObservationKind::Changed, + true, + ) + .steps, + [DocumentSyncStep::Change] + ); + assert_eq!( + document_sync_plan( + ServerTextSyncMode::Incremental, + true, + false, + DocumentObservationKind::Changed, + true, + ) + .steps, + [DocumentSyncStep::Close, DocumentSyncStep::Open] + ); + assert_eq!( + document_sync_plan( + ServerTextSyncMode::Incremental, + true, + false, + DocumentObservationKind::Reopened, + true, + ) + .steps, + [DocumentSyncStep::Close, DocumentSyncStep::Open] + ); + assert!(document_sync_plan( + ServerTextSyncMode::None, + true, + false, + DocumentObservationKind::Opened, + false, + ) + .steps + .is_empty()); + let disk_only = document_sync_plan( + ServerTextSyncMode::Full, + false, + true, + DocumentObservationKind::Changed, + true, + ); + assert!(disk_only.steps.is_empty()); + assert!(disk_only.send_did_save); + + let uri = Uri::from_str("file:///workspace/src/lib.rs").unwrap(); + assert_eq!( + serde_json::to_value(did_save_params(&uri)).unwrap(), + json!({"textDocument": {"uri": "file:///workspace/src/lib.rs"}}) + ); +} + +#[test] +fn validates_utf16_boundaries_and_all_lsp_line_endings() { + let text = "a🦀中\r\nsecond\rthird\nfourth"; + assert!(valid_utf16_position(text, CodePosition::new(0, 4))); + assert!(!valid_utf16_position(text, CodePosition::new(0, 2))); + assert!(valid_utf16_position(text, CodePosition::new(1, 6))); + assert!(valid_utf16_position(text, CodePosition::new(2, 5))); + assert!(valid_utf16_position(text, CodePosition::new(3, 6))); + assert!(!valid_utf16_position(text, CodePosition::new(4, 0))); + assert!(valid_utf16_position("trailing\n", CodePosition::new(1, 0))); +} + +#[test] +fn rejects_non_normalized_paths_and_bounds_results() { + for value in ["../outside.rs", "src/../lib.rs", "."] { + assert!(validate_workspace_path(&WorkspacePath::from_normalized(value)).is_err()); + } + // WorkspacePath intentionally treats a leading slash as workspace-relative. + assert!(validate_workspace_path(&WorkspacePath::from_normalized("/src/lib.rs")).is_ok()); + assert!(validate_workspace_path(&WorkspacePath::from_normalized("src/lib.rs")).is_ok()); + assert_eq!(bound_items(vec![1, 2, 3], 2), (vec![1, 2], true)); + assert_eq!(bound_items(vec![1, 2], 2), (vec![1, 2], false)); + assert_eq!(bound_items(vec![1], 0), (Vec::::new(), true)); +} + +#[test] +fn document_symbol_bound_truncates_wide_trees_in_server_order() { + let symbols = (0..=MAX_DOCUMENT_SYMBOLS) + .map(|index| document_symbol(format!("symbol-{index}"), Vec::new())) + .collect(); + + let (bounded, truncated) = bound_document_symbols(symbols, MAX_DOCUMENT_SYMBOLS); + + assert!(truncated); + assert_eq!(bounded.len(), MAX_DOCUMENT_SYMBOLS); + assert_eq!(bounded[0].name, "symbol-0"); + assert_eq!( + bounded[MAX_DOCUMENT_SYMBOLS - 1].name, + format!("symbol-{}", MAX_DOCUMENT_SYMBOLS - 1) + ); +} + +#[test] +fn document_symbol_bound_preserves_ancestors_and_child_nesting() { + let symbols = vec![document_symbol( + "root", + vec![document_symbol( + "child", + vec![document_symbol( + "grandchild", + vec![document_symbol("discarded", Vec::new())], + )], + )], + )]; + + let (bounded, truncated) = bound_document_symbols(symbols, 3); + + assert!(truncated); + assert_eq!(bounded.len(), 1); + assert_eq!(bounded[0].name, "root"); + assert_eq!(bounded[0].children.len(), 1); + assert_eq!(bounded[0].children[0].name, "child"); + assert_eq!(bounded[0].children[0].children.len(), 1); + assert_eq!(bounded[0].children[0].children[0].name, "grandchild"); + assert!(bounded[0].children[0].children[0].children.is_empty()); +} + +#[test] +fn document_symbol_bound_is_not_truncated_at_the_exact_limit() { + let symbols = (0..MAX_DOCUMENT_SYMBOLS) + .map(|index| document_symbol(format!("symbol-{index}"), Vec::new())) + .collect(); + + let (bounded, truncated) = bound_document_symbols(symbols, MAX_DOCUMENT_SYMBOLS); + + assert!(!truncated); + assert_eq!(bounded.len(), MAX_DOCUMENT_SYMBOLS); +} + +#[tokio::test] +async fn canonical_root_must_be_resolved_and_a_directory() { + let workspace = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(workspace.path()).unwrap(); + validate_canonical_root(&root).await.unwrap(); + assert!(validate_canonical_root(Path::new("relative")) + .await + .is_err()); + + let file = root.join("file"); + std::fs::write(&file, "saved").unwrap(); + assert!(validate_canonical_root(&file).await.is_err()); +} + +#[cfg(unix)] +#[tokio::test] +async fn existing_source_uri_rejects_symlink_escape_and_missing_path() { + use std::os::unix::fs::symlink; + + let workspace = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(workspace.path()).unwrap(); + let outside_file = outside.path().join("secret.rs"); + std::fs::write(&outside_file, "secret").unwrap(); + symlink(&outside_file, workspace.path().join("linked.rs")).unwrap(); + + assert!( + existing_workspace_file_uri(&root, &WorkspacePath::from_normalized("linked.rs")) + .await + .is_err() + ); + assert!( + existing_workspace_file_uri(&root, &WorkspacePath::from_normalized("missing.rs")) + .await + .is_err() + ); +} diff --git a/core/src/code_intelligence/local_provider.rs b/core/src/code_intelligence/local_provider.rs new file mode 100644 index 00000000..e77c7e89 --- /dev/null +++ b/core/src/code_intelligence/local_provider.rs @@ -0,0 +1,514 @@ +//! Local manifest-backed Code Intelligence provider. + +use std::{ + convert::Infallible, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex as StdMutex, Weak, + }, + time::Duration, +}; + +use async_trait::async_trait; +use tokio::sync::{broadcast, watch, RwLock}; +use tokio_util::sync::CancellationToken; + +use super::{ + project_layout::ProjectLayoutResolver, + registry::{ + LocalCodeIntelligenceRegistry, RegistryAcquireError, RegistryConfig, RegistryKey, + RegistryKeyError, RegistryReport, RegistryShutdownError, RegistryShutdownFailure, + RuntimeLease, + }, + workspace_runtime::WorkspaceRuntime, + CodeDiagnostic, CodeIntelligenceError, CodeIntelligenceResult, CodeIntelligenceState, + CodeIntelligenceStatus, CodeLocation, CodePosition, CodeQueryResult, DocumentSymbol, + NavigationKind, SymbolInformation, WorkspaceCodeIntelligence, +}; +use crate::workspace::{ + LocalWorkspaceManifest, LocalWorkspaceManifestSnapshot, WorkspaceFileChange, + WorkspaceFileSystem, WorkspacePath, +}; + +const DEFAULT_QUERY_TIMEOUT: Duration = Duration::from_secs(15); +type RuntimeRegistry = LocalCodeIntelligenceRegistry; +type WorkspaceRuntimeLease = RuntimeLease; + +/// Native local provider sharing the workspace manifest's existing watcher. +pub struct LocalCodeIntelligence { + isolation_scope: String, + manifest: Arc, + file_system: Arc, + registry: RuntimeRegistry, + current: RwLock>, + status: watch::Sender, + generation: Arc, + lifetime: CancellationToken, + manifest_task: StdMutex>>, + query_timeout: Duration, +} + +impl std::fmt::Debug for LocalCodeIntelligence { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("LocalCodeIntelligence") + .field("isolation_scope", &self.isolation_scope) + .field("manifest_root", &self.manifest.snapshot().root) + .field("generation", &self.generation.load(Ordering::Acquire)) + .finish_non_exhaustive() + } +} + +impl LocalCodeIntelligence { + /// Create a provider and acquire a cheap, lazily-started runtime generation. + pub async fn start( + isolation_scope: impl Into, + manifest: Arc, + file_system: Arc, + ) -> CodeIntelligenceResult> { + Self::start_with_timeout( + isolation_scope, + manifest, + file_system, + DEFAULT_QUERY_TIMEOUT, + ) + .await + } + + pub(crate) async fn start_with_timeout( + isolation_scope: impl Into, + manifest: Arc, + file_system: Arc, + query_timeout: Duration, + ) -> CodeIntelligenceResult> { + let isolation_scope = isolation_scope.into(); + let snapshot_rx = manifest.subscribe(); + let changes_rx = manifest.subscribe_changes(); + let registry = RuntimeRegistry::new( + RegistryConfig::new(Duration::ZERO, 0), + |runtime: Arc| async move { + runtime.shutdown().await; + Ok(()) + }, + ); + let (status, _) = watch::channel(CodeIntelligenceStatus { + state: CodeIntelligenceState::Starting, + message: Some("Code Intelligence is preparing the saved workspace".to_owned()), + ..CodeIntelligenceStatus::default() + }); + let provider = Arc::new(Self { + isolation_scope, + manifest, + file_system, + registry, + current: RwLock::new(None), + status, + generation: Arc::new(AtomicU64::new(0)), + lifetime: CancellationToken::new(), + manifest_task: StdMutex::new(None), + query_timeout, + }); + + provider + .refresh_snapshot(provider.manifest.snapshot()) + .await?; + let weak = Arc::downgrade(&provider); + let task = tokio::spawn(run_manifest_updates( + weak, + snapshot_rx, + changes_rx, + provider.lifetime.clone(), + )); + *provider + .manifest_task + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(task); + Ok(provider) + } + + /// Stop manifest forwarding and all language processes owned by this provider. + pub async fn shutdown(&self) { + self.lifetime.cancel(); + let task = self + .manifest_task + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(task) = task { + let _ = task.await; + } + let old = self.current.write().await.take(); + drop(old); + report_registry_cleanup("shutdown", self.registry.shutdown_all().await); + self.status.send_replace(CodeIntelligenceStatus { + state: CodeIntelligenceState::Unavailable, + message: Some("Code Intelligence is shut down".to_owned()), + ..CodeIntelligenceStatus::default() + }); + } + + async fn refresh_snapshot( + self: &Arc, + snapshot: LocalWorkspaceManifestSnapshot, + ) -> CodeIntelligenceResult<()> { + let layout = ProjectLayoutResolver::resolve(&snapshot); + { + let current = self.current.read().await; + if let Some(runtime) = current + .as_ref() + .filter(|runtime| runtime.layout_hash() == layout.layout_hash) + { + runtime.update_snapshot(&snapshot).await; + return Ok(()); + } + } + + let key = RegistryKey::new( + self.isolation_scope.clone(), + &snapshot.root, + layout.layout_hash, + ) + .await + .map_err(map_key_error)?; + let canonical_root = key.canonical_root().to_path_buf(); + let runtime_snapshot = snapshot.clone(); + let file_system = Arc::clone(&self.file_system); + let timeout = self.query_timeout; + let lease = self + .registry + .acquire(key, move |_| async move { + Ok(WorkspaceRuntime::new( + canonical_root, + layout, + &runtime_snapshot, + file_system, + timeout, + )) + }) + .await + .map_err(map_acquire_error)?; + + lease.update_snapshot(&snapshot).await; + let mut receiver = lease.subscribe_status(); + let generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1; + self.status.send_replace(receiver.borrow().clone()); + let old = self.current.write().await.replace(lease); + drop(old); + report_registry_cleanup("layout refresh", self.registry.cleanup_idle().await); + self.spawn_status_forwarder(generation, &mut receiver); + Ok(()) + } + + fn spawn_status_forwarder( + &self, + generation: u64, + receiver: &mut watch::Receiver, + ) { + let mut receiver = receiver.clone(); + let sender = self.status.clone(); + let current_generation = Arc::clone(&self.generation); + let lifetime = self.lifetime.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + _ = lifetime.cancelled() => break, + changed = receiver.changed() => { + if changed.is_err() + || current_generation.load(Ordering::Acquire) != generation + { + break; + } + sender.send_replace(receiver.borrow().clone()); + } + } + } + }); + } + + async fn handle_changes(&self, changes: &[WorkspaceFileChange]) { + let current = self.current.read().await; + if let Some(runtime) = current.as_ref() { + runtime.notify_file_changes(changes).await; + } + } + + async fn runtime( + &self, + ) -> CodeIntelligenceResult>> + { + let current = self.current.read().await; + if current.is_none() { + return Err(CodeIntelligenceError::Unavailable { + message: "Code Intelligence has not prepared this workspace yet".to_owned(), + }); + } + Ok(current) + } + + fn report_update_error(&self, error: &CodeIntelligenceError) { + let mut status = self.status.borrow().clone(); + status.state = CodeIntelligenceState::Degraded; + status.message = Some(format!("workspace refresh failed: {error}")); + self.status.send_replace(status); + } +} + +#[async_trait] +impl WorkspaceCodeIntelligence for LocalCodeIntelligence { + fn subscribe_status(&self) -> watch::Receiver { + self.status.subscribe() + } + + async fn document_symbols( + &self, + path: &WorkspacePath, + cancellation: CancellationToken, + ) -> CodeIntelligenceResult> { + let runtime = self.runtime().await?; + let Some(runtime) = runtime.as_ref() else { + return Err(runtime_unavailable()); + }; + runtime.document_symbols(path, cancellation).await + } + + async fn search_symbols( + &self, + query: &str, + limit: usize, + cancellation: CancellationToken, + ) -> CodeIntelligenceResult> { + let runtime = self.runtime().await?; + let Some(runtime) = runtime.as_ref() else { + return Err(runtime_unavailable()); + }; + runtime.search_symbols(query, limit, cancellation).await + } + + async fn navigate( + &self, + kind: NavigationKind, + path: &WorkspacePath, + position: CodePosition, + cancellation: CancellationToken, + ) -> CodeIntelligenceResult> { + let runtime = self.runtime().await?; + let Some(runtime) = runtime.as_ref() else { + return Err(runtime_unavailable()); + }; + runtime.navigate(kind, path, position, cancellation).await + } + + async fn diagnostics( + &self, + path: Option<&WorkspacePath>, + cancellation: CancellationToken, + ) -> CodeIntelligenceResult> { + let runtime = self.runtime().await?; + let Some(runtime) = runtime.as_ref() else { + return Err(runtime_unavailable()); + }; + runtime.diagnostics(path, cancellation).await + } +} + +impl Drop for LocalCodeIntelligence { + fn drop(&mut self) { + self.lifetime.cancel(); + if let Some(task) = self + .manifest_task + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + task.abort(); + } + } +} + +async fn run_manifest_updates( + provider: Weak, + mut snapshots: broadcast::Receiver, + mut changes: broadcast::Receiver, + lifetime: CancellationToken, +) { + loop { + tokio::select! { + _ = lifetime.cancelled() => break, + update = snapshots.recv() => match update { + Ok(snapshot) => { + let Some(provider) = provider.upgrade() else { break; }; + if let Err(error) = provider.refresh_snapshot(snapshot).await { + provider.report_update_error(&error); + } + } + Err(broadcast::error::RecvError::Lagged(_)) => { + let Some(provider) = provider.upgrade() else { break; }; + if let Err(error) = provider.refresh_snapshot(provider.manifest.snapshot()).await { + provider.report_update_error(&error); + } + } + Err(broadcast::error::RecvError::Closed) => break, + }, + update = changes.recv() => match update { + Ok(change) => { + let mut batch = vec![change]; + while let Ok(change) = changes.try_recv() { + batch.push(change); + } + let Some(provider) = provider.upgrade() else { break; }; + provider.handle_changes(&batch).await; + } + Err(broadcast::error::RecvError::Lagged(count)) => { + let Some(provider) = provider.upgrade() else { break; }; + let mut status = provider.status.borrow().clone(); + status.state = CodeIntelligenceState::Degraded; + status.message = Some(format!( + "workspace change stream skipped {count} events; saved documents will resynchronize on query" + )); + provider.status.send_replace(status); + } + Err(broadcast::error::RecvError::Closed) => break, + }, + } + } +} + +fn map_key_error(error: RegistryKeyError) -> CodeIntelligenceError { + CodeIntelligenceError::Unavailable { + message: error.to_string(), + } +} + +fn map_acquire_error(error: RegistryAcquireError) -> CodeIntelligenceError { + match error { + RegistryAcquireError::ShuttingDown => CodeIntelligenceError::Unavailable { + message: "the Code Intelligence registry is shutting down".to_owned(), + }, + RegistryAcquireError::Factory(error) => match *error {}, + RegistryAcquireError::FactoryPanicked { message } => CodeIntelligenceError::Unavailable { + message: format!("Code Intelligence runtime initialization panicked: {message}"), + }, + RegistryAcquireError::LeaseLimit => CodeIntelligenceError::Unavailable { + message: "the Code Intelligence runtime lease limit was exhausted".to_owned(), + }, + } +} + +fn runtime_unavailable() -> CodeIntelligenceError { + CodeIntelligenceError::Unavailable { + message: "Code Intelligence has not prepared this workspace yet".to_owned(), + } +} + +fn report_registry_cleanup(context: &'static str, report: RegistryReport) { + if !report.removed.is_empty() { + tracing::debug!( + context, + retired = report.removed.len(), + "Code Intelligence retired workspace runtimes" + ); + } + for RegistryShutdownError { key, failure } in report.errors { + match failure { + RegistryShutdownFailure::Runtime(error) => { + tracing::error!( + context, + workspace = ?key.canonical_root(), + ?error, + "Code Intelligence runtime cleanup returned an impossible error" + ); + } + RegistryShutdownFailure::Panicked { message } => { + tracing::warn!( + context, + workspace = ?key.canonical_root(), + %message, + "Code Intelligence runtime cleanup panicked" + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::workspace::{ + LocalWorkspaceFile, LocalWorkspaceFileStatus, ManifestWorkspaceBackend, + }; + + fn file(path: &str) -> LocalWorkspaceFile { + LocalWorkspaceFile { + path: path.to_owned(), + size: 1, + modified_ms: Some(1), + language: None, + status: LocalWorkspaceFileStatus::Tracked, + binary: false, + generated: false, + } + } + + async fn scanned_snapshot(manifest: &LocalWorkspaceManifest) -> LocalWorkspaceManifestSnapshot { + if manifest.snapshot().version > 0 { + return manifest.snapshot(); + } + let mut snapshots = manifest.subscribe(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let snapshot = snapshots.recv().await.unwrap(); + if snapshot.version > 0 { + break snapshot; + } + } + }) + .await + .expect("initial manifest scan should finish") + } + + #[tokio::test] + async fn provider_reuses_unchanged_layout_and_shutdown_is_idempotent() { + let workspace = tempfile::tempdir().unwrap(); + std::fs::create_dir(workspace.path().join("src")).unwrap(); + std::fs::write(workspace.path().join("Cargo.toml"), "[workspace]\n").unwrap(); + std::fs::write(workspace.path().join("src/lib.rs"), "pub fn saved() {}\n").unwrap(); + let backend = ManifestWorkspaceBackend::new(workspace.path()); + let manifest = backend.manifest(); + let initial = scanned_snapshot(&manifest).await; + let file_system: Arc = backend; + let provider = LocalCodeIntelligence::start_with_timeout( + "test-session", + manifest, + file_system, + Duration::from_secs(1), + ) + .await + .unwrap(); + let generation = provider.generation.load(Ordering::Acquire); + assert_eq!(generation, 1); + + let mut source_only = initial.clone(); + source_only.version += 1; + source_only.files.push(file("src/new.rs")); + provider.refresh_snapshot(source_only).await.unwrap(); + assert_eq!(provider.generation.load(Ordering::Acquire), generation); + + let mut changed_layout = initial; + changed_layout.version += 2; + changed_layout + .files + .retain(|entry| entry.path != "Cargo.toml"); + changed_layout.files.push(file("package.json")); + provider.refresh_snapshot(changed_layout).await.unwrap(); + assert_eq!(provider.generation.load(Ordering::Acquire), generation + 1); + + provider.shutdown().await; + provider.shutdown().await; + assert!(provider.current.read().await.is_none()); + assert!(provider.lifetime.is_cancelled()); + assert_eq!( + provider.status.borrow().state, + CodeIntelligenceState::Unavailable + ); + } +} diff --git a/core/src/code_intelligence/lsp/client.rs b/core/src/code_intelligence/lsp/client.rs new file mode 100644 index 00000000..40cc0c9b --- /dev/null +++ b/core/src/code_intelligence/lsp/client.rs @@ -0,0 +1,910 @@ +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex as StdMutex, + }, + time::Duration, +}; + +use futures::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +#[cfg(test)] +use tokio::io; +use tokio::{ + io::{AsyncRead, AsyncWrite}, + sync::{mpsc, oneshot, Mutex}, + task::JoinHandle, + time::{self, Instant}, +}; +use tokio_util::{ + codec::{FramedRead, FramedWrite}, + sync::CancellationToken, +}; + +use super::{ + codec::{LspCodec, LspCodecError}, + message::{ + IncomingMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponsePayload, RequestId, + }, + router::ServerRequestRouter, +}; + +const DEFAULT_WRITER_CAPACITY: usize = 64; +const DEFAULT_NOTIFICATION_CAPACITY: usize = 256; + +type PendingResult = Result; +type PendingMap = HashMap>; + +/// Bounded channel sizes used by a protocol client. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct LspClientConfig { + writer_capacity: usize, + notification_capacity: usize, +} + +impl LspClientConfig { + #[cfg(test)] + pub(crate) fn new(writer_capacity: usize, notification_capacity: usize) -> Self { + Self { + writer_capacity: writer_capacity.max(1), + notification_capacity: notification_capacity.max(1), + } + } +} + +impl Default for LspClientConfig { + fn default() -> Self { + Self { + writer_capacity: DEFAULT_WRITER_CAPACITY, + notification_capacity: DEFAULT_NOTIFICATION_CAPACITY, + } + } +} + +/// Failure returned by the protocol actor. +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub(crate) enum LspClientError { + #[error("language server returned error {code}: {message}")] + RemoteError { + code: i64, + message: String, + data: Option, + }, + + #[error("language server request was cancelled")] + Cancelled, + + #[error("language server request '{method}' timed out after {duration:?}")] + Timeout { method: String, duration: Duration }, + + #[error("language server connection closed: {message}")] + Closed { message: String }, + + #[error("language server transport failed: {message}")] + Transport { message: String }, + + #[error("language server protocol failed: {message}")] + Protocol { message: String }, +} + +#[derive(Debug)] +struct SharedState { + writer: mpsc::Sender, + pending: Mutex, + next_request_id: AtomicU64, + closed: AtomicBool, + close_reason: StdMutex>, + shutdown: CancellationToken, +} + +#[derive(Debug)] +struct ClientInner { + shared: Arc, + tasks: Mutex>>>, + notifications: StdMutex>>, +} + +/// Cloneable handle to one bidirectional language-server connection. +#[derive(Debug, Clone)] +pub(crate) struct LspClient { + inner: Arc, +} + +impl LspClient { + /// Start a client over one bidirectional asynchronous stream. + #[cfg(test)] + pub(crate) fn start(io: T, router: ServerRequestRouter) -> Self + where + T: AsyncRead + AsyncWrite + Unpin + Send + 'static, + { + Self::start_with_config(io, router, LspClientConfig::default()) + } + + #[cfg(test)] + pub(crate) fn start_with_config( + io: T, + router: ServerRequestRouter, + config: LspClientConfig, + ) -> Self + where + T: AsyncRead + AsyncWrite + Unpin + Send + 'static, + { + let (reader, writer) = io::split(io); + Self::start_split_with_config(reader, writer, router, config) + } + + /// Start a client when the process exposes separate stdout and stdin + /// handles instead of one bidirectional stream. + pub(crate) fn start_split(reader: R, writer: W, router: ServerRequestRouter) -> Self + where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, + { + Self::start_split_with_config(reader, writer, router, LspClientConfig::default()) + } + + pub(crate) fn start_split_with_config( + reader: R, + writer: W, + router: ServerRequestRouter, + config: LspClientConfig, + ) -> Self + where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, + { + let (writer_tx, writer_rx) = mpsc::channel(config.writer_capacity); + let (notification_tx, notification_rx) = mpsc::channel(config.notification_capacity); + let shared = Arc::new(SharedState { + writer: writer_tx, + pending: Mutex::new(HashMap::new()), + next_request_id: AtomicU64::new(1), + closed: AtomicBool::new(false), + close_reason: StdMutex::new(None), + shutdown: CancellationToken::new(), + }); + + let writer_task = tokio::spawn(run_writer(writer, writer_rx, shared.clone())); + let reader_task = tokio::spawn(run_reader(reader, router, notification_tx, shared.clone())); + Self { + inner: Arc::new(ClientInner { + shared, + tasks: Mutex::new(Some(vec![writer_task, reader_task])), + notifications: StdMutex::new(Some(notification_rx)), + }), + } + } + + /// Send a request and wait for its result for no longer than `timeout`. + pub(crate) async fn request( + &self, + method: &str, + params: Option, + cancellation: CancellationToken, + timeout: Duration, + ) -> Result { + if cancellation.is_cancelled() { + return Err(LspClientError::Cancelled); + } + self.ensure_open()?; + + let id = RequestId::from( + self.inner + .shared + .next_request_id + .fetch_add(1, Ordering::Relaxed), + ); + let request = JsonRpcRequest::new(id.clone(), method, params).to_value(); + let (response_tx, response_rx) = oneshot::channel(); + { + let mut pending = self.inner.shared.pending.lock().await; + if self.inner.shared.closed.load(Ordering::Acquire) { + return Err(self.close_error()); + } + pending.insert(id.clone(), response_tx); + } + + let deadline = Instant::now() + timeout; + let enqueue = self.inner.shared.writer.send(request); + tokio::pin!(enqueue); + tokio::select! { + biased; + _ = cancellation.cancelled() => { + self.abort_request(&id).await; + return Err(LspClientError::Cancelled); + } + _ = time::sleep_until(deadline) => { + self.abort_request(&id).await; + return Err(LspClientError::Timeout { + method: method.to_owned(), + duration: timeout, + }); + } + result = &mut enqueue => { + if result.is_err() { + self.inner.shared.pending.lock().await.remove(&id); + return Err(self.close_error()); + } + } + } + + tokio::select! { + biased; + _ = cancellation.cancelled() => { + self.abort_request(&id).await; + Err(LspClientError::Cancelled) + } + _ = time::sleep_until(deadline) => { + self.abort_request(&id).await; + Err(LspClientError::Timeout { + method: method.to_owned(), + duration: timeout, + }) + } + response = response_rx => match response { + Ok(result) => result, + Err(_) => Err(self.close_error()), + } + } + } + + /// Send a notification to the server, applying bounded backpressure. + pub(crate) async fn notify( + &self, + method: &str, + params: Option, + ) -> Result<(), LspClientError> { + self.ensure_open()?; + let notification = JsonRpcNotification::new(method, params).to_value(); + self.inner + .shared + .writer + .send(notification) + .await + .map_err(|_| self.close_error()) + } + + /// Take the typed server-notification stream. Only one consumer may own it. + pub(crate) fn take_notifications(&self) -> Option> { + self.inner + .notifications + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + } + + pub(crate) fn shutdown_token(&self) -> CancellationToken { + self.inner.shared.shutdown.clone() + } + + pub(crate) fn is_closed(&self) -> bool { + self.inner.shared.closed.load(Ordering::Acquire) + } + + /// Stop the reader and writer tasks and fail any outstanding requests. + pub(crate) async fn close(&self) { + terminate( + &self.inner.shared, + LspClientError::Closed { + message: "client shutdown requested".to_owned(), + }, + ) + .await; + + let tasks = self.inner.tasks.lock().await.take().unwrap_or_default(); + for task in tasks { + let _ = task.await; + } + } + + async fn abort_request(&self, id: &RequestId) { + let removed = self.inner.shared.pending.lock().await.remove(id).is_some(); + if !removed || self.inner.shared.closed.load(Ordering::Acquire) { + return; + } + + let cancellation = + JsonRpcNotification::new("$/cancelRequest", Some(json!({"id": id.to_value()}))) + .to_value(); + // Cancellation must remain prompt even when the transport is already + // backpressured. The local pending entry has been removed, so a late + // response remains harmless if this best-effort notification cannot + // enter a full queue. + let _ = self.inner.shared.writer.try_send(cancellation); + } + + fn ensure_open(&self) -> Result<(), LspClientError> { + if self.inner.shared.closed.load(Ordering::Acquire) { + Err(self.close_error()) + } else { + Ok(()) + } + } + + fn close_error(&self) -> LspClientError { + self.inner + .shared + .close_reason + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + .unwrap_or_else(|| LspClientError::Closed { + message: "connection is no longer available".to_owned(), + }) + } +} + +async fn run_writer(writer: W, mut messages: mpsc::Receiver, shared: Arc) +where + W: AsyncWrite + Unpin, +{ + let mut framed = FramedWrite::new(writer, LspCodec::default()); + loop { + let message = tokio::select! { + _ = shared.shutdown.cancelled() => { + terminate_if_needed(&shared).await; + break; + } + message = messages.recv() => match message { + Some(message) => message, + None => { + terminate( + &shared, + LspClientError::Closed { + message: "protocol writer channel closed".to_owned(), + }, + ).await; + break; + } + } + }; + + let send = framed.send(message); + tokio::pin!(send); + tokio::select! { + _ = shared.shutdown.cancelled() => { + terminate_if_needed(&shared).await; + break; + } + result = &mut send => { + if let Err(error) = result { + terminate(&shared, codec_client_error(error)).await; + break; + } + } + } + } +} + +async fn run_reader( + reader: R, + router: ServerRequestRouter, + notifications: mpsc::Sender, + shared: Arc, +) where + R: AsyncRead + Unpin, +{ + let mut framed = FramedRead::new(reader, LspCodec::default()); + loop { + let frame = tokio::select! { + _ = shared.shutdown.cancelled() => { + terminate_if_needed(&shared).await; + break; + } + frame = framed.next() => frame, + }; + + let value = match frame { + Some(Ok(value)) => value, + Some(Err(error)) => { + terminate(&shared, codec_client_error(error)).await; + break; + } + None => { + terminate( + &shared, + LspClientError::Closed { + message: "language server reached end of stream".to_owned(), + }, + ) + .await; + break; + } + }; + + let message = match IncomingMessage::try_from(value) { + Ok(message) => message, + Err(error) => { + terminate( + &shared, + LspClientError::Protocol { + message: error.to_string(), + }, + ) + .await; + break; + } + }; + + match message { + IncomingMessage::Response(response) => { + let sender = shared.pending.lock().await.remove(&response.id); + let Some(sender) = sender else { + // Unknown and late responses are expected after cancellation. + continue; + }; + let result = match response.payload { + JsonRpcResponsePayload::Result(value) => Ok(value), + JsonRpcResponsePayload::Error(error) => Err(LspClientError::RemoteError { + code: error.code, + message: error.message, + data: error.data, + }), + }; + let _ = sender.send(result); + } + IncomingMessage::Request(request) => { + let response = router.route(&request).to_value(); + let enqueue = shared.writer.send(response); + tokio::pin!(enqueue); + let sent = tokio::select! { + _ = shared.shutdown.cancelled() => false, + result = &mut enqueue => result.is_ok(), + }; + if !sent { + terminate( + &shared, + LspClientError::Transport { + message: "protocol writer is unavailable".to_owned(), + }, + ) + .await; + break; + } + } + IncomingMessage::Notification(notification) => { + // Apply bounded backpressure instead of dropping diagnostics: + // losing a later empty diagnostics notification would retain + // stale errors in callers. + let deliver = notifications.send(notification); + tokio::pin!(deliver); + tokio::select! { + _ = shared.shutdown.cancelled() => { + terminate_if_needed(&shared).await; + break; + } + // A caller may intentionally decline the notification + // stream. Request/response handling remains usable. + _ = &mut deliver => {} + } + } + } + } +} + +async fn terminate_if_needed(shared: &Arc) { + if !shared.closed.load(Ordering::Acquire) { + terminate( + shared, + LspClientError::Closed { + message: "protocol shutdown requested".to_owned(), + }, + ) + .await; + } +} + +async fn terminate(shared: &Arc, error: LspClientError) { + if shared + .closed + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + + *shared + .close_reason + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(error.clone()); + shared.shutdown.cancel(); + let pending = { + let mut pending = shared.pending.lock().await; + std::mem::take(&mut *pending) + }; + for sender in pending.into_values() { + let _ = sender.send(Err(error.clone())); + } +} + +fn codec_client_error(error: LspCodecError) -> LspClientError { + match error { + LspCodecError::Io(error) => LspClientError::Transport { + message: error.to_string(), + }, + error => LspClientError::Protocol { + message: error.to_string(), + }, + } +} + +#[cfg(test)] +mod tests { + use futures::{SinkExt, StreamExt}; + use serde_json::json; + use tokio::io::DuplexStream; + use tokio_util::codec::Framed; + + use super::*; + use crate::code_intelligence::lsp::{ + message::{IncomingMessage, JsonRpcResponse}, + router::ServerRequestRouterConfig, + }; + + fn client_and_server() -> (LspClient, Framed) { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let client = LspClient::start( + client_io, + ServerRequestRouter::new(ServerRequestRouterConfig::default()), + ); + let server = Framed::new(server_io, LspCodec::default()); + (client, server) + } + + async fn next_server_message(server: &mut Framed) -> IncomingMessage { + let value = time::timeout(Duration::from_secs(1), server.next()) + .await + .expect("server message timed out") + .expect("client stream closed") + .expect("client frame failed"); + IncomingMessage::try_from(value).expect("client sent invalid message") + } + + #[tokio::test] + async fn completes_client_request_and_delivers_typed_notification() { + let (client, mut server) = client_and_server(); + let mut notifications = client.take_notifications().unwrap(); + let request_task = tokio::spawn({ + let client = client.clone(); + async move { + client + .request( + "workspace/symbol", + Some(json!({"query": "Client"})), + CancellationToken::new(), + Duration::from_secs(1), + ) + .await + } + }); + + let IncomingMessage::Request(request) = next_server_message(&mut server).await else { + panic!("expected request"); + }; + assert_eq!(request.method, "workspace/symbol"); + server + .send(JsonRpcResponse::success(request.id, json!([{"name": "Client"}])).to_value()) + .await + .unwrap(); + assert_eq!(request_task.await.unwrap().unwrap()[0]["name"], "Client"); + + server + .send(json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": {"diagnostics": []} + })) + .await + .unwrap(); + let notification = time::timeout(Duration::from_secs(1), notifications.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(notification.method, "textDocument/publishDiagnostics"); + assert_eq!(notification.params.unwrap()["diagnostics"], json!([])); + client.close().await; + } + + #[tokio::test] + async fn routes_server_request_with_string_id_and_unknown_method() { + let (client, mut server) = client_and_server(); + server + .send(json!({ + "jsonrpc": "2.0", + "id": "server-42", + "method": "unknown/method", + "params": {} + })) + .await + .unwrap(); + + let IncomingMessage::Response(response) = next_server_message(&mut server).await else { + panic!("expected response"); + }; + assert_eq!(response.id, RequestId::from("server-42")); + let JsonRpcResponsePayload::Error(error) = response.payload else { + panic!("expected method-not-found error"); + }; + assert_eq!(error.code, -32601); + client.close().await; + } + + #[tokio::test] + async fn cancellation_removes_pending_and_notifies_server() { + let (client, mut server) = client_and_server(); + let cancellation = CancellationToken::new(); + let request_task = tokio::spawn({ + let client = client.clone(); + let cancellation = cancellation.clone(); + async move { + client + .request( + "textDocument/references", + Some(json!({})), + cancellation, + Duration::from_secs(1), + ) + .await + } + }); + + let IncomingMessage::Request(request) = next_server_message(&mut server).await else { + panic!("expected request"); + }; + cancellation.cancel(); + let IncomingMessage::Notification(cancel) = next_server_message(&mut server).await else { + panic!("expected cancellation notification"); + }; + assert_eq!(cancel.method, "$/cancelRequest"); + assert_eq!(cancel.params.unwrap()["id"], request.id.to_value()); + assert_eq!(request_task.await.unwrap(), Err(LspClientError::Cancelled)); + client.close().await; + } + + #[tokio::test] + async fn timeout_removes_pending_and_notifies_server() { + let (client, mut server) = client_and_server(); + let request_task = tokio::spawn({ + let client = client.clone(); + async move { + client + .request( + "textDocument/definition", + Some(json!({})), + CancellationToken::new(), + Duration::from_millis(20), + ) + .await + } + }); + + let IncomingMessage::Request(request) = next_server_message(&mut server).await else { + panic!("expected request"); + }; + let IncomingMessage::Notification(cancel) = next_server_message(&mut server).await else { + panic!("expected cancellation notification"); + }; + assert_eq!(cancel.params.unwrap()["id"], request.id.to_value()); + assert!(matches!( + request_task.await.unwrap(), + Err(LspClientError::Timeout { .. }) + )); + client.close().await; + } + + #[tokio::test] + async fn cancellation_returns_promptly_when_writer_queue_is_saturated() { + let (client_io, _server_io) = tokio::io::duplex(64); + let client = LspClient::start_with_config( + client_io, + ServerRequestRouter::new(ServerRequestRouterConfig::default()), + LspClientConfig::new(1, 1), + ); + let large_params = Some(json!({"payload": "x".repeat(8 * 1024)})); + + // The first frame blocks in the tiny duplex buffer. The second fills + // the sole writer queue slot. + client + .notify("test/first", large_params.clone()) + .await + .unwrap(); + client.notify("test/second", large_params).await.unwrap(); + time::timeout(Duration::from_secs(1), async { + while client.inner.shared.writer.capacity() != 0 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + + let cancellation = CancellationToken::new(); + let pending = tokio::spawn({ + let client = client.clone(); + let cancellation = cancellation.clone(); + async move { + client + .request( + "workspace/symbol", + Some(json!({"query": "blocked"})), + cancellation, + Duration::from_secs(5), + ) + .await + } + }); + time::timeout(Duration::from_secs(1), async { + while client.inner.shared.pending.lock().await.is_empty() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + + cancellation.cancel(); + assert_eq!( + time::timeout(Duration::from_millis(100), pending) + .await + .expect("cancellation was blocked by the full writer queue") + .unwrap(), + Err(LspClientError::Cancelled) + ); + client.close().await; + } + + #[tokio::test] + async fn notification_backpressure_preserves_every_notification() { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let client = LspClient::start_with_config( + client_io, + ServerRequestRouter::new(ServerRequestRouterConfig::default()), + LspClientConfig::new(8, 1), + ); + let mut server = Framed::new(server_io, LspCodec::default()); + + server + .send(json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": {"version": 1, "diagnostics": [{"message": "old"}]} + })) + .await + .unwrap(); + server + .send(json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": {"version": 2, "diagnostics": []} + })) + .await + .unwrap(); + + // Delay taking the single-slot receiver so the second notification + // must wait rather than being dropped. + time::sleep(Duration::from_millis(20)).await; + let mut notifications = client.take_notifications().unwrap(); + let first = time::timeout(Duration::from_secs(1), notifications.recv()) + .await + .unwrap() + .unwrap(); + let second = time::timeout(Duration::from_secs(1), notifications.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(first.params.unwrap()["version"], 1); + assert_eq!(second.params.unwrap()["version"], 2); + client.close().await; + } + + #[tokio::test] + async fn eof_settles_all_pending_requests_once() { + let (client, mut server) = client_and_server(); + let request_task = tokio::spawn({ + let client = client.clone(); + async move { + client + .request( + "workspace/symbol", + Some(json!({"query": "x"})), + CancellationToken::new(), + Duration::from_secs(5), + ) + .await + } + }); + let _ = next_server_message(&mut server).await; + drop(server); + + let result = time::timeout(Duration::from_secs(1), request_task) + .await + .unwrap() + .unwrap(); + assert!(matches!( + result, + Err(LspClientError::Closed { .. } | LspClientError::Transport { .. }) + )); + assert!(client.is_closed()); + client.close().await; + } + + #[tokio::test] + async fn safely_ignores_late_response_after_timeout() { + let (client, mut server) = client_and_server(); + let timed_out = tokio::spawn({ + let client = client.clone(); + async move { + client + .request( + "textDocument/definition", + Some(json!({})), + CancellationToken::new(), + Duration::from_millis(20), + ) + .await + } + }); + let IncomingMessage::Request(first) = next_server_message(&mut server).await else { + panic!("expected request"); + }; + let _ = next_server_message(&mut server).await; + assert!(matches!( + timed_out.await.unwrap(), + Err(LspClientError::Timeout { .. }) + )); + server + .send(JsonRpcResponse::success(first.id, json!({"late": true})).to_value()) + .await + .unwrap(); + + let next = tokio::spawn({ + let client = client.clone(); + async move { + client + .request( + "workspace/symbol", + Some(json!({"query": "next"})), + CancellationToken::new(), + Duration::from_secs(1), + ) + .await + } + }); + let IncomingMessage::Request(second) = next_server_message(&mut server).await else { + panic!("expected second request"); + }; + server + .send(JsonRpcResponse::success(second.id, json!({"ok": true})).to_value()) + .await + .unwrap(); + assert_eq!(next.await.unwrap().unwrap(), json!({"ok": true})); + client.close().await; + } + + #[tokio::test] + async fn external_shutdown_token_stops_tasks_and_settles_pending() { + let (client, mut server) = client_and_server(); + let pending = tokio::spawn({ + let client = client.clone(); + async move { + client + .request( + "workspace/symbol", + Some(json!({"query": "pending"})), + CancellationToken::new(), + Duration::from_secs(5), + ) + .await + } + }); + let _ = next_server_message(&mut server).await; + client.shutdown_token().cancel(); + assert!(matches!( + time::timeout(Duration::from_secs(1), pending) + .await + .unwrap() + .unwrap(), + Err(LspClientError::Closed { .. }) + )); + client.close().await; + } +} diff --git a/core/src/code_intelligence/lsp/codec.rs b/core/src/code_intelligence/lsp/codec.rs new file mode 100644 index 00000000..2949a61e --- /dev/null +++ b/core/src/code_intelligence/lsp/codec.rs @@ -0,0 +1,363 @@ +use std::{io, str::Utf8Error}; + +use bytes::BytesMut; +use serde_json::Value; +use thiserror::Error; +use tokio_util::codec::{Decoder, Encoder}; + +const HEADER_TERMINATOR: &[u8; 4] = b"\r\n\r\n"; +pub(crate) const MAX_HEADER_BYTES: usize = 8 * 1024; +pub(crate) const MAX_BODY_BYTES: usize = 16 * 1024 * 1024; + +/// Errors produced while reading or writing protocol frames. +#[derive(Debug, Error)] +pub(crate) enum LspCodecError { + #[error("protocol I/O failed: {0}")] + Io(#[from] io::Error), + + #[error("protocol header exceeds the {max}-byte limit")] + HeaderTooLarge { max: usize }, + + #[error("protocol header is not valid UTF-8: {0}")] + InvalidHeaderEncoding(#[from] Utf8Error), + + #[error("malformed protocol header line: {line:?}")] + MalformedHeader { line: String }, + + #[error("protocol frame is missing Content-Length")] + MissingContentLength, + + #[error("protocol frame contains more than one Content-Length header")] + DuplicateContentLength, + + #[error("invalid Content-Length value: {value:?}")] + InvalidContentLength { value: String }, + + #[error("protocol body length {length} exceeds the {max}-byte limit")] + BodyTooLarge { length: usize, max: usize }, + + #[error("malformed protocol JSON body: {0}")] + MalformedJson(#[from] serde_json::Error), +} + +#[derive(Debug, Default)] +pub(crate) struct LspCodec { + state: DecodeState, +} + +#[derive(Debug, Default)] +enum DecodeState { + #[default] + Header, + Body { + content_length: usize, + }, +} + +impl Decoder for LspCodec { + type Item = Value; + type Error = LspCodecError; + + fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { + loop { + match self.state { + DecodeState::Header => { + let Some(header_end) = find_header_end(src) else { + // A terminator arriving after this point would make the + // complete header exceed the configured limit. + if src.len() >= MAX_HEADER_BYTES { + return Err(LspCodecError::HeaderTooLarge { + max: MAX_HEADER_BYTES, + }); + } + return Ok(None); + }; + + let framed_header_len = header_end + HEADER_TERMINATOR.len(); + if framed_header_len > MAX_HEADER_BYTES { + return Err(LspCodecError::HeaderTooLarge { + max: MAX_HEADER_BYTES, + }); + } + + let content_length = parse_content_length(&src[..header_end])?; + if content_length > MAX_BODY_BYTES { + return Err(LspCodecError::BodyTooLarge { + length: content_length, + max: MAX_BODY_BYTES, + }); + } + + let _ = src.split_to(framed_header_len); + self.state = DecodeState::Body { content_length }; + } + DecodeState::Body { content_length } => { + if src.len() < content_length { + return Ok(None); + } + + let body = src.split_to(content_length); + self.state = DecodeState::Header; + return serde_json::from_slice(&body) + .map(Some) + .map_err(LspCodecError::MalformedJson); + } + } + } + } +} + +impl Encoder for LspCodec { + type Error = LspCodecError; + + fn encode(&mut self, item: Value, dst: &mut BytesMut) -> Result<(), Self::Error> { + let body = serde_json::to_vec(&item)?; + if body.len() > MAX_BODY_BYTES { + return Err(LspCodecError::BodyTooLarge { + length: body.len(), + max: MAX_BODY_BYTES, + }); + } + + let header = format!("Content-Length: {}\r\n\r\n", body.len()); + dst.reserve(header.len() + body.len()); + dst.extend_from_slice(header.as_bytes()); + dst.extend_from_slice(&body); + Ok(()) + } +} + +fn find_header_end(src: &[u8]) -> Option { + src.windows(HEADER_TERMINATOR.len()) + .position(|window| window == HEADER_TERMINATOR) +} + +fn parse_content_length(header: &[u8]) -> Result { + let header = std::str::from_utf8(header)?; + let mut content_length = None; + + for line in header.split("\r\n") { + let Some((name, value)) = line.split_once(':') else { + return Err(LspCodecError::MalformedHeader { + line: line.to_owned(), + }); + }; + + let name = name.trim(); + if name.is_empty() { + return Err(LspCodecError::MalformedHeader { + line: line.to_owned(), + }); + } + + if name.eq_ignore_ascii_case("Content-Length") { + if content_length.is_some() { + return Err(LspCodecError::DuplicateContentLength); + } + + let value = value.trim(); + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(LspCodecError::InvalidContentLength { + value: value.to_owned(), + }); + } + + content_length = + Some( + value + .parse::() + .map_err(|_| LspCodecError::InvalidContentLength { + value: value.to_owned(), + })?, + ); + } + } + + content_length.ok_or(LspCodecError::MissingContentLength) +} + +#[cfg(test)] +mod tests { + use bytes::BytesMut; + use serde_json::{json, Value}; + use tokio_util::codec::{Decoder, Encoder}; + + use super::{LspCodec, LspCodecError, MAX_BODY_BYTES, MAX_HEADER_BYTES}; + + fn raw_frame(headers: &str, body: &[u8]) -> Vec { + let mut frame = Vec::with_capacity(headers.len() + 4 + body.len()); + frame.extend_from_slice(headers.as_bytes()); + frame.extend_from_slice(b"\r\n\r\n"); + frame.extend_from_slice(body); + frame + } + + #[test] + fn decodes_fragmented_header_and_body() { + let body = br#"{"jsonrpc":"2.0","message":"ready"}"#; + let headers = format!( + "content-length: {}\r\nContent-Type: application/vscode-jsonrpc; charset=utf-8", + body.len() + ); + let frame = raw_frame(&headers, body); + let header_end = frame + .windows(4) + .position(|window| window == b"\r\n\r\n") + .unwrap(); + + let mut codec = LspCodec::default(); + let mut src = BytesMut::new(); + + src.extend_from_slice(&frame[..8]); + assert!(codec.decode(&mut src).unwrap().is_none()); + + src.extend_from_slice(&frame[8..header_end + 6]); + assert!(codec.decode(&mut src).unwrap().is_none()); + + src.extend_from_slice(&frame[header_end + 6..]); + assert_eq!( + codec.decode(&mut src).unwrap(), + Some(json!({"jsonrpc": "2.0", "message": "ready"})) + ); + assert!(src.is_empty()); + } + + #[test] + fn decodes_multiple_frames_from_one_buffer() { + let mut codec = LspCodec::default(); + let mut src = BytesMut::new(); + codec.encode(json!({"id": 1}), &mut src).unwrap(); + codec.encode(json!({"id": 2}), &mut src).unwrap(); + + assert_eq!(codec.decode(&mut src).unwrap(), Some(json!({"id": 1}))); + assert_eq!(codec.decode(&mut src).unwrap(), Some(json!({"id": 2}))); + assert!(codec.decode(&mut src).unwrap().is_none()); + } + + #[test] + fn encoded_content_length_counts_unicode_bytes() { + let value = json!({"text": "你好 👋"}); + let mut codec = LspCodec::default(); + let mut frame = BytesMut::new(); + codec.encode(value.clone(), &mut frame).unwrap(); + + let header_end = frame + .windows(4) + .position(|window| window == b"\r\n\r\n") + .unwrap(); + let header = std::str::from_utf8(&frame[..header_end]).unwrap(); + let advertised = header + .strip_prefix("Content-Length: ") + .unwrap() + .parse::() + .unwrap(); + assert_eq!(advertised, frame.len() - header_end - 4); + assert_eq!(codec.decode(&mut frame).unwrap(), Some(value)); + } + + #[test] + fn rejects_oversized_header_and_body() { + let mut codec = LspCodec::default(); + let mut oversized_header = BytesMut::from(&vec![b'x'; MAX_HEADER_BYTES][..]); + assert!(matches!( + codec.decode(&mut oversized_header), + Err(LspCodecError::HeaderTooLarge { + max: MAX_HEADER_BYTES + }) + )); + + let mut codec = LspCodec::default(); + let frame = format!("Content-Length: {}\r\n\r\n", MAX_BODY_BYTES + 1); + let mut oversized_body = BytesMut::from(frame.as_bytes()); + assert!(matches!( + codec.decode(&mut oversized_body), + Err(LspCodecError::BodyTooLarge { + length, + max: MAX_BODY_BYTES + }) if length == MAX_BODY_BYTES + 1 + )); + } + + #[test] + fn rejects_missing_duplicate_and_invalid_content_length() { + let cases = [ + ( + raw_frame("Content-Type: application/json", b"{}"), + "missing", + ), + ( + raw_frame("Content-Length: 2\r\ncontent-length: 2", b"{}"), + "duplicate", + ), + (raw_frame("Content-Length: nope", b"{}"), "invalid"), + ]; + + for (frame, expected) in cases { + let mut codec = LspCodec::default(); + let mut src = BytesMut::from(frame.as_slice()); + let error = codec.decode(&mut src).unwrap_err(); + match expected { + "missing" => assert!(matches!(error, LspCodecError::MissingContentLength)), + "duplicate" => assert!(matches!(error, LspCodecError::DuplicateContentLength)), + "invalid" => { + assert!(matches!(error, LspCodecError::InvalidContentLength { .. })) + } + _ => unreachable!(), + } + } + } + + #[test] + fn rejects_malformed_header_and_json() { + let mut codec = LspCodec::default(); + let mut malformed_header = BytesMut::from(&b"Content-Length 2\r\n\r\n{}"[..]); + assert!(matches!( + codec.decode(&mut malformed_header), + Err(LspCodecError::MalformedHeader { .. }) + )); + + let body = b"{not json}"; + let frame = raw_frame(&format!("Content-Length: {}", body.len()), body); + let mut codec = LspCodec::default(); + let mut malformed_json = BytesMut::from(frame.as_slice()); + assert!(matches!( + codec.decode(&mut malformed_json), + Err(LspCodecError::MalformedJson(_)) + )); + } + + #[test] + fn encode_roundtrips_json_values() { + let values: Vec = vec![ + json!({"jsonrpc": "2.0", "id": 7, "method": "initialize"}), + json!([true, null, 42, "text"]), + ]; + let mut codec = LspCodec::default(); + let mut frame = BytesMut::new(); + + for value in &values { + codec.encode(value.clone(), &mut frame).unwrap(); + } + + for value in values { + assert_eq!(codec.decode(&mut frame).unwrap(), Some(value)); + } + assert!(frame.is_empty()); + } + + #[test] + fn encode_rejects_oversized_json() { + let value = Value::String("x".repeat(MAX_BODY_BYTES)); + let mut codec = LspCodec::default(); + let mut frame = BytesMut::new(); + + assert!(matches!( + codec.encode(value, &mut frame), + Err(LspCodecError::BodyTooLarge { + length, + max: MAX_BODY_BYTES + }) if length == MAX_BODY_BYTES + 2 + )); + assert!(frame.is_empty()); + } +} diff --git a/core/src/code_intelligence/lsp/initialize.rs b/core/src/code_intelligence/lsp/initialize.rs new file mode 100644 index 00000000..b6919f10 --- /dev/null +++ b/core/src/code_intelligence/lsp/initialize.rs @@ -0,0 +1,673 @@ +use std::{str::FromStr, time::Duration}; + +use lsp_types::{ + ClientCapabilities, ClientInfo, DeclarationCapability, DiagnosticClientCapabilities, + DocumentSymbolClientCapabilities, DynamicRegistrationClientCapabilities, + GeneralClientCapabilities, GotoCapability, ImplementationProviderCapability, InitializeParams, + InitializeResult, InitializedParams, OneOf, PositionEncodingKind, + PublishDiagnosticsClientCapabilities, ServerCapabilities, ServerInfo, + TextDocumentClientCapabilities, TextDocumentSyncCapability, TextDocumentSyncClientCapabilities, + TextDocumentSyncKind, TextDocumentSyncSaveOptions, Uri, WorkDoneProgressParams, + WorkspaceClientCapabilities, WorkspaceFolder as LspWorkspaceFolder, + WorkspaceSymbolClientCapabilities, +}; +use serde_json::Value; +use tokio_util::sync::CancellationToken; +use url::Url; + +use super::{ + client::{LspClient, LspClientError}, + router::WorkspaceFolder, +}; +use crate::code_intelligence::CodeIntelligenceCapabilities; + +const INITIALIZE_METHOD: &str = "initialize"; +const INITIALIZED_METHOD: &str = "initialized"; + +/// Immutable data advertised while starting one language server. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct InitializeConfig { + canonical_root: Url, + workspace_folders: Vec, + initialization_options: Option, + client_name: String, + client_version: String, +} + +impl InitializeConfig { + pub(crate) fn new( + canonical_root: Url, + workspace_folders: Vec, + initialization_options: Option, + client_name: impl Into, + client_version: impl Into, + ) -> Self { + Self { + canonical_root, + workspace_folders, + initialization_options, + client_name: client_name.into(), + client_version: client_version.into(), + } + } +} + +/// Read-only capabilities negotiated with an initialized language server. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct InitializedServer { + pub(crate) capabilities: CodeIntelligenceCapabilities, + pub(crate) supports_pull_diagnostics: bool, + pub(crate) supports_publish_diagnostics: bool, + pub(crate) text_sync_mode: ServerTextSyncMode, + pub(crate) supports_open_close: bool, + pub(crate) supports_did_save: bool, + pub(crate) server_info: Option, +} + +/// Text synchronization mode selected by a language server. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ServerTextSyncMode { + None, + Full, + /// Saved-only callers must close and reopen a changed document instead of + /// sending a full-content change under an incremental contract. + Incremental, +} + +/// Complete the protocol handshake for one language-server connection. +/// +/// The `initialized` notification is emitted only after the server has +/// returned and the client has validated a successful `initialize` result. +pub(crate) async fn initialize( + client: &LspClient, + config: &InitializeConfig, + cancellation: CancellationToken, + timeout: Duration, +) -> Result { + let params = initialize_params(config)?; + let params = serde_json::to_value(params).map_err(|error| LspClientError::Protocol { + message: format!("failed to encode initialize parameters: {error}"), + })?; + let response = client + .request(INITIALIZE_METHOD, Some(params), cancellation, timeout) + .await?; + let result: InitializeResult = + serde_json::from_value(response).map_err(|error| LspClientError::Protocol { + message: format!("invalid initialize result: {error}"), + })?; + + let initialized = normalized_server(result)?; + let params = + serde_json::to_value(InitializedParams {}).map_err(|error| LspClientError::Protocol { + message: format!("failed to encode initialized parameters: {error}"), + })?; + client.notify(INITIALIZED_METHOD, Some(params)).await?; + + Ok(initialized) +} + +#[allow(deprecated)] +fn initialize_params(config: &InitializeConfig) -> Result { + let root_uri = protocol_uri(&config.canonical_root)?; + let workspace_folders = config + .workspace_folders + .iter() + .map(|folder| { + Ok(LspWorkspaceFolder { + uri: protocol_uri(folder.uri())?, + name: folder.name().to_owned(), + }) + }) + .collect::, LspClientError>>()?; + + Ok(InitializeParams { + process_id: Some(std::process::id()), + root_path: None, + root_uri: Some(root_uri), + initialization_options: config.initialization_options.clone(), + capabilities: client_capabilities(), + trace: None, + workspace_folders: Some(workspace_folders), + client_info: Some(ClientInfo { + name: config.client_name.clone(), + version: Some(config.client_version.clone()), + }), + locale: None, + work_done_progress_params: WorkDoneProgressParams::default(), + }) +} + +fn protocol_uri(uri: &Url) -> Result { + Uri::from_str(uri.as_str()).map_err(|error| LspClientError::Protocol { + message: format!("invalid language-server URI '{}': {error}", uri.as_str()), + }) +} + +fn client_capabilities() -> ClientCapabilities { + let goto = || GotoCapability { + dynamic_registration: Some(false), + link_support: Some(true), + }; + + ClientCapabilities { + workspace: Some(WorkspaceClientCapabilities { + apply_edit: Some(false), + workspace_edit: None, + symbol: Some(WorkspaceSymbolClientCapabilities { + dynamic_registration: Some(false), + ..WorkspaceSymbolClientCapabilities::default() + }), + workspace_folders: Some(true), + configuration: Some(true), + ..WorkspaceClientCapabilities::default() + }), + text_document: Some(TextDocumentClientCapabilities { + synchronization: Some(TextDocumentSyncClientCapabilities { + dynamic_registration: Some(false), + will_save: Some(false), + will_save_wait_until: Some(false), + did_save: Some(true), + }), + references: Some(DynamicRegistrationClientCapabilities { + dynamic_registration: Some(false), + }), + document_symbol: Some(DocumentSymbolClientCapabilities { + dynamic_registration: Some(false), + hierarchical_document_symbol_support: Some(true), + ..DocumentSymbolClientCapabilities::default() + }), + declaration: Some(goto()), + definition: Some(goto()), + implementation: Some(goto()), + rename: None, + publish_diagnostics: Some(PublishDiagnosticsClientCapabilities { + related_information: Some(true), + version_support: Some(true), + code_description_support: Some(true), + data_support: Some(false), + ..PublishDiagnosticsClientCapabilities::default() + }), + diagnostic: Some(DiagnosticClientCapabilities { + dynamic_registration: Some(false), + related_document_support: Some(false), + }), + ..TextDocumentClientCapabilities::default() + }), + general: Some(GeneralClientCapabilities { + position_encodings: Some(vec![PositionEncodingKind::UTF16]), + ..GeneralClientCapabilities::default() + }), + ..ClientCapabilities::default() + } +} + +fn normalized_server(result: InitializeResult) -> Result { + validate_position_encoding(&result.capabilities)?; + let (text_sync_mode, supports_open_close, supports_did_save) = + normalize_text_sync(result.capabilities.text_document_sync.as_ref())?; + let supports_pull_diagnostics = result.capabilities.diagnostic_provider.is_some(); + let supports_publish_diagnostics = true; + let capabilities = normalize_capabilities(&result.capabilities, supports_publish_diagnostics); + + Ok(InitializedServer { + capabilities, + supports_pull_diagnostics, + supports_publish_diagnostics, + text_sync_mode, + supports_open_close, + supports_did_save, + server_info: result.server_info, + }) +} + +fn validate_position_encoding(server: &ServerCapabilities) -> Result<(), LspClientError> { + let Some(encoding) = server.position_encoding.as_ref() else { + // UTF-16 is the protocol default when the server omits this field. + return Ok(()); + }; + if encoding == &PositionEncodingKind::UTF16 { + return Ok(()); + } + + Err(LspClientError::Protocol { + message: format!( + "language server selected unsupported position encoding '{}'; UTF-16 is required", + encoding.as_str() + ), + }) +} + +fn normalize_text_sync( + capability: Option<&TextDocumentSyncCapability>, +) -> Result<(ServerTextSyncMode, bool, bool), LspClientError> { + match capability { + None | Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::NONE)) => { + Ok((ServerTextSyncMode::None, false, false)) + } + Some(TextDocumentSyncCapability::Kind(kind)) => { + let mode = text_sync_mode(*kind)?; + Ok((mode, true, false)) + } + Some(TextDocumentSyncCapability::Options(options)) => { + let mode = match options.change { + Some(kind) => text_sync_mode(kind)?, + None => ServerTextSyncMode::None, + }; + let supports_did_save = match options.save.as_ref() { + Some(TextDocumentSyncSaveOptions::Supported(supported)) => *supported, + Some(TextDocumentSyncSaveOptions::SaveOptions(_)) => true, + None => false, + }; + Ok((mode, options.open_close.unwrap_or(false), supports_did_save)) + } + } +} + +fn text_sync_mode(kind: TextDocumentSyncKind) -> Result { + if kind == TextDocumentSyncKind::NONE { + Ok(ServerTextSyncMode::None) + } else if kind == TextDocumentSyncKind::FULL { + Ok(ServerTextSyncMode::Full) + } else if kind == TextDocumentSyncKind::INCREMENTAL { + Ok(ServerTextSyncMode::Incremental) + } else { + Err(LspClientError::Protocol { + message: format!("language server selected unsupported text sync kind {kind:?}"), + }) + } +} + +fn normalize_capabilities( + server: &ServerCapabilities, + supports_publish_diagnostics: bool, +) -> CodeIntelligenceCapabilities { + let supports_pull_diagnostics = server.diagnostic_provider.is_some(); + CodeIntelligenceCapabilities { + document_symbols: one_of_enabled(server.document_symbol_provider.as_ref()), + workspace_symbols: one_of_enabled(server.workspace_symbol_provider.as_ref()), + definition: one_of_enabled(server.definition_provider.as_ref()), + declaration: declaration_enabled(server.declaration_provider.as_ref()), + references: one_of_enabled(server.references_provider.as_ref()), + implementations: implementation_enabled(server.implementation_provider.as_ref()), + diagnostics: supports_pull_diagnostics || supports_publish_diagnostics, + } +} + +fn one_of_enabled(capability: Option<&OneOf>) -> bool { + match capability { + Some(OneOf::Left(enabled)) => *enabled, + Some(OneOf::Right(_)) => true, + None => false, + } +} + +fn declaration_enabled(capability: Option<&DeclarationCapability>) -> bool { + match capability { + Some(DeclarationCapability::Simple(enabled)) => *enabled, + Some(DeclarationCapability::RegistrationOptions(_) | DeclarationCapability::Options(_)) => { + true + } + None => false, + } +} + +fn implementation_enabled(capability: Option<&ImplementationProviderCapability>) -> bool { + match capability { + Some(ImplementationProviderCapability::Simple(enabled)) => *enabled, + Some(ImplementationProviderCapability::Options(_)) => true, + None => false, + } +} + +#[cfg(test)] +mod tests { + use futures::{SinkExt, StreamExt}; + use serde_json::json; + use tokio::{io::DuplexStream, time}; + use tokio_util::codec::Framed; + + use super::*; + use crate::code_intelligence::lsp::{ + codec::LspCodec, + message::{IncomingMessage, JsonRpcResponse}, + router::{ServerRequestRouter, ServerRequestRouterConfig}, + }; + + fn client_and_server() -> (LspClient, Framed) { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let client = LspClient::start( + client_io, + ServerRequestRouter::new(ServerRequestRouterConfig::default()), + ); + (client, Framed::new(server_io, LspCodec::default())) + } + + fn config() -> InitializeConfig { + InitializeConfig::new( + Url::parse("file:///workspace").unwrap(), + vec![ + WorkspaceFolder::new(Url::parse("file:///workspace/core").unwrap(), "core"), + WorkspaceFolder::new(Url::parse("file:///workspace/web").unwrap(), "web"), + ], + Some(json!({"check": {"command": "clippy"}})), + "a3s-code", + "5.2.4", + ) + } + + async fn next_server_message(server: &mut Framed) -> IncomingMessage { + let value = time::timeout(Duration::from_secs(1), server.next()) + .await + .expect("server message timed out") + .expect("client stream closed") + .expect("client frame failed"); + IncomingMessage::try_from(value).expect("client sent invalid message") + } + + async fn assert_no_server_message(server: &mut Framed) { + assert!( + time::timeout(Duration::from_millis(25), server.next()) + .await + .is_err(), + "client sent an unexpected protocol message" + ); + } + + #[tokio::test] + async fn initializes_in_order_and_normalizes_read_only_capabilities() { + let (client, mut server) = client_and_server(); + let task = tokio::spawn({ + let client = client.clone(); + async move { + initialize( + &client, + &config(), + CancellationToken::new(), + Duration::from_secs(1), + ) + .await + } + }); + + let IncomingMessage::Request(request) = next_server_message(&mut server).await else { + panic!("expected initialize request"); + }; + assert_eq!(request.method, INITIALIZE_METHOD); + let params = request.params.as_ref().expect("initialize params"); + assert_eq!(params["rootUri"], "file:///workspace"); + assert_eq!( + params["workspaceFolders"], + json!([ + {"uri": "file:///workspace/core", "name": "core"}, + {"uri": "file:///workspace/web", "name": "web"} + ]) + ); + assert_eq!( + params["initializationOptions"], + json!({"check": {"command": "clippy"}}) + ); + assert_eq!( + params["clientInfo"], + json!({"name": "a3s-code", "version": "5.2.4"}) + ); + assert!(params["processId"].is_number()); + assert_eq!(params["capabilities"]["workspace"]["applyEdit"], false); + assert!(params["capabilities"]["workspace"] + .get("workspaceEdit") + .is_none()); + assert!(params["capabilities"]["textDocument"] + .get("rename") + .is_none()); + assert_eq!( + params["capabilities"]["textDocument"]["documentSymbol"] + ["hierarchicalDocumentSymbolSupport"], + true + ); + for capability in ["declaration", "definition", "references", "implementation"] { + assert!(params["capabilities"]["textDocument"] + .get(capability) + .is_some()); + } + assert!(params["capabilities"]["workspace"].get("symbol").is_some()); + assert!(params["capabilities"]["textDocument"] + .get("publishDiagnostics") + .is_some()); + assert!(params["capabilities"]["textDocument"] + .get("diagnostic") + .is_some()); + assert_eq!( + params["capabilities"]["general"]["positionEncodings"], + json!(["utf-16"]) + ); + + assert_no_server_message(&mut server).await; + server + .send( + JsonRpcResponse::success( + request.id, + json!({ + "capabilities": { + "positionEncoding": "utf-16", + "textDocumentSync": { + "openClose": true, + "change": 2, + "save": {"includeText": true} + }, + "documentSymbolProvider": {"label": "outline"}, + "workspaceSymbolProvider": true, + "definitionProvider": false, + "declarationProvider": {}, + "referencesProvider": {}, + "implementationProvider": {"documentSelector": null}, + "diagnosticProvider": { + "identifier": "saved", + "interFileDependencies": true, + "workspaceDiagnostics": true + }, + "renameProvider": true + }, + "serverInfo": {"name": "test-server", "version": "1.2.3"} + }), + ) + .to_value(), + ) + .await + .unwrap(); + + let IncomingMessage::Notification(notification) = next_server_message(&mut server).await + else { + panic!("expected initialized notification"); + }; + assert_eq!(notification.method, INITIALIZED_METHOD); + assert_eq!(notification.params, Some(json!({}))); + + let initialized = task.await.unwrap().unwrap(); + assert_eq!( + initialized.capabilities, + CodeIntelligenceCapabilities { + document_symbols: true, + workspace_symbols: true, + definition: false, + declaration: true, + references: true, + implementations: true, + diagnostics: true, + } + ); + assert!(initialized.supports_pull_diagnostics); + assert!(initialized.supports_publish_diagnostics); + assert_eq!(initialized.text_sync_mode, ServerTextSyncMode::Incremental); + assert!(initialized.supports_open_close); + assert!(initialized.supports_did_save); + assert_eq!( + initialized.server_info, + Some(ServerInfo { + name: "test-server".to_owned(), + version: Some("1.2.3".to_owned()), + }) + ); + client.close().await; + } + + #[tokio::test] + async fn publish_diagnostics_remain_available_without_pull_support() { + let (client, mut server) = client_and_server(); + let task = tokio::spawn({ + let client = client.clone(); + async move { + initialize( + &client, + &config(), + CancellationToken::new(), + Duration::from_secs(1), + ) + .await + } + }); + let IncomingMessage::Request(request) = next_server_message(&mut server).await else { + panic!("expected initialize request"); + }; + server + .send(JsonRpcResponse::success(request.id, json!({"capabilities": {}})).to_value()) + .await + .unwrap(); + let _ = next_server_message(&mut server).await; + + let initialized = task.await.unwrap().unwrap(); + assert!(!initialized.supports_pull_diagnostics); + assert!(initialized.supports_publish_diagnostics); + assert!(initialized.capabilities.diagnostics); + assert_eq!(initialized.text_sync_mode, ServerTextSyncMode::None); + assert!(!initialized.supports_open_close); + assert!(!initialized.supports_did_save); + client.close().await; + } + + #[test] + fn normalizes_legacy_and_options_text_sync_capabilities() { + fn sync(value: Value) -> (ServerTextSyncMode, bool, bool) { + let capability = serde_json::from_value(value).unwrap(); + normalize_text_sync(Some(&capability)).unwrap() + } + + assert_eq!(sync(json!(0)), (ServerTextSyncMode::None, false, false)); + assert_eq!(sync(json!(1)), (ServerTextSyncMode::Full, true, false)); + assert_eq!( + sync(json!(2)), + (ServerTextSyncMode::Incremental, true, false) + ); + assert_eq!( + sync(json!({"openClose": false, "change": 1, "save": true})), + (ServerTextSyncMode::Full, false, true) + ); + assert_eq!( + sync(json!({"openClose": true, "change": 0, "save": false})), + (ServerTextSyncMode::None, true, false) + ); + assert_eq!( + sync(json!({"save": {"includeText": false}})), + (ServerTextSyncMode::None, false, true) + ); + } + + #[tokio::test] + async fn rejects_non_utf16_server_encoding_before_initialized_notification() { + let (client, mut server) = client_and_server(); + let task = tokio::spawn({ + let client = client.clone(); + async move { + initialize( + &client, + &config(), + CancellationToken::new(), + Duration::from_secs(1), + ) + .await + } + }); + let IncomingMessage::Request(request) = next_server_message(&mut server).await else { + panic!("expected initialize request"); + }; + server + .send( + JsonRpcResponse::success( + request.id, + json!({"capabilities": {"positionEncoding": "utf-8"}}), + ) + .to_value(), + ) + .await + .unwrap(); + + let error = task.await.unwrap().unwrap_err(); + assert!(matches!(error, LspClientError::Protocol { .. })); + assert!(error.to_string().contains("utf-8")); + assert!(error.to_string().contains("UTF-16")); + assert_no_server_message(&mut server).await; + client.close().await; + } + + #[tokio::test] + async fn remote_error_does_not_send_initialized_notification() { + let (client, mut server) = client_and_server(); + let task = tokio::spawn({ + let client = client.clone(); + async move { + initialize( + &client, + &config(), + CancellationToken::new(), + Duration::from_secs(1), + ) + .await + } + }); + let IncomingMessage::Request(request) = next_server_message(&mut server).await else { + panic!("expected initialize request"); + }; + server + .send( + JsonRpcResponse::error( + request.id, + -32002, + "server is not ready", + Some(json!({"retry": false})), + ) + .to_value(), + ) + .await + .unwrap(); + + assert!(matches!( + task.await.unwrap(), + Err(LspClientError::RemoteError { code: -32002, .. }) + )); + assert_no_server_message(&mut server).await; + client.close().await; + } + + #[tokio::test] + async fn cancellation_aborts_initialize_without_initialized_notification() { + let (client, mut server) = client_and_server(); + let cancellation = CancellationToken::new(); + let task = tokio::spawn({ + let client = client.clone(); + let cancellation = cancellation.clone(); + async move { initialize(&client, &config(), cancellation, Duration::from_secs(1)).await } + }); + let IncomingMessage::Request(request) = next_server_message(&mut server).await else { + panic!("expected initialize request"); + }; + cancellation.cancel(); + + let IncomingMessage::Notification(notification) = next_server_message(&mut server).await + else { + panic!("expected cancellation notification"); + }; + assert_eq!(notification.method, "$/cancelRequest"); + assert_eq!(notification.params.unwrap()["id"], request.id.to_value()); + assert_eq!(task.await.unwrap(), Err(LspClientError::Cancelled)); + assert_no_server_message(&mut server).await; + client.close().await; + } +} diff --git a/core/src/code_intelligence/lsp/mapping.rs b/core/src/code_intelligence/lsp/mapping.rs new file mode 100644 index 00000000..2c8c8276 --- /dev/null +++ b/core/src/code_intelligence/lsp/mapping.rs @@ -0,0 +1,761 @@ +use super::super::{ + CodeDiagnostic, CodeDiagnosticSeverity, CodeLocation, CodePosition, CodeRange, CodeSymbolKind, + DocumentSymbol, SymbolInformation, +}; +use crate::workspace::WorkspacePath; +use lsp_types::{ + Diagnostic, DiagnosticSeverity, DocumentSymbolResponse, GotoDefinitionResponse, Location, + LocationLink, NumberOrString, OneOf, SymbolKind, Uri, WorkspaceSymbol, WorkspaceSymbolResponse, +}; +use std::io; +use std::path::{Path, PathBuf}; +use thiserror::Error; +use url::Url; + +/// Failure while normalizing a language-server response into core types. +#[derive(Debug, Error)] +pub(crate) enum MappingError { + #[error("workspace root must be an absolute canonical path: {root:?}")] + InvalidWorkspaceRoot { root: PathBuf }, + + #[error("unsupported resource URI scheme: {scheme}")] + UnsupportedUriScheme { scheme: String }, + + #[error("resource URI is not a valid local file URI: {uri}")] + InvalidFileUri { uri: String }, + + #[error("resource path does not exist: {path:?}")] + PathDoesNotExist { path: PathBuf }, + + #[error("resource path could not be resolved: {path:?}: {source}")] + PathResolution { + path: PathBuf, + #[source] + source: io::Error, + }, + + #[error("resource path is outside the workspace: {path:?}")] + OutsideWorkspace { path: PathBuf }, + + #[error("workspace-relative resource path is not valid UTF-8: {path:?}")] + NonUtf8WorkspacePath { path: PathBuf }, + + #[error("protocol range ends before it starts: {start:?}..{end:?}")] + InvalidRange { + start: CodePosition, + end: CodePosition, + }, + + #[error("protocol selection range {selection:?} is outside enclosing range {enclosing:?}")] + SelectionOutsideRange { + selection: CodeRange, + enclosing: CodeRange, + }, +} + +pub(crate) fn map_position(position: lsp_types::Position) -> CodePosition { + CodePosition::new(position.line, position.character) +} + +pub(crate) fn map_range(range: lsp_types::Range) -> Result { + let start = map_position(range.start); + let end = map_position(range.end); + if end < start { + return Err(MappingError::InvalidRange { start, end }); + } + + Ok(CodeRange::new(start, end)) +} + +pub(crate) fn map_document_symbol( + symbol: lsp_types::DocumentSymbol, +) -> Result { + let range = map_range(symbol.range)?; + let selection_range = map_range(symbol.selection_range)?; + ensure_range_contains(range, selection_range)?; + let children = symbol + .children + .unwrap_or_default() + .into_iter() + .map(map_document_symbol) + .collect::>()?; + + Ok(DocumentSymbol { + name: symbol.name, + detail: symbol.detail, + kind: map_symbol_kind(symbol.kind), + range, + selection_range, + children, + }) +} + +pub(crate) async fn map_document_symbol_response( + canonical_workspace_root: &Path, + response: DocumentSymbolResponse, +) -> Result, MappingError> { + match response { + DocumentSymbolResponse::Nested(symbols) => symbols + .into_iter() + .map(map_document_symbol) + .collect::>(), + DocumentSymbolResponse::Flat(symbols) => { + let mut mapped_symbols = Vec::with_capacity(symbols.len()); + for symbol in symbols { + let mapped = map_symbol_information(canonical_workspace_root, symbol).await?; + mapped_symbols.push(DocumentSymbol { + name: mapped.name, + detail: None, + kind: mapped.kind, + range: mapped.location.range, + selection_range: mapped.location.range, + children: Vec::new(), + }); + } + Ok(mapped_symbols) + } + } +} + +pub(crate) async fn map_symbol_information( + canonical_workspace_root: &Path, + symbol: lsp_types::SymbolInformation, +) -> Result { + Ok(SymbolInformation { + name: symbol.name, + kind: map_symbol_kind(symbol.kind), + location: map_location(canonical_workspace_root, symbol.location).await?, + container_name: symbol.container_name, + }) +} + +pub(crate) async fn map_workspace_symbol( + canonical_workspace_root: &Path, + symbol: WorkspaceSymbol, +) -> Result { + let location = match symbol.location { + OneOf::Left(location) => map_location(canonical_workspace_root, location).await?, + OneOf::Right(location) => CodeLocation { + path: file_uri_to_workspace_path(canonical_workspace_root, &location.uri).await?, + range: CodeRange::default(), + }, + }; + + Ok(SymbolInformation { + name: symbol.name, + kind: map_symbol_kind(symbol.kind), + location, + container_name: symbol.container_name, + }) +} + +pub(crate) async fn map_workspace_symbol_response( + canonical_workspace_root: &Path, + response: WorkspaceSymbolResponse, +) -> Result, MappingError> { + let mut mapped_symbols = Vec::new(); + match response { + WorkspaceSymbolResponse::Flat(symbols) => { + mapped_symbols.reserve(symbols.len()); + for symbol in symbols { + mapped_symbols + .push(map_symbol_information(canonical_workspace_root, symbol).await?); + } + } + WorkspaceSymbolResponse::Nested(symbols) => { + mapped_symbols.reserve(symbols.len()); + for symbol in symbols { + mapped_symbols.push(map_workspace_symbol(canonical_workspace_root, symbol).await?); + } + } + } + Ok(mapped_symbols) +} + +pub(crate) async fn map_location( + canonical_workspace_root: &Path, + location: Location, +) -> Result { + Ok(CodeLocation { + path: file_uri_to_workspace_path(canonical_workspace_root, &location.uri).await?, + range: map_range(location.range)?, + }) +} + +pub(crate) async fn map_location_link( + canonical_workspace_root: &Path, + link: LocationLink, +) -> Result { + let target_range = map_range(link.target_range)?; + let target_selection_range = map_range(link.target_selection_range)?; + ensure_range_contains(target_range, target_selection_range)?; + + Ok(CodeLocation { + path: file_uri_to_workspace_path(canonical_workspace_root, &link.target_uri).await?, + range: target_selection_range, + }) +} + +pub(crate) async fn map_definition_response( + canonical_workspace_root: &Path, + response: Option, +) -> Result, MappingError> { + match response { + None => Ok(Vec::new()), + Some(GotoDefinitionResponse::Scalar(location)) => Ok(vec![ + map_location(canonical_workspace_root, location).await?, + ]), + Some(GotoDefinitionResponse::Array(locations)) => { + let mut mapped = Vec::with_capacity(locations.len()); + for location in locations { + mapped.push(map_location(canonical_workspace_root, location).await?); + } + Ok(mapped) + } + Some(GotoDefinitionResponse::Link(links)) => { + let mut mapped = Vec::with_capacity(links.len()); + for link in links { + mapped.push(map_location_link(canonical_workspace_root, link).await?); + } + Ok(mapped) + } + } +} + +pub(crate) async fn map_diagnostics( + canonical_workspace_root: &Path, + uri: &Uri, + diagnostics: Vec, +) -> Result, MappingError> { + let path = file_uri_to_workspace_path(canonical_workspace_root, uri).await?; + diagnostics + .into_iter() + .map(|diagnostic| { + Ok(CodeDiagnostic { + location: CodeLocation { + path: path.clone(), + range: map_range(diagnostic.range)?, + }, + severity: diagnostic.severity.and_then(map_diagnostic_severity), + code: diagnostic.code.map(|code| match code { + NumberOrString::Number(number) => number.to_string(), + NumberOrString::String(text) => text, + }), + source: diagnostic.source, + message: diagnostic.message, + }) + }) + .collect() +} + +pub(crate) fn map_symbol_kind(kind: SymbolKind) -> CodeSymbolKind { + match kind { + SymbolKind::FILE => CodeSymbolKind::File, + SymbolKind::MODULE => CodeSymbolKind::Module, + SymbolKind::NAMESPACE => CodeSymbolKind::Namespace, + SymbolKind::PACKAGE => CodeSymbolKind::Package, + SymbolKind::CLASS => CodeSymbolKind::Class, + SymbolKind::METHOD => CodeSymbolKind::Method, + SymbolKind::PROPERTY => CodeSymbolKind::Property, + SymbolKind::FIELD => CodeSymbolKind::Field, + SymbolKind::CONSTRUCTOR => CodeSymbolKind::Constructor, + SymbolKind::ENUM => CodeSymbolKind::Enum, + SymbolKind::INTERFACE => CodeSymbolKind::Interface, + SymbolKind::FUNCTION => CodeSymbolKind::Function, + SymbolKind::VARIABLE => CodeSymbolKind::Variable, + SymbolKind::CONSTANT => CodeSymbolKind::Constant, + SymbolKind::STRING => CodeSymbolKind::String, + SymbolKind::NUMBER => CodeSymbolKind::Number, + SymbolKind::BOOLEAN => CodeSymbolKind::Boolean, + SymbolKind::ARRAY => CodeSymbolKind::Array, + SymbolKind::OBJECT => CodeSymbolKind::Object, + SymbolKind::KEY => CodeSymbolKind::Key, + SymbolKind::NULL => CodeSymbolKind::Null, + SymbolKind::ENUM_MEMBER => CodeSymbolKind::EnumMember, + SymbolKind::STRUCT => CodeSymbolKind::Struct, + SymbolKind::EVENT => CodeSymbolKind::Event, + SymbolKind::OPERATOR => CodeSymbolKind::Operator, + SymbolKind::TYPE_PARAMETER => CodeSymbolKind::TypeParameter, + _ => CodeSymbolKind::Unknown, + } +} + +pub(crate) fn map_diagnostic_severity( + severity: DiagnosticSeverity, +) -> Option { + match severity { + DiagnosticSeverity::ERROR => Some(CodeDiagnosticSeverity::Error), + DiagnosticSeverity::WARNING => Some(CodeDiagnosticSeverity::Warning), + DiagnosticSeverity::INFORMATION => Some(CodeDiagnosticSeverity::Information), + DiagnosticSeverity::HINT => Some(CodeDiagnosticSeverity::Hint), + _ => None, + } +} + +/// Convert a local file URI into a canonical, workspace-relative path. +/// +/// Both the workspace root and the URI target are expected to exist. The root +/// must already be canonical; the target is canonicalized before containment +/// is checked so a symlink cannot escape the workspace. A missing target is an +/// error rather than an invitation to fall back to lexical normalization. +pub(crate) async fn file_uri_to_workspace_path( + canonical_workspace_root: &Path, + uri: &Uri, +) -> Result { + if !canonical_workspace_root.is_absolute() { + return Err(MappingError::InvalidWorkspaceRoot { + root: canonical_workspace_root.to_path_buf(), + }); + } + + let uri_text = uri.as_str(); + let parsed = Url::parse(uri_text).map_err(|_| MappingError::InvalidFileUri { + uri: uri_text.to_owned(), + })?; + if parsed.scheme() != "file" { + return Err(MappingError::UnsupportedUriScheme { + scheme: parsed.scheme().to_owned(), + }); + } + if parsed.query().is_some() + || parsed.fragment().is_some() + || !has_valid_percent_encoding(parsed.path()) + { + return Err(MappingError::InvalidFileUri { + uri: uri_text.to_owned(), + }); + } + + let decoded_path = parsed + .to_file_path() + .map_err(|()| MappingError::InvalidFileUri { + uri: uri_text.to_owned(), + })?; + let canonical_path = tokio::fs::canonicalize(&decoded_path) + .await + .map_err(|source| { + if source.kind() == io::ErrorKind::NotFound { + MappingError::PathDoesNotExist { + path: decoded_path.clone(), + } + } else { + MappingError::PathResolution { + path: decoded_path.clone(), + source, + } + } + })?; + let relative = canonical_path + .strip_prefix(canonical_workspace_root) + .map_err(|_| MappingError::OutsideWorkspace { + path: canonical_path.clone(), + })?; + let relative = relative + .to_str() + .ok_or_else(|| MappingError::NonUtf8WorkspacePath { + path: relative.to_path_buf(), + })?; + + Ok(WorkspacePath::from_normalized(relative)) +} + +fn ensure_range_contains(enclosing: CodeRange, selection: CodeRange) -> Result<(), MappingError> { + if selection.start < enclosing.start || selection.end > enclosing.end { + return Err(MappingError::SelectionOutsideRange { + selection, + enclosing, + }); + } + Ok(()) +} + +fn has_valid_percent_encoding(value: &str) -> bool { + let bytes = value.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] != b'%' { + index += 1; + continue; + } + + if index + 2 >= bytes.len() + || !bytes[index + 1].is_ascii_hexdigit() + || !bytes[index + 2].is_ascii_hexdigit() + { + return false; + } + index += 3; + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + use lsp_types::{ + DocumentSymbol as LspDocumentSymbol, Position, Range, + SymbolInformation as LspSymbolInformation, WorkspaceLocation, + }; + use serde_json::json; + use std::fs; + use tempfile::TempDir; + + fn canonical_root(directory: &TempDir) -> PathBuf { + fs::canonicalize(directory.path()).unwrap() + } + + fn create_file(directory: &TempDir, relative: &str) -> PathBuf { + let path = directory.path().join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&path, b"saved content").unwrap(); + path + } + + fn file_uri(path: &Path) -> Uri { + Url::from_file_path(path).unwrap().as_str().parse().unwrap() + } + + fn protocol_range(start: (u32, u32), end: (u32, u32)) -> Range { + Range::new(Position::new(start.0, start.1), Position::new(end.0, end.1)) + } + + #[allow(deprecated)] + fn document_symbol( + name: &str, + kind: SymbolKind, + range: Range, + selection_range: Range, + children: Vec, + ) -> LspDocumentSymbol { + LspDocumentSymbol { + name: name.to_owned(), + detail: None, + kind, + tags: None, + deprecated: None, + range, + selection_range, + children: Some(children), + } + } + + #[allow(deprecated)] + fn symbol_information( + name: &str, + kind: SymbolKind, + location: Location, + ) -> LspSymbolInformation { + LspSymbolInformation { + name: name.to_owned(), + kind, + tags: None, + deprecated: None, + location, + container_name: Some("container".to_owned()), + } + } + + #[test] + fn position_preserves_utf16_code_unit_offset() { + let prefix = "a\u{1f980}\u{4e2d}"; + let utf16_offset = prefix.encode_utf16().count() as u32; + assert_eq!(utf16_offset, 4); + + assert_eq!( + map_position(Position::new(7, utf16_offset)), + CodePosition::new(7, 4) + ); + } + + #[tokio::test] + async fn document_symbols_keep_nested_structure() { + let workspace = TempDir::new().unwrap(); + let root = canonical_root(&workspace); + let child = document_symbol( + "method", + SymbolKind::METHOD, + protocol_range((2, 2), (4, 3)), + protocol_range((2, 5), (2, 11)), + Vec::new(), + ); + let parent = document_symbol( + "Type", + SymbolKind::CLASS, + protocol_range((1, 0), (5, 1)), + protocol_range((1, 6), (1, 10)), + vec![child], + ); + + let mapped = + map_document_symbol_response(&root, DocumentSymbolResponse::Nested(vec![parent])) + .await + .unwrap() + .remove(0); + assert_eq!(mapped.kind, CodeSymbolKind::Class); + assert_eq!(mapped.children.len(), 1); + assert_eq!(mapped.children[0].name, "method"); + assert_eq!(mapped.children[0].kind, CodeSymbolKind::Method); + assert!(mapped.children[0].children.is_empty()); + } + + #[tokio::test] + async fn definition_response_maps_location_link_array_and_null_shapes() { + let workspace = TempDir::new().unwrap(); + let root = canonical_root(&workspace); + let target = create_file(&workspace, "src/target.rs"); + let uri = file_uri(&target); + + let scalar = map_definition_response( + &root, + Some(GotoDefinitionResponse::Scalar(Location::new( + uri.clone(), + protocol_range((1, 2), (1, 8)), + ))), + ) + .await + .unwrap(); + assert_eq!(scalar.len(), 1); + assert_eq!(scalar[0].path.as_str(), "src/target.rs"); + + let array = map_definition_response( + &root, + Some(GotoDefinitionResponse::Array(vec![Location::new( + uri.clone(), + protocol_range((2, 0), (2, 4)), + )])), + ) + .await + .unwrap(); + assert_eq!(array[0].range.start, CodePosition::new(2, 0)); + + let links = map_definition_response( + &root, + Some(GotoDefinitionResponse::Link(vec![LocationLink { + origin_selection_range: None, + target_uri: uri, + target_range: protocol_range((3, 0), (6, 1)), + target_selection_range: protocol_range((3, 4), (3, 10)), + }])), + ) + .await + .unwrap(); + assert_eq!( + links[0].range, + CodeRange::new(CodePosition::new(3, 4), CodePosition::new(3, 10)) + ); + + assert!(map_definition_response(&root, None) + .await + .unwrap() + .is_empty()); + } + + #[tokio::test] + async fn location_link_rejects_selection_outside_target_range() { + let workspace = TempDir::new().unwrap(); + let root = canonical_root(&workspace); + let target = create_file(&workspace, "target.ts"); + let error = map_location_link( + &root, + LocationLink { + origin_selection_range: None, + target_uri: file_uri(&target), + target_range: protocol_range((5, 0), (7, 0)), + target_selection_range: protocol_range((4, 0), (4, 3)), + }, + ) + .await + .unwrap_err(); + + assert!(matches!(error, MappingError::SelectionOutsideRange { .. })); + } + + #[test] + fn rejects_reversed_protocol_range() { + let error = map_range(protocol_range((4, 1), (3, 9))).unwrap_err(); + assert!(matches!( + error, + MappingError::InvalidRange { + start: CodePosition { + line: 4, + character: 1 + }, + end: CodePosition { + line: 3, + character: 9 + } + } + )); + } + + #[tokio::test] + async fn diagnostics_normalize_numeric_and_string_codes() { + let workspace = TempDir::new().unwrap(); + let root = canonical_root(&workspace); + let source = create_file(&workspace, "src/lib.rs"); + let diagnostics = vec![ + Diagnostic::new( + protocol_range((0, 0), (0, 1)), + Some(DiagnosticSeverity::ERROR), + Some(NumberOrString::Number(42)), + Some("compiler".to_owned()), + "numeric".to_owned(), + None, + None, + ), + Diagnostic::new( + protocol_range((1, 0), (1, 1)), + Some(DiagnosticSeverity::WARNING), + Some(NumberOrString::String("E_NAME".to_owned())), + None, + "string".to_owned(), + None, + None, + ), + ]; + + let mapped = map_diagnostics(&root, &file_uri(&source), diagnostics) + .await + .unwrap(); + assert_eq!(mapped[0].code.as_deref(), Some("42")); + assert_eq!(mapped[0].severity, Some(CodeDiagnosticSeverity::Error)); + assert_eq!(mapped[1].code.as_deref(), Some("E_NAME")); + assert_eq!(mapped[1].severity, Some(CodeDiagnosticSeverity::Warning)); + } + + #[test] + fn symbol_and_severity_mappings_handle_unknown_values() { + let unknown_kind: SymbolKind = serde_json::from_value(json!(99)).unwrap(); + let unknown_severity: DiagnosticSeverity = serde_json::from_value(json!(99)).unwrap(); + + assert_eq!(map_symbol_kind(SymbolKind::STRUCT), CodeSymbolKind::Struct); + assert_eq!(map_symbol_kind(unknown_kind), CodeSymbolKind::Unknown); + assert_eq!( + map_diagnostic_severity(DiagnosticSeverity::HINT), + Some(CodeDiagnosticSeverity::Hint) + ); + assert_eq!(map_diagnostic_severity(unknown_severity), None); + } + + #[tokio::test] + async fn maps_classic_and_range_less_workspace_symbols() { + let workspace = TempDir::new().unwrap(); + let root = canonical_root(&workspace); + let source = create_file(&workspace, "src/main.ts"); + let uri = file_uri(&source); + + let classic = map_workspace_symbol_response( + &root, + WorkspaceSymbolResponse::Flat(vec![symbol_information( + "run", + SymbolKind::FUNCTION, + Location::new(uri.clone(), protocol_range((1, 1), (1, 4))), + )]), + ) + .await + .unwrap() + .remove(0); + assert_eq!(classic.location.path.as_str(), "src/main.ts"); + assert_eq!(classic.kind, CodeSymbolKind::Function); + + let modern = map_workspace_symbol_response( + &root, + WorkspaceSymbolResponse::Nested(vec![ + WorkspaceSymbol { + name: "Runner".to_owned(), + kind: SymbolKind::CLASS, + tags: None, + container_name: None, + location: OneOf::Left(Location::new( + uri.clone(), + protocol_range((3, 1), (3, 7)), + )), + data: None, + }, + WorkspaceSymbol { + name: "State".to_owned(), + kind: SymbolKind::INTERFACE, + tags: None, + container_name: Some("model".to_owned()), + location: OneOf::Right(WorkspaceLocation { uri }), + data: None, + }, + ]), + ) + .await + .unwrap(); + assert_eq!(modern[0].location.range.start, CodePosition::new(3, 1)); + assert_eq!(modern[1].location.path.as_str(), "src/main.ts"); + assert_eq!(modern[1].location.range, CodeRange::default()); + } + + #[tokio::test] + async fn percent_decodes_file_name() { + let workspace = TempDir::new().unwrap(); + let root = canonical_root(&workspace); + let source = create_file(&workspace, "src/with space.rs"); + let uri = file_uri(&source); + assert!(uri.as_str().contains("with%20space.rs")); + + let mapped = file_uri_to_workspace_path(&root, &uri).await.unwrap(); + assert_eq!(mapped.as_str(), "src/with space.rs"); + } + + #[tokio::test] + async fn rejects_paths_outside_workspace() { + let workspace = TempDir::new().unwrap(); + let outside = TempDir::new().unwrap(); + let root = canonical_root(&workspace); + let source = create_file(&outside, "outside.rs"); + + let error = file_uri_to_workspace_path(&root, &file_uri(&source)) + .await + .unwrap_err(); + assert!(matches!(error, MappingError::OutsideWorkspace { .. })); + } + + #[tokio::test] + async fn rejects_non_file_uri() { + let workspace = TempDir::new().unwrap(); + let root = canonical_root(&workspace); + let uri: Uri = "https://example.test/source.rs".parse().unwrap(); + + let error = file_uri_to_workspace_path(&root, &uri).await.unwrap_err(); + assert!(matches!( + error, + MappingError::UnsupportedUriScheme { ref scheme } if scheme == "https" + )); + } + + #[tokio::test] + async fn missing_paths_do_not_use_lexical_fallback() { + let workspace = TempDir::new().unwrap(); + let root = canonical_root(&workspace); + let missing = workspace.path().join("missing.rs"); + + let error = file_uri_to_workspace_path(&root, &file_uri(&missing)) + .await + .unwrap_err(); + assert!(matches!(error, MappingError::PathDoesNotExist { .. })); + } + + #[cfg(unix)] + #[tokio::test] + async fn rejects_symlink_escape() { + use std::os::unix::fs::symlink; + + let workspace = TempDir::new().unwrap(); + let outside = TempDir::new().unwrap(); + let root = canonical_root(&workspace); + let outside_source = create_file(&outside, "secret.rs"); + let linked_source = workspace.path().join("linked.rs"); + symlink(outside_source, &linked_source).unwrap(); + + let error = file_uri_to_workspace_path(&root, &file_uri(&linked_source)) + .await + .unwrap_err(); + assert!(matches!(error, MappingError::OutsideWorkspace { .. })); + } +} diff --git a/core/src/code_intelligence/lsp/message.rs b/core/src/code_intelligence/lsp/message.rs new file mode 100644 index 00000000..24a09792 --- /dev/null +++ b/core/src/code_intelligence/lsp/message.rs @@ -0,0 +1,418 @@ +use serde_json::{Map, Number, Value}; +use std::hash::Hash; + +const JSON_RPC_VERSION: &str = "2.0"; + +/// Identifier carried by a JSON-RPC request or response. +/// +/// Although new client requests use monotonically increasing numeric IDs, +/// language servers are allowed to initiate requests with string or null IDs. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) enum RequestId { + Number(Number), + String(String), + Null, +} + +impl RequestId { + pub(crate) fn to_value(&self) -> Value { + match self { + Self::Number(value) => Value::Number(value.clone()), + Self::String(value) => Value::String(value.clone()), + Self::Null => Value::Null, + } + } + + fn from_value(value: &Value) -> Result { + match value { + Value::Number(value) => Ok(Self::Number(value.clone())), + Value::String(value) => Ok(Self::String(value.clone())), + Value::Null => Ok(Self::Null), + _ => Err(MessageError::InvalidRequestId), + } + } +} + +impl From for RequestId { + fn from(value: u64) -> Self { + Self::Number(Number::from(value)) + } +} + +impl From for RequestId { + fn from(value: i64) -> Self { + Self::Number(Number::from(value)) + } +} + +impl From for RequestId { + fn from(value: String) -> Self { + Self::String(value) + } +} + +impl From<&str> for RequestId { + fn from(value: &str) -> Self { + Self::String(value.to_owned()) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct JsonRpcRequest { + pub(crate) id: RequestId, + pub(crate) method: String, + pub(crate) params: Option, +} + +impl JsonRpcRequest { + pub(crate) fn new( + id: impl Into, + method: impl Into, + params: Option, + ) -> Self { + Self { + id: id.into(), + method: method.into(), + params, + } + } + + pub(crate) fn to_value(&self) -> Value { + message_value(Some((&self.id, &self.method)), None, self.params.as_ref()) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct JsonRpcNotification { + pub(crate) method: String, + pub(crate) params: Option, +} + +impl JsonRpcNotification { + pub(crate) fn new(method: impl Into, params: Option) -> Self { + Self { + method: method.into(), + params, + } + } + + pub(crate) fn to_value(&self) -> Value { + let mut object = Map::new(); + object.insert( + "jsonrpc".to_owned(), + Value::String(JSON_RPC_VERSION.to_owned()), + ); + object.insert("method".to_owned(), Value::String(self.method.clone())); + if let Some(params) = &self.params { + object.insert("params".to_owned(), params.clone()); + } + Value::Object(object) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct JsonRpcError { + pub(crate) code: i64, + pub(crate) message: String, + pub(crate) data: Option, +} + +impl JsonRpcError { + pub(crate) fn new(code: i64, message: impl Into, data: Option) -> Self { + Self { + code, + message: message.into(), + data, + } + } + + fn to_value(&self) -> Value { + let mut object = Map::new(); + object.insert("code".to_owned(), Value::Number(Number::from(self.code))); + object.insert("message".to_owned(), Value::String(self.message.clone())); + if let Some(data) = &self.data { + object.insert("data".to_owned(), data.clone()); + } + Value::Object(object) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum JsonRpcResponsePayload { + Result(Value), + Error(JsonRpcError), +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct JsonRpcResponse { + pub(crate) id: RequestId, + pub(crate) payload: JsonRpcResponsePayload, +} + +impl JsonRpcResponse { + pub(crate) fn success(id: impl Into, result: Value) -> Self { + Self { + id: id.into(), + payload: JsonRpcResponsePayload::Result(result), + } + } + + pub(crate) fn error( + id: impl Into, + code: i64, + message: impl Into, + data: Option, + ) -> Self { + Self { + id: id.into(), + payload: JsonRpcResponsePayload::Error(JsonRpcError::new(code, message, data)), + } + } + + pub(crate) fn to_value(&self) -> Value { + let mut object = Map::new(); + object.insert( + "jsonrpc".to_owned(), + Value::String(JSON_RPC_VERSION.to_owned()), + ); + object.insert("id".to_owned(), self.id.to_value()); + match &self.payload { + JsonRpcResponsePayload::Result(result) => { + object.insert("result".to_owned(), result.clone()); + } + JsonRpcResponsePayload::Error(error) => { + object.insert("error".to_owned(), error.to_value()); + } + } + Value::Object(object) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum IncomingMessage { + Request(JsonRpcRequest), + Notification(JsonRpcNotification), + Response(JsonRpcResponse), +} + +impl TryFrom for IncomingMessage { + type Error = MessageError; + + fn try_from(value: Value) -> Result { + let object = value.as_object().ok_or(MessageError::ExpectedObject)?; + validate_version(object)?; + + let has_id = object.contains_key("id"); + let has_method = object.contains_key("method"); + let has_result = object.contains_key("result"); + let has_error = object.contains_key("error"); + + if has_method { + if has_result || has_error { + return Err(MessageError::AmbiguousShape); + } + + let method = object + .get("method") + .and_then(Value::as_str) + .ok_or(MessageError::InvalidMethod)? + .to_owned(); + let params = parse_params(object)?; + + if has_id { + let id = RequestId::from_value(&object["id"])?; + Ok(Self::Request(JsonRpcRequest { id, method, params })) + } else { + Ok(Self::Notification(JsonRpcNotification { method, params })) + } + } else { + if object.contains_key("params") || !has_id || has_result == has_error { + return Err(MessageError::InvalidResponseShape); + } + + let id = RequestId::from_value(&object["id"])?; + let payload = if has_result { + JsonRpcResponsePayload::Result(object["result"].clone()) + } else { + JsonRpcResponsePayload::Error(parse_error(&object["error"])?) + }; + Ok(Self::Response(JsonRpcResponse { id, payload })) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub(crate) enum MessageError { + #[error("JSON-RPC message must be an object")] + ExpectedObject, + + #[error("JSON-RPC message must declare jsonrpc=\"2.0\"")] + InvalidVersion, + + #[error("JSON-RPC request ID must be a number, string, or null")] + InvalidRequestId, + + #[error("JSON-RPC method must be a string")] + InvalidMethod, + + #[error("JSON-RPC params must be an object, array, or null")] + InvalidParams, + + #[error("JSON-RPC message mixes request and response fields")] + AmbiguousShape, + + #[error("JSON-RPC response must contain an ID and exactly one of result or error")] + InvalidResponseShape, + + #[error("JSON-RPC error must contain an integer code and string message")] + InvalidError, +} + +fn validate_version(object: &Map) -> Result<(), MessageError> { + match object.get("jsonrpc") { + Some(Value::String(version)) if version == JSON_RPC_VERSION => Ok(()), + _ => Err(MessageError::InvalidVersion), + } +} + +fn parse_params(object: &Map) -> Result, MessageError> { + let Some(params) = object.get("params") else { + return Ok(None); + }; + if params.is_null() || params.is_array() || params.is_object() { + Ok(Some(params.clone())) + } else { + Err(MessageError::InvalidParams) + } +} + +fn parse_error(value: &Value) -> Result { + let object = value.as_object().ok_or(MessageError::InvalidError)?; + let code = object + .get("code") + .and_then(Value::as_i64) + .ok_or(MessageError::InvalidError)?; + let message = object + .get("message") + .and_then(Value::as_str) + .ok_or(MessageError::InvalidError)? + .to_owned(); + Ok(JsonRpcError { + code, + message, + data: object.get("data").cloned(), + }) +} + +fn message_value( + request: Option<(&RequestId, &str)>, + notification_method: Option<&str>, + params: Option<&Value>, +) -> Value { + let mut object = Map::new(); + object.insert( + "jsonrpc".to_owned(), + Value::String(JSON_RPC_VERSION.to_owned()), + ); + if let Some((id, method)) = request { + object.insert("id".to_owned(), id.to_value()); + object.insert("method".to_owned(), Value::String(method.to_owned())); + } else if let Some(method) = notification_method { + object.insert("method".to_owned(), Value::String(method.to_owned())); + } + if let Some(params) = params { + object.insert("params".to_owned(), params.clone()); + } + Value::Object(object) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn parses_all_request_id_kinds() { + for (id, expected) in [ + (json!(42), RequestId::from(42_u64)), + (json!("server-1"), RequestId::from("server-1")), + (Value::Null, RequestId::Null), + ] { + let message = IncomingMessage::try_from(json!({ + "jsonrpc": "2.0", + "id": id, + "method": "server/request", + "params": null + })) + .unwrap(); + let IncomingMessage::Request(request) = message else { + panic!("expected request"); + }; + assert_eq!(request.id, expected); + } + } + + #[test] + fn parses_result_error_and_notification() { + assert!(matches!( + IncomingMessage::try_from(json!({ + "jsonrpc": "2.0", + "id": 7, + "result": null + })), + Ok(IncomingMessage::Response(JsonRpcResponse { + payload: JsonRpcResponsePayload::Result(Value::Null), + .. + })) + )); + assert!(matches!( + IncomingMessage::try_from(json!({ + "jsonrpc": "2.0", + "id": "seven", + "error": {"code": -32000, "message": "failed", "data": {"retry": false}} + })), + Ok(IncomingMessage::Response(JsonRpcResponse { + payload: JsonRpcResponsePayload::Error(JsonRpcError { code: -32000, .. }), + .. + })) + )); + assert!(matches!( + IncomingMessage::try_from(json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": {"diagnostics": []} + })), + Ok(IncomingMessage::Notification(_)) + )); + } + + #[test] + fn rejects_invalid_versions_and_ambiguous_shapes() { + for value in [ + json!({"id": 1, "result": null}), + json!({"jsonrpc": "1.0", "id": 1, "result": null}), + json!({"jsonrpc": "2.0", "id": 1, "result": null, "error": null}), + json!({"jsonrpc": "2.0", "id": 1, "method": "x", "result": null}), + json!({"jsonrpc": "2.0", "method": "x", "params": "scalar"}), + ] { + assert!(IncomingMessage::try_from(value).is_err()); + } + } + + #[test] + fn constructors_emit_unambiguous_messages() { + assert_eq!( + JsonRpcRequest::new(3_u64, "initialize", Some(json!({}))).to_value(), + json!({"jsonrpc": "2.0", "id": 3, "method": "initialize", "params": {}}) + ); + assert_eq!( + JsonRpcNotification::new("initialized", None).to_value(), + json!({"jsonrpc": "2.0", "method": "initialized"}) + ); + assert_eq!( + JsonRpcResponse::error(RequestId::Null, -32601, "not found", None).to_value(), + json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32601, "message": "not found"}}) + ); + } +} diff --git a/core/src/code_intelligence/lsp/mod.rs b/core/src/code_intelligence/lsp/mod.rs new file mode 100644 index 00000000..4b6dd08d --- /dev/null +++ b/core/src/code_intelligence/lsp/mod.rs @@ -0,0 +1,9 @@ +//! Language-server protocol transport primitives. + +pub(crate) mod client; +pub(crate) mod codec; +pub(crate) mod initialize; +pub(crate) mod mapping; +pub(crate) mod message; +pub(crate) mod process; +pub(crate) mod router; diff --git a/core/src/code_intelligence/lsp/process.rs b/core/src/code_intelligence/lsp/process.rs new file mode 100644 index 00000000..659b220f --- /dev/null +++ b/core/src/code_intelligence/lsp/process.rs @@ -0,0 +1,418 @@ +//! Long-lived language server process lifecycle. + +use super::client::LspClient; +use super::router::ServerRequestRouter; +use crate::code_intelligence::language_profile::LanguageServerCommand; +use crate::tools::process::{configure_process_group, ProcessGroupGuard}; +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::Duration; +use tokio::io::AsyncReadExt; +use tokio::process::{Child, Command}; +use tokio::sync::{mpsc, watch}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +const STDERR_LIMIT_BYTES: usize = 64 * 1024; +const STDERR_SETTLEMENT_TIMEOUT: Duration = Duration::from_secs(1); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum LspProcessState { + Running, + Exited { code: Option, forced: bool }, + Failed { message: String }, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum LspProcessError { + #[error("failed to start language server {program:?}: {source}")] + Spawn { + program: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("language server process did not expose piped {stream}")] + MissingPipe { stream: &'static str }, + + #[error("language server did not report an exit within {duration:?}")] + ShutdownIncomplete { duration: Duration }, +} + +enum ProcessControl { + ForceKill, +} + +/// Handle to one process and its protocol client. +pub(crate) struct LspProcess { + client: LspClient, + control: mpsc::UnboundedSender, + state: watch::Receiver, + stderr: Arc>, + shutdown_started: AtomicBool, +} + +impl std::fmt::Debug for LspProcess { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("LspProcess") + .field("state", &*self.state.borrow()) + .field("client_closed", &self.client.is_closed()) + .finish() + } +} + +impl LspProcess { + pub(crate) fn spawn( + command: &LanguageServerCommand, + working_directory: &Path, + router: ServerRequestRouter, + ) -> Result { + let mut process = Command::new(&command.program); + process + .args(&command.args) + .envs(&command.env) + .current_dir(working_directory) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + configure_process_group(&mut process); + + let mut child = process.spawn().map_err(|source| LspProcessError::Spawn { + program: command.program.clone(), + source, + })?; + let process_group = ProcessGroupGuard::for_child(&child); + let stdout = child + .stdout + .take() + .ok_or(LspProcessError::MissingPipe { stream: "stdout" })?; + let stdin = child + .stdin + .take() + .ok_or(LspProcessError::MissingPipe { stream: "stdin" })?; + let stderr = child + .stderr + .take() + .ok_or(LspProcessError::MissingPipe { stream: "stderr" })?; + + let client = LspClient::start_split(stdout, stdin, router); + let protocol_closed = client.shutdown_token(); + let stderr_buffer = Arc::new(StdMutex::new(BoundedStderr::new(STDERR_LIMIT_BYTES))); + let stderr_task = tokio::spawn(read_stderr(stderr, Arc::clone(&stderr_buffer))); + let (control, control_rx) = mpsc::unbounded_channel(); + let (state_tx, state) = watch::channel(LspProcessState::Running); + tokio::spawn(monitor_process( + child, + process_group, + client.clone(), + protocol_closed, + control_rx, + state_tx, + stderr_task, + )); + + Ok(Self { + client, + control, + state, + stderr: stderr_buffer, + shutdown_started: AtomicBool::new(false), + }) + } + + pub(crate) fn client(&self) -> LspClient { + self.client.clone() + } + + pub(crate) fn state(&self) -> LspProcessState { + self.state.borrow().clone() + } + + pub(crate) fn subscribe_state(&self) -> watch::Receiver { + self.state.clone() + } + + pub(crate) fn stderr_snapshot(&self) -> String { + self.stderr + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .render() + } + + /// Request a graceful protocol shutdown, then kill the process group if + /// the server does not exit within the supplied bounds. + pub(crate) async fn shutdown( + &self, + request_timeout: Duration, + exit_timeout: Duration, + ) -> Result { + if !self.shutdown_started.swap(true, Ordering::AcqRel) + && matches!(self.state(), LspProcessState::Running) + { + let _ = self + .client + .request("shutdown", None, CancellationToken::new(), request_timeout) + .await; + // A saturated writer queue must not make host shutdown + // unbounded after the graceful request has already timed out. + let _ = tokio::time::timeout(request_timeout, self.client.notify("exit", None)).await; + } + + if let Some(state) = wait_for_terminal_state(self.state.clone(), exit_timeout).await { + return Ok(state); + } + + let _ = self.control.send(ProcessControl::ForceKill); + wait_for_terminal_state(self.state.clone(), exit_timeout) + .await + .ok_or(LspProcessError::ShutdownIncomplete { + duration: exit_timeout, + }) + } + + pub(crate) fn force_kill(&self) { + self.shutdown_started.store(true, Ordering::Release); + let _ = self.control.send(ProcessControl::ForceKill); + } +} + +impl Drop for LspProcess { + fn drop(&mut self) { + if matches!(self.state(), LspProcessState::Running) { + let _ = self.control.send(ProcessControl::ForceKill); + } + } +} + +async fn monitor_process( + mut child: Child, + mut process_group: ProcessGroupGuard, + client: LspClient, + protocol_closed: CancellationToken, + mut control: mpsc::UnboundedReceiver, + state: watch::Sender, + mut stderr_task: JoinHandle<()>, +) { + let (forced, result) = tokio::select! { + result = child.wait() => (false, result), + _ = protocol_closed.cancelled() => { + match child.try_wait() { + Ok(Some(status)) => (false, Ok(status)), + Ok(None) => { + // A language server can close its protocol streams without + // exiting. Reap that generation before the workspace is + // allowed to start a replacement process. + process_group.kill(); + let _ = child.start_kill(); + (true, child.wait().await) + } + Err(error) => (false, Err(error)), + } + } + command = control.recv() => { + match command { + Some(ProcessControl::ForceKill) | None => { + process_group.kill(); + let _ = child.start_kill(); + (true, child.wait().await) + } + } + } + }; + + // A server can leave helper children behind even after its leader exits. + process_group.kill(); + let final_state = match result { + Ok(status) => LspProcessState::Exited { + code: status.code(), + forced, + }, + Err(error) => LspProcessState::Failed { + message: error.to_string(), + }, + }; + let _ = state.send(final_state); + client.close().await; + + if tokio::time::timeout(STDERR_SETTLEMENT_TIMEOUT, &mut stderr_task) + .await + .is_err() + { + stderr_task.abort(); + } +} + +async fn wait_for_terminal_state( + mut state: watch::Receiver, + timeout: Duration, +) -> Option { + if !matches!(*state.borrow(), LspProcessState::Running) { + return Some(state.borrow().clone()); + } + + tokio::time::timeout(timeout, async { + loop { + state.changed().await.ok()?; + if !matches!(*state.borrow(), LspProcessState::Running) { + return Some(state.borrow().clone()); + } + } + }) + .await + .ok() + .flatten() +} + +async fn read_stderr( + mut stderr: tokio::process::ChildStderr, + buffer: Arc>, +) { + let mut chunk = [0_u8; 4096]; + loop { + match stderr.read(&mut chunk).await { + Ok(0) | Err(_) => break, + Ok(count) => buffer + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(&chunk[..count]), + } + } +} + +#[derive(Debug)] +struct BoundedStderr { + bytes: VecDeque, + limit: usize, + total_bytes: usize, +} + +impl BoundedStderr { + fn new(limit: usize) -> Self { + Self { + bytes: VecDeque::with_capacity(limit), + limit, + total_bytes: 0, + } + } + + fn push(&mut self, bytes: &[u8]) { + self.total_bytes = self.total_bytes.saturating_add(bytes.len()); + self.bytes.extend(bytes.iter().copied()); + while self.bytes.len() > self.limit { + self.bytes.pop_front(); + } + } + + fn render(&self) -> String { + let bytes = self.bytes.iter().copied().collect::>(); + let stderr = String::from_utf8_lossy(&bytes); + if self.total_bytes <= self.bytes.len() { + stderr.into_owned() + } else { + format!( + "[language server stderr truncated: retained the last {} of {} bytes]\n{}", + self.bytes.len(), + self.total_bytes, + stderr + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::code_intelligence::lsp::router::{ServerRequestRouter, ServerRequestRouterConfig}; + use std::collections::BTreeMap; + use std::ffi::OsString; + use std::time::Instant; + + #[test] + fn bounded_stderr_retains_only_the_tail() { + let mut stderr = BoundedStderr::new(5); + stderr.push(b"abc"); + stderr.push(b"defgh"); + + let rendered = stderr.render(); + assert!(rendered.contains("last 5 of 8 bytes")); + assert!(rendered.ends_with("defgh")); + } + + #[tokio::test] + async fn missing_executable_is_a_typed_spawn_error() { + let command = LanguageServerCommand { + program: PathBuf::from("a3s-code-missing-language-server-executable"), + args: Vec::new(), + env: BTreeMap::new(), + }; + let directory = tempfile::tempdir().unwrap(); + let error = LspProcess::spawn( + &command, + directory.path(), + ServerRequestRouter::new(ServerRequestRouterConfig::default()), + ) + .unwrap_err(); + + assert!(matches!(error, LspProcessError::Spawn { .. })); + } + + #[cfg(unix)] + #[tokio::test] + async fn shutdown_kills_an_unresponsive_process_group() { + let command = LanguageServerCommand { + program: PathBuf::from("sh"), + args: vec![OsString::from("-c"), OsString::from("sleep 30")], + env: BTreeMap::new(), + }; + let directory = tempfile::tempdir().unwrap(); + let process = LspProcess::spawn( + &command, + directory.path(), + ServerRequestRouter::new(ServerRequestRouterConfig::default()), + ) + .unwrap(); + let started = Instant::now(); + + let state = process + .shutdown(Duration::from_millis(50), Duration::from_millis(500)) + .await + .unwrap(); + + assert!(started.elapsed() < Duration::from_secs(2)); + assert!(matches!( + state, + LspProcessState::Exited { forced: true, .. } + )); + } + + #[cfg(unix)] + #[tokio::test] + async fn protocol_eof_reaps_a_still_running_process() { + let command = LanguageServerCommand { + program: PathBuf::from("sh"), + args: vec![OsString::from("-c"), OsString::from("exec 1>&-; sleep 30")], + env: BTreeMap::new(), + }; + let directory = tempfile::tempdir().unwrap(); + let process = LspProcess::spawn( + &command, + directory.path(), + ServerRequestRouter::new(ServerRequestRouterConfig::default()), + ) + .unwrap(); + + let state = wait_for_terminal_state(process.subscribe_state(), Duration::from_secs(2)) + .await + .expect("protocol EOF should force the live process to exit"); + + assert!(matches!( + state, + LspProcessState::Exited { forced: true, .. } + )); + } +} diff --git a/core/src/code_intelligence/lsp/router.rs b/core/src/code_intelligence/lsp/router.rs new file mode 100644 index 00000000..7afb2c46 --- /dev/null +++ b/core/src/code_intelligence/lsp/router.rs @@ -0,0 +1,291 @@ +use std::collections::BTreeMap; + +use serde_json::{json, Map, Value}; +use url::Url; + +use super::message::{JsonRpcRequest, JsonRpcResponse}; + +const METHOD_NOT_FOUND: i64 = -32601; +const INVALID_PARAMS: i64 = -32602; + +/// One workspace folder exposed to a language server. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WorkspaceFolder { + uri: Url, + name: String, +} + +impl WorkspaceFolder { + pub(crate) fn new(uri: Url, name: impl Into) -> Self { + Self { + uri, + name: name.into(), + } + } + + pub(crate) fn uri(&self) -> &Url { + &self.uri + } + + pub(crate) fn name(&self) -> &str { + &self.name + } + + fn to_value(&self) -> Value { + json!({ + "uri": self.uri.as_str(), + "name": self.name, + }) + } +} + +/// Immutable workspace-wide settings returned to server configuration queries. +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct WorkspaceSettings { + sections: BTreeMap, +} + +impl WorkspaceSettings { + pub(crate) fn new(sections: BTreeMap) -> Self { + Self { sections } + } + + #[cfg(test)] + pub(crate) fn with_section(mut self, section: impl Into, value: Value) -> Self { + self.sections.insert(section.into(), value); + self + } + + fn resolve(&self, section: Option<&str>) -> Value { + let Some(section) = section else { + return Value::Object( + self.sections + .iter() + .map(|(name, value)| (name.clone(), value.clone())) + .collect::>(), + ); + }; + + if let Some(value) = self.sections.get(section) { + return value.clone(); + } + + // Servers commonly ask for a nested section even when the client was + // configured with one object at the language root. + for (index, _) in section.rmatch_indices('.') { + let prefix = §ion[..index]; + let Some(mut value) = self.sections.get(prefix) else { + continue; + }; + let mut found = true; + for part in section[index + 1..].split('.') { + let Some(next) = value.as_object().and_then(|object| object.get(part)) else { + found = false; + break; + }; + value = next; + } + if found { + return value.clone(); + } + } + + Value::Null + } +} + +/// Static data available to server-initiated requests. +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct ServerRequestRouterConfig { + workspace_folders: Vec, + settings: WorkspaceSettings, +} + +impl ServerRequestRouterConfig { + pub(crate) fn new( + workspace_folders: Vec, + settings: WorkspaceSettings, + ) -> Self { + Self { + workspace_folders, + settings, + } + } +} + +/// Handles the small, explicitly safe set of language-server requests. +#[derive(Debug, Clone, Default)] +pub(crate) struct ServerRequestRouter { + config: ServerRequestRouterConfig, +} + +impl ServerRequestRouter { + pub(crate) fn new(config: ServerRequestRouterConfig) -> Self { + Self { config } + } + + pub(crate) fn route(&self, request: &JsonRpcRequest) -> JsonRpcResponse { + match request.method.as_str() { + "workspace/configuration" => self.configuration(request), + "workspace/workspaceFolders" => JsonRpcResponse::success( + request.id.clone(), + Value::Array( + self.config + .workspace_folders + .iter() + .map(WorkspaceFolder::to_value) + .collect(), + ), + ), + "client/registerCapability" + | "client/unregisterCapability" + | "window/workDoneProgress/create" => { + JsonRpcResponse::success(request.id.clone(), Value::Null) + } + "workspace/applyEdit" => JsonRpcResponse::success( + request.id.clone(), + json!({ + "applied": false, + "failureReason": "workspace edits must be initiated by the client", + }), + ), + "window/showDocument" => { + JsonRpcResponse::success(request.id.clone(), json!({"success": false})) + } + _ => JsonRpcResponse::error( + request.id.clone(), + METHOD_NOT_FOUND, + format!("method not found: {}", request.method), + None, + ), + } + } + + fn configuration(&self, request: &JsonRpcRequest) -> JsonRpcResponse { + let Some(items) = request + .params + .as_ref() + .and_then(Value::as_object) + .and_then(|params| params.get("items")) + .and_then(Value::as_array) + else { + return JsonRpcResponse::error( + request.id.clone(), + INVALID_PARAMS, + "workspace/configuration requires an items array", + None, + ); + }; + + let mut values = Vec::with_capacity(items.len()); + for item in items { + let Some(item) = item.as_object() else { + return JsonRpcResponse::error( + request.id.clone(), + INVALID_PARAMS, + "workspace/configuration items must be objects", + None, + ); + }; + let section = match item.get("section") { + None | Some(Value::Null) => None, + Some(Value::String(section)) => Some(section.as_str()), + Some(_) => { + return JsonRpcResponse::error( + request.id.clone(), + INVALID_PARAMS, + "workspace/configuration section must be a string or null", + None, + ); + } + }; + values.push(self.config.settings.resolve(section)); + } + + JsonRpcResponse::success(request.id.clone(), Value::Array(values)) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::code_intelligence::lsp::message::JsonRpcResponsePayload; + + fn router() -> ServerRequestRouter { + let folder = + WorkspaceFolder::new(Url::parse("file:///workspace/project").unwrap(), "project"); + let settings = WorkspaceSettings::default() + .with_section("rust", json!({"check": {"command": "clippy"}})); + ServerRequestRouter::new(ServerRequestRouterConfig::new(vec![folder], settings)) + } + + fn request(method: &str, params: Option) -> JsonRpcRequest { + JsonRpcRequest::new("server-request", method, params) + } + + fn result(response: JsonRpcResponse) -> Value { + match response.payload { + JsonRpcResponsePayload::Result(value) => value, + JsonRpcResponsePayload::Error(error) => panic!("unexpected error: {error:?}"), + } + } + + #[test] + fn returns_typed_workspace_folders_and_settings() { + assert_eq!( + result(router().route(&request("workspace/workspaceFolders", None))), + json!([{"uri": "file:///workspace/project", "name": "project"}]) + ); + assert_eq!( + result(router().route(&request( + "workspace/configuration", + Some(json!({ + "items": [ + {"section": "rust"}, + {"section": "rust.check.command"}, + {"section": "missing"} + ] + })), + ))), + json!([{"check": {"command": "clippy"}}, "clippy", null]) + ); + } + + #[test] + fn acknowledges_registration_and_progress_requests() { + for method in [ + "client/registerCapability", + "client/unregisterCapability", + "window/workDoneProgress/create", + ] { + assert_eq!(result(router().route(&request(method, None))), Value::Null); + } + } + + #[test] + fn refuses_server_initiated_user_actions() { + assert_eq!( + result(router().route(&request("workspace/applyEdit", None)))["applied"], + false + ); + assert_eq!( + result(router().route(&request("window/showDocument", None)))["success"], + false + ); + } + + #[test] + fn rejects_unknown_methods_and_invalid_configuration_params() { + for response in [ + router().route(&request("unknown/method", None)), + router().route(&request("workspace/configuration", Some(json!({})))), + ] { + let JsonRpcResponsePayload::Error(error) = response.payload else { + panic!("expected error response"); + }; + assert!(matches!(error.code, METHOD_NOT_FOUND | INVALID_PARAMS)); + } + } +} diff --git a/core/src/code_intelligence/mod.rs b/core/src/code_intelligence/mod.rs new file mode 100644 index 00000000..a638eb4d --- /dev/null +++ b/core/src/code_intelligence/mod.rs @@ -0,0 +1,28 @@ +//! Workspace-scoped semantic code intelligence contracts. +//! +//! This module defines read-only, language-aware queries over saved workspace +//! documents. Concrete runtimes live behind [`WorkspaceCodeIntelligence`] so +//! TUI, web, and agent integrations can share one source of semantic results. + +pub(crate) mod diagnostics; +pub(crate) mod document_store; +mod error; +pub(crate) mod language_profile; +pub(crate) mod language_runtime; +mod local_provider; +pub(crate) mod lsp; +pub(crate) mod project_layout; +pub(crate) mod registry; +mod service; +mod types; +pub(crate) mod workspace_runtime; + +pub use error::{CodeIntelligenceError, CodeIntelligenceResult}; +pub use local_provider::LocalCodeIntelligence; +pub use service::WorkspaceCodeIntelligence; +pub use types::{ + CodeDiagnostic, CodeDiagnosticSeverity, CodeIntelligenceCapabilities, + CodeIntelligenceLanguageStatus, CodeIntelligenceState, CodeIntelligenceStatus, CodeLocation, + CodePosition, CodeQueryResult, CodeRange, CodeSymbolKind, DocumentRevision, DocumentSnapshot, + DocumentSymbol, LanguageId, NavigationKind, SymbolInformation, +}; diff --git a/core/src/code_intelligence/project_layout.rs b/core/src/code_intelligence/project_layout.rs new file mode 100644 index 00000000..d59c2265 --- /dev/null +++ b/core/src/code_intelligence/project_layout.rs @@ -0,0 +1,329 @@ +//! Project layout derived from an immutable workspace manifest snapshot. + +use crate::workspace::{LocalWorkspaceFile, LocalWorkspaceManifestSnapshot, WorkspacePath}; +use std::collections::BTreeMap; +use std::path::Path; + +const LAYOUT_HASH_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; +const LAYOUT_HASH_PRIME: u64 = 0x0000_0100_0000_01b3; + +/// Language runtime profile selected by a project marker. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub(crate) enum ProjectLanguageProfile { + Rust, + TypeScriptJavaScript, +} + +/// Supported project marker kind. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub(crate) enum ProjectMarkerKind { + CargoManifest, + PackageManifest, + TypeScriptConfig, +} + +/// One project marker found in the workspace manifest. +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub(crate) struct ProjectMarker { + pub(crate) path: WorkspacePath, + pub(crate) root: WorkspacePath, + pub(crate) profile: ProjectLanguageProfile, + pub(crate) kind: ProjectMarkerKind, +} + +/// Stable project topology for one workspace manifest revision. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ProjectLayout { + pub(crate) markers: Vec, + pub(crate) workspace_revision: u64, + pub(crate) layout_hash: u64, +} + +/// Resolves project topology without filesystem access. +pub(crate) struct ProjectLayoutResolver; + +impl ProjectLayoutResolver { + /// Resolve supported project markers from an immutable manifest snapshot. + /// + /// Only marker topology affects `layout_hash`; file metadata, source-file + /// changes, and the manifest revision do not restart an unchanged layout. + pub(crate) fn resolve(snapshot: &LocalWorkspaceManifestSnapshot) -> ProjectLayout { + let mut markers = BTreeMap::::new(); + + for file in &snapshot.files { + let Some(marker) = marker_from_file(file) else { + continue; + }; + + markers + .entry(marker.path.as_str().to_string()) + .or_insert(marker); + } + + let mut hasher = StableLayoutHasher::new(); + let markers = markers + .into_values() + .inspect(|marker| hash_marker(marker, &mut hasher)) + .collect(); + + ProjectLayout { + markers, + workspace_revision: snapshot.version, + layout_hash: hasher.finish(), + } + } +} + +fn marker_from_file(file: &LocalWorkspaceFile) -> Option { + if file.binary || file.generated { + return None; + } + + let path = Path::new(&file.path); + let file_name = path.file_name()?.to_str()?; + let (profile, kind) = marker_classification(file_name)?; + let root = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map(|parent| WorkspacePath::from_normalized(parent.to_string_lossy().into_owned())) + .unwrap_or_else(WorkspacePath::root); + + Some(ProjectMarker { + path: WorkspacePath::from_normalized(file.path.clone()), + root, + profile, + kind, + }) +} + +fn hash_marker(marker: &ProjectMarker, hasher: &mut StableLayoutHasher) { + hasher.write_str(marker.path.as_str()); + hasher.write_str(marker.root.as_str()); + hasher.write_u8(match marker.profile { + ProjectLanguageProfile::Rust => 1, + ProjectLanguageProfile::TypeScriptJavaScript => 2, + }); + hasher.write_u8(match marker.kind { + ProjectMarkerKind::CargoManifest => 1, + ProjectMarkerKind::PackageManifest => 2, + ProjectMarkerKind::TypeScriptConfig => 3, + }); +} + +fn marker_classification(file_name: &str) -> Option<(ProjectLanguageProfile, ProjectMarkerKind)> { + match file_name { + "Cargo.toml" => Some(( + ProjectLanguageProfile::Rust, + ProjectMarkerKind::CargoManifest, + )), + "package.json" => Some(( + ProjectLanguageProfile::TypeScriptJavaScript, + ProjectMarkerKind::PackageManifest, + )), + name if name.starts_with("tsconfig") && name.ends_with(".json") => Some(( + ProjectLanguageProfile::TypeScriptJavaScript, + ProjectMarkerKind::TypeScriptConfig, + )), + _ => None, + } +} + +struct StableLayoutHasher(u64); + +impl StableLayoutHasher { + fn new() -> Self { + let mut hasher = Self(LAYOUT_HASH_OFFSET_BASIS); + hasher.write_str("a3s-code-project-layout-v1"); + hasher + } + + fn write_str(&mut self, value: &str) { + self.write_u64(value.len() as u64); + self.write_bytes(value.as_bytes()); + } + + fn write_u64(&mut self, value: u64) { + self.write_bytes(&value.to_le_bytes()); + } + + fn write_u8(&mut self, value: u8) { + self.write_bytes(&[value]); + } + + fn write_bytes(&mut self, bytes: &[u8]) { + for byte in bytes { + self.0 ^= u64::from(*byte); + self.0 = self.0.wrapping_mul(LAYOUT_HASH_PRIME); + } + } + + fn finish(self) -> u64 { + self.0 + } +} + +#[cfg(test)] +mod tests { + use super::{ + ProjectLanguageProfile, ProjectLayoutResolver, ProjectMarkerKind, LAYOUT_HASH_OFFSET_BASIS, + }; + use crate::workspace::{ + LocalWorkspaceFile, LocalWorkspaceFileStatus, LocalWorkspaceManifestSnapshot, + }; + use std::path::PathBuf; + + fn workspace_file(path: &str, size: u64, modified_ms: u64) -> LocalWorkspaceFile { + LocalWorkspaceFile { + path: path.to_string(), + size, + modified_ms: Some(modified_ms), + language: None, + status: LocalWorkspaceFileStatus::Tracked, + binary: false, + generated: false, + } + } + + fn snapshot(version: u64, files: Vec) -> LocalWorkspaceManifestSnapshot { + LocalWorkspaceManifestSnapshot { + version, + root: PathBuf::from("/workspace"), + files, + scanned_at_ms: 1_000, + } + } + + #[test] + fn resolves_mixed_nested_monorepo_with_marker_roots() { + let layout = ProjectLayoutResolver::resolve(&snapshot( + 17, + vec![ + workspace_file("crates/runtime/Cargo.toml", 220, 30), + workspace_file("apps/web/tsconfig.build.json", 180, 20), + workspace_file("apps/web/package.json", 140, 10), + workspace_file("apps/web/src/main.ts", 900, 40), + workspace_file("README.md", 500, 50), + ], + )); + + assert_eq!(layout.workspace_revision, 17); + assert_eq!(layout.markers.len(), 3); + assert_eq!(layout.markers[0].path.as_str(), "apps/web/package.json"); + assert_eq!(layout.markers[0].root.as_str(), "apps/web"); + assert_eq!( + layout.markers[0].profile, + ProjectLanguageProfile::TypeScriptJavaScript + ); + assert_eq!(layout.markers[0].kind, ProjectMarkerKind::PackageManifest); + assert_eq!( + layout.markers[1].path.as_str(), + "apps/web/tsconfig.build.json" + ); + assert_eq!(layout.markers[1].root.as_str(), "apps/web"); + assert_eq!(layout.markers[1].kind, ProjectMarkerKind::TypeScriptConfig); + assert_eq!(layout.markers[2].path.as_str(), "crates/runtime/Cargo.toml"); + assert_eq!(layout.markers[2].root.as_str(), "crates/runtime"); + assert_eq!(layout.markers[2].profile, ProjectLanguageProfile::Rust); + assert_eq!(layout.markers[2].kind, ProjectMarkerKind::CargoManifest); + assert_ne!(layout.layout_hash, LAYOUT_HASH_OFFSET_BASIS); + } + + #[test] + fn marker_order_and_duplicates_do_not_change_layout() { + let cargo = workspace_file("services/api/Cargo.toml", 100, 10); + let package = workspace_file("apps/web/package.json", 200, 20); + let tsconfig = workspace_file("apps/web/tsconfig.json", 300, 30); + + let first = ProjectLayoutResolver::resolve(&snapshot( + 8, + vec![cargo.clone(), package.clone(), tsconfig.clone()], + )); + let second = ProjectLayoutResolver::resolve(&snapshot( + 8, + vec![tsconfig, package.clone(), cargo, package], + )); + + assert_eq!(first, second); + assert_eq!(first.markers.len(), 3); + } + + #[test] + fn source_changes_do_not_change_layout_hash() { + let first = ProjectLayoutResolver::resolve(&snapshot( + 1, + vec![ + workspace_file("Cargo.toml", 100, 10), + workspace_file("src/lib.rs", 200, 20), + ], + )); + let second = ProjectLayoutResolver::resolve(&snapshot( + 2, + vec![ + workspace_file("Cargo.toml", 100, 10), + workspace_file("src/lib.rs", 900, 90), + ], + )); + + assert_eq!(first.layout_hash, second.layout_hash); + assert_eq!(first.markers, second.markers); + assert_eq!(first.workspace_revision, 1); + assert_eq!(second.workspace_revision, 2); + } + + #[test] + fn marker_metadata_changes_do_not_change_layout_hash() { + let first = ProjectLayoutResolver::resolve(&snapshot( + 1, + vec![workspace_file("apps/web/package.json", 100, 10)], + )); + let second = ProjectLayoutResolver::resolve(&snapshot( + 2, + vec![workspace_file("apps/web/package.json", 101, 11)], + )); + + assert_eq!(first.layout_hash, second.layout_hash); + assert_eq!(first.markers, second.markers); + } + + #[test] + fn adding_or_removing_markers_changes_layout_hash() { + let baseline = ProjectLayoutResolver::resolve(&snapshot( + 1, + vec![workspace_file("apps/web/package.json", 100, 10)], + )); + let added = ProjectLayoutResolver::resolve(&snapshot( + 2, + vec![ + workspace_file("apps/web/package.json", 100, 10), + workspace_file("apps/web/tsconfig.json", 200, 20), + ], + )); + let removed = ProjectLayoutResolver::resolve(&snapshot(3, Vec::new())); + + assert_ne!(baseline.layout_hash, added.layout_hash); + assert_ne!(baseline.layout_hash, removed.layout_hash); + } + + #[test] + fn generated_and_binary_markers_are_ignored() { + let baseline = ProjectLayoutResolver::resolve(&snapshot( + 1, + vec![workspace_file("apps/web/package.json", 100, 10)], + )); + let mut generated = workspace_file("generated/client/tsconfig.json", 200, 20); + generated.generated = true; + let mut binary = workspace_file("fixtures/package.json", 300, 30); + binary.binary = true; + let with_ignored = ProjectLayoutResolver::resolve(&snapshot( + 2, + vec![ + workspace_file("apps/web/package.json", 100, 10), + generated, + binary, + ], + )); + + assert_eq!(baseline.markers, with_ignored.markers); + assert_eq!(baseline.layout_hash, with_ignored.layout_hash); + } +} diff --git a/core/src/code_intelligence/registry.rs b/core/src/code_intelligence/registry.rs new file mode 100644 index 00000000..6b38a34a --- /dev/null +++ b/core/src/code_intelligence/registry.rs @@ -0,0 +1,494 @@ +use std::{ + collections::HashMap, + fmt, + future::Future, + ops::Deref, + path::{Path, PathBuf}, + pin::Pin, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex, MutexGuard, + }, + time::{Duration, Instant}, +}; + +use tokio::sync::{Mutex as AsyncMutex, Notify}; + +mod lifecycle; +#[cfg(test)] +mod tests; + +use lifecycle::{retire_idle_entries, shutdown_entry, spawn_factory, stop_runtime}; + +type ShutdownFuture = Pin> + Send + 'static>>; +type ShutdownCallback = dyn Fn(Arc) -> ShutdownFuture + Send + Sync + 'static; + +/// Fully isolated identity of one workspace runtime. +/// +/// Construction resolves symlinks and lexical aliases so equivalent roots +/// cannot create duplicate language processes. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct RegistryKey { + isolation_scope: String, + canonical_root: PathBuf, + layout_hash: u64, +} + +impl RegistryKey { + pub(crate) async fn new( + isolation_scope: impl Into, + workspace_root: impl AsRef, + layout_hash: u64, + ) -> Result { + let isolation_scope = isolation_scope.into(); + if isolation_scope.trim().is_empty() { + return Err(RegistryKeyError::EmptyIsolationScope); + } + + let supplied_root = workspace_root.as_ref().to_path_buf(); + let canonical_root = tokio::fs::canonicalize(&supplied_root) + .await + .map_err(|source| RegistryKeyError::Canonicalize { + root: supplied_root, + source, + })?; + let metadata = tokio::fs::metadata(&canonical_root) + .await + .map_err(|source| RegistryKeyError::Canonicalize { + root: canonical_root.clone(), + source, + })?; + if !metadata.is_dir() { + return Err(RegistryKeyError::RootIsNotDirectory { + root: canonical_root, + }); + } + + Ok(Self { + isolation_scope, + canonical_root, + layout_hash, + }) + } + + #[cfg(test)] + pub(crate) fn isolation_scope(&self) -> &str { + &self.isolation_scope + } + + pub(crate) fn canonical_root(&self) -> &Path { + &self.canonical_root + } + + #[cfg(test)] + pub(crate) const fn layout_hash(&self) -> u64 { + self.layout_hash + } +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum RegistryKeyError { + #[error("Code Intelligence isolation scope cannot be empty")] + EmptyIsolationScope, + + #[error("failed to canonicalize Code Intelligence workspace root {root:?}: {source}")] + Canonicalize { + root: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("Code Intelligence workspace root is not a directory: {root:?}")] + RootIsNotDirectory { root: PathBuf }, +} + +/// Bounds applied only to idle runtimes. Active leases are never evicted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct RegistryConfig { + pub(crate) idle_ttl: Duration, + pub(crate) max_idle_entries: usize, +} + +impl RegistryConfig { + pub(crate) const fn new(idle_ttl: Duration, max_idle_entries: usize) -> Self { + Self { + idle_ttl, + max_idle_entries, + } + } +} + +impl Default for RegistryConfig { + fn default() -> Self { + Self::new(Duration::from_secs(5 * 60), 8) + } +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum RegistryAcquireError { + #[error("Code Intelligence registry is shut down")] + ShuttingDown, + + #[error("Code Intelligence runtime initialization failed: {0}")] + Factory(Arc), + + #[error("Code Intelligence runtime initialization panicked: {message}")] + FactoryPanicked { message: Arc }, + + #[error("Code Intelligence runtime lease limit was exhausted")] + LeaseLimit, +} + +impl Clone for RegistryAcquireError { + fn clone(&self) -> Self { + match self { + Self::ShuttingDown => Self::ShuttingDown, + Self::Factory(error) => Self::Factory(Arc::clone(error)), + Self::FactoryPanicked { message } => Self::FactoryPanicked { + message: Arc::clone(message), + }, + Self::LeaseLimit => Self::LeaseLimit, + } + } +} + +#[derive(Debug)] +pub(crate) enum RegistryShutdownFailure { + Runtime(Arc), + Panicked { message: Arc }, +} + +#[derive(Debug)] +pub(crate) struct RegistryShutdownError { + pub(crate) key: RegistryKey, + pub(crate) failure: RegistryShutdownFailure, +} + +#[derive(Debug)] +pub(crate) struct RegistryReport { + pub(crate) removed: Vec, + pub(crate) errors: Vec>, +} + +enum StartFailure { + Factory(Arc), + Panicked(Arc), +} + +impl Clone for StartFailure { + fn clone(&self) -> Self { + match self { + Self::Factory(error) => Self::Factory(Arc::clone(error)), + Self::Panicked(message) => Self::Panicked(Arc::clone(message)), + } + } +} + +enum EntryObservation { + Starting, + Ready, + Failed(StartFailure), + Unavailable, +} + +enum EntryState { + Starting, + Ready { + runtime: Arc, + leases: usize, + idle_since: Option, + last_access: u64, + }, + Failed(StartFailure), + ShuttingDown, + Stopped, +} + +struct RegistryEntry { + state: Mutex>, + changed: Notify, +} + +impl RegistryEntry { + fn starting() -> Self { + Self { + state: Mutex::new(EntryState::Starting), + changed: Notify::new(), + } + } +} + +struct RegistryState { + accepting: bool, + entries: HashMap>>, +} + +struct RegistryInner { + config: RegistryConfig, + state: Mutex>, + access_clock: AtomicU64, + lifecycle: AsyncMutex<()>, + shutdown: Arc>, +} + +/// Workspace-scoped runtime registry with single-flight initialization. +pub(crate) struct LocalCodeIntelligenceRegistry { + inner: Arc>, +} + +impl Clone for LocalCodeIntelligenceRegistry { + fn clone(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + } + } +} + +impl LocalCodeIntelligenceRegistry +where + R: Send + Sync + 'static, + E: Send + Sync + 'static, +{ + pub(crate) fn new(config: RegistryConfig, shutdown: S) -> Self + where + S: Fn(Arc) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + let shutdown = Arc::new(move |runtime| Box::pin(shutdown(runtime)) as ShutdownFuture); + Self { + inner: Arc::new(RegistryInner { + config, + state: Mutex::new(RegistryState { + accepting: true, + entries: HashMap::new(), + }), + access_clock: AtomicU64::new(0), + lifecycle: AsyncMutex::new(()), + shutdown, + }), + } + } + + /// Acquire one runtime. Exactly one owned factory task is active per key. + pub(crate) async fn acquire( + &self, + key: RegistryKey, + factory: F, + ) -> Result, RegistryAcquireError> + where + F: FnOnce(RegistryKey) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + { + let (entry, starts_factory) = { + let mut state = mutex_lock(&self.inner.state); + if !state.accepting { + return Err(RegistryAcquireError::ShuttingDown); + } + match state.entries.get(&key) { + Some(entry) => (Arc::clone(entry), false), + None => { + let entry = Arc::new(RegistryEntry::starting()); + state.entries.insert(key.clone(), Arc::clone(&entry)); + (entry, true) + } + } + }; + + if starts_factory { + spawn_factory( + Arc::clone(&self.inner), + key.clone(), + Arc::clone(&entry), + factory, + ); + } + self.wait_for_lease(key, entry).await + } + + #[cfg(test)] + pub(crate) fn entry_count(&self) -> usize { + mutex_lock(&self.inner.state).entries.len() + } + + #[cfg(test)] + pub(crate) fn is_accepting(&self) -> bool { + mutex_lock(&self.inner.state).accepting + } + + pub(crate) async fn cleanup_idle(&self) -> RegistryReport { + let _lifecycle = self.inner.lifecycle.lock().await; + let retired = retire_idle_entries(&self.inner, Instant::now()); + let mut report = RegistryReport { + removed: retired.iter().map(|(key, _, _)| key.clone()).collect(), + errors: Vec::new(), + }; + for (key, entry, runtime) in retired { + if let Some(failure) = stop_runtime(&self.inner, &entry, runtime).await { + report.errors.push(RegistryShutdownError { key, failure }); + } + } + report + } + + /// Permanently stop admission and shut down every registered runtime. + pub(crate) async fn shutdown_all(&self) -> RegistryReport { + let _lifecycle = self.inner.lifecycle.lock().await; + let entries = { + let mut state = mutex_lock(&self.inner.state); + state.accepting = false; + std::mem::take(&mut state.entries) + .into_iter() + .collect::>() + }; + let mut report = RegistryReport { + removed: entries.iter().map(|(key, _)| key.clone()).collect(), + errors: Vec::new(), + }; + for (key, entry) in entries { + if let Some(failure) = shutdown_entry(&self.inner, &entry).await { + report.errors.push(RegistryShutdownError { key, failure }); + } + } + report + } + + async fn wait_for_lease( + &self, + key: RegistryKey, + entry: Arc>, + ) -> Result, RegistryAcquireError> { + loop { + let changed = entry.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + let observation = { + let state = mutex_lock(&entry.state); + match &*state { + EntryState::Starting => EntryObservation::Starting, + EntryState::Ready { .. } => EntryObservation::Ready, + EntryState::Failed(failure) => EntryObservation::Failed(failure.clone()), + EntryState::ShuttingDown | EntryState::Stopped => EntryObservation::Unavailable, + } + }; + match observation { + EntryObservation::Starting => changed.await, + EntryObservation::Ready => return self.try_lease(&key, &entry), + EntryObservation::Failed(StartFailure::Factory(error)) => { + return Err(RegistryAcquireError::Factory(error)); + } + EntryObservation::Failed(StartFailure::Panicked(message)) => { + return Err(RegistryAcquireError::FactoryPanicked { message }); + } + EntryObservation::Unavailable => { + return Err(RegistryAcquireError::ShuttingDown); + } + } + } + } + + fn try_lease( + &self, + key: &RegistryKey, + entry: &Arc>, + ) -> Result, RegistryAcquireError> { + let registry = mutex_lock(&self.inner.state); + if !registry.accepting + || !registry + .entries + .get(key) + .is_some_and(|current| Arc::ptr_eq(current, entry)) + { + return Err(RegistryAcquireError::ShuttingDown); + } + + let mut state = mutex_lock(&entry.state); + let EntryState::Ready { + runtime, + leases, + idle_since, + last_access, + } = &mut *state + else { + return Err(RegistryAcquireError::ShuttingDown); + }; + *leases = leases + .checked_add(1) + .ok_or(RegistryAcquireError::LeaseLimit)?; + *idle_since = None; + *last_access = next_access(&self.inner); + Ok(RuntimeLease { + key: key.clone(), + runtime: Arc::clone(runtime), + entry: Arc::clone(entry), + registry: Arc::clone(&self.inner), + }) + } +} + +/// RAII ownership of one runtime consumer. +pub(crate) struct RuntimeLease { + key: RegistryKey, + runtime: Arc, + entry: Arc>, + registry: Arc>, +} + +impl fmt::Debug for RuntimeLease { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RuntimeLease") + .field("key", &self.key) + .finish_non_exhaustive() + } +} + +impl RuntimeLease { + #[cfg(test)] + pub(crate) fn key(&self) -> &RegistryKey { + &self.key + } +} + +impl Deref for RuntimeLease { + type Target = R; + + fn deref(&self) -> &Self::Target { + self.runtime.as_ref() + } +} + +impl Drop for RuntimeLease { + fn drop(&mut self) { + let mut state = mutex_lock(&self.entry.state); + let EntryState::Ready { + leases, + idle_since, + last_access, + .. + } = &mut *state + else { + return; + }; + if *leases == 0 { + return; + } + *leases -= 1; + *last_access = next_access(&self.registry); + if *leases == 0 { + *idle_since = Some(Instant::now()); + } + } +} + +fn next_access(registry: &RegistryInner) -> u64 { + registry + .access_clock + .fetch_add(1, Ordering::Relaxed) + .wrapping_add(1) +} + +fn mutex_lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} diff --git a/core/src/code_intelligence/registry/lifecycle.rs b/core/src/code_intelligence/registry/lifecycle.rs new file mode 100644 index 00000000..9c13cef4 --- /dev/null +++ b/core/src/code_intelligence/registry/lifecycle.rs @@ -0,0 +1,216 @@ +use std::{any::Any, collections::HashSet}; + +use futures::FutureExt; + +use super::*; + +type RetiredEntry = (RegistryKey, Arc>, Arc); + +pub(super) fn spawn_factory( + registry: Arc>, + key: RegistryKey, + entry: Arc>, + factory: F, +) where + R: Send + Sync + 'static, + E: Send + Sync + 'static, + F: FnOnce(RegistryKey) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, +{ + tokio::spawn(async move { + let factory_key = key.clone(); + let outcome = std::panic::AssertUnwindSafe(async move { factory(factory_key).await }) + .catch_unwind() + .await; + match outcome { + Ok(Ok(runtime)) => { + let mut state = mutex_lock(&entry.state); + if matches!(*state, EntryState::Starting) { + *state = EntryState::Ready { + runtime: Arc::new(runtime), + leases: 0, + idle_since: Some(Instant::now()), + last_access: next_access(®istry), + }; + } + } + Ok(Err(error)) => complete_factory_failure( + ®istry, + &key, + &entry, + StartFailure::Factory(Arc::new(error)), + ), + Err(payload) => complete_factory_failure( + ®istry, + &key, + &entry, + StartFailure::Panicked(panic_message(payload)), + ), + } + entry.changed.notify_waiters(); + }); +} + +fn complete_factory_failure( + registry: &Arc>, + key: &RegistryKey, + entry: &Arc>, + failure: StartFailure, +) { + { + let mut state = mutex_lock(&entry.state); + if matches!(*state, EntryState::Starting) { + *state = EntryState::Failed(failure); + } + } + let mut registry = mutex_lock(®istry.state); + if registry + .entries + .get(key) + .is_some_and(|current| Arc::ptr_eq(current, entry)) + { + registry.entries.remove(key); + } +} + +pub(super) fn retire_idle_entries( + registry: &Arc>, + now: Instant, +) -> Vec> { + let mut state = mutex_lock(®istry.state); + let mut idle = state + .entries + .iter() + .filter_map(|(key, entry)| { + if Arc::strong_count(entry) != 1 { + return None; + } + let entry_state = mutex_lock(&entry.state); + let EntryState::Ready { + leases: 0, + idle_since: Some(idle_since), + last_access, + .. + } = &*entry_state + else { + return None; + }; + let expired = now.checked_duration_since(*idle_since).unwrap_or_default() + >= registry.config.idle_ttl; + Some((key.clone(), *last_access, expired)) + }) + .collect::>(); + idle.sort_by_key(|(_, last_access, _)| *last_access); + + let mut selected = idle + .iter() + .filter(|(_, _, expired)| *expired) + .map(|(key, _, _)| key.clone()) + .collect::>(); + let retained_idle = idle.len().saturating_sub(selected.len()); + let excess = retained_idle.saturating_sub(registry.config.max_idle_entries); + for (key, _, _) in idle.iter().filter(|(_, _, expired)| !*expired).take(excess) { + selected.insert(key.clone()); + } + + let mut retired = Vec::with_capacity(selected.len()); + for key in selected { + let Some(entry) = state.entries.remove(&key) else { + continue; + }; + let runtime = { + let mut entry_state = mutex_lock(&entry.state); + match &*entry_state { + EntryState::Ready { + runtime, leases: 0, .. + } => { + let runtime = Arc::clone(runtime); + *entry_state = EntryState::ShuttingDown; + Some(runtime) + } + _ => None, + } + }; + if let Some(runtime) = runtime { + retired.push((key, entry, runtime)); + } else { + state.entries.insert(key, entry); + } + } + retired +} + +pub(super) async fn shutdown_entry( + registry: &Arc>, + entry: &Arc>, +) -> Option> +where + R: Send + Sync + 'static, + E: Send + Sync + 'static, +{ + loop { + let changed = entry.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + let runtime = { + let mut state = mutex_lock(&entry.state); + match &*state { + EntryState::Starting => None, + EntryState::Ready { runtime, .. } => { + let runtime = Arc::clone(runtime); + *state = EntryState::ShuttingDown; + Some(runtime) + } + EntryState::Failed(_) => { + *state = EntryState::Stopped; + entry.changed.notify_waiters(); + return None; + } + EntryState::ShuttingDown => None, + EntryState::Stopped => return None, + } + }; + match runtime { + Some(runtime) => return stop_runtime(registry, entry, runtime).await, + None => changed.await, + } + } +} + +pub(super) async fn stop_runtime( + registry: &Arc>, + entry: &Arc>, + runtime: Arc, +) -> Option> +where + R: Send + Sync + 'static, + E: Send + Sync + 'static, +{ + let shutdown = Arc::clone(®istry.shutdown); + let outcome = std::panic::AssertUnwindSafe(async move { shutdown(runtime).await }) + .catch_unwind() + .await; + { + let mut state = mutex_lock(&entry.state); + *state = EntryState::Stopped; + } + entry.changed.notify_waiters(); + + match outcome { + Ok(Ok(())) => None, + Ok(Err(error)) => Some(RegistryShutdownFailure::Runtime(Arc::new(error))), + Err(payload) => Some(RegistryShutdownFailure::Panicked { + message: panic_message(payload), + }), + } +} + +fn panic_message(payload: Box) -> Arc { + if let Some(message) = payload.downcast_ref::<&str>() { + Arc::from(*message) + } else if let Some(message) = payload.downcast_ref::() { + Arc::from(message.as_str()) + } else { + Arc::from("unknown panic payload") + } +} diff --git a/core/src/code_intelligence/registry/tests.rs b/core/src/code_intelligence/registry/tests.rs new file mode 100644 index 00000000..ece701a4 --- /dev/null +++ b/core/src/code_intelligence/registry/tests.rs @@ -0,0 +1,358 @@ +use std::{ + error::Error, + sync::atomic::{AtomicUsize, Ordering}, +}; + +use tempfile::TempDir; +use tokio::sync::Barrier; + +use super::*; + +#[derive(Debug)] +struct MockRuntime { + id: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TestError(&'static str); + +impl fmt::Display for TestError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.0) + } +} + +impl Error for TestError {} + +fn registry( + config: RegistryConfig, + shutdowns: Arc>>, +) -> LocalCodeIntelligenceRegistry { + LocalCodeIntelligenceRegistry::new(config, move |runtime: Arc| { + let shutdowns = Arc::clone(&shutdowns); + async move { + mutex_lock(&shutdowns).push(runtime.id); + Ok(()) + } + }) +} + +async fn registry_key(scope: &str, root: &Path, layout_hash: u64) -> RegistryKey { + RegistryKey::new(scope, root, layout_hash).await.unwrap() +} + +#[tokio::test] +async fn key_canonicalizes_equivalent_roots() { + let workspace = TempDir::new().unwrap(); + let nested = workspace.path().join("nested"); + tokio::fs::create_dir(&nested).await.unwrap(); + let aliased = nested.join("..").join("nested"); + + let direct = registry_key("tenant-a", &nested, 42).await; + let normalized = registry_key("tenant-a", &aliased, 42).await; + + assert_eq!(direct, normalized); + assert_eq!( + direct.canonical_root(), + tokio::fs::canonicalize(&nested).await.unwrap() + ); + assert_eq!(direct.isolation_scope(), "tenant-a"); + assert_eq!(direct.layout_hash(), 42); +} + +#[tokio::test] +async fn concurrent_acquire_starts_exactly_once() { + let workspace = TempDir::new().unwrap(); + let key = registry_key("tenant-a", workspace.path(), 1).await; + let starts = Arc::new(AtomicUsize::new(0)); + let release = Arc::new(Barrier::new(2)); + let registry = registry(RegistryConfig::default(), Arc::new(Mutex::new(Vec::new()))); + + let first = { + let registry = registry.clone(); + let key = key.clone(); + let starts = Arc::clone(&starts); + let release = Arc::clone(&release); + tokio::spawn(async move { + registry + .acquire(key, move |_| async move { + starts.fetch_add(1, Ordering::SeqCst); + release.wait().await; + Ok(MockRuntime { id: 7 }) + }) + .await + .unwrap() + }) + }; + while starts.load(Ordering::SeqCst) == 0 { + tokio::task::yield_now().await; + } + first.abort(); + assert!(first.await.unwrap_err().is_cancelled()); + + let mut tasks = Vec::new(); + for _ in 0..15 { + let registry = registry.clone(); + let key = key.clone(); + tasks.push(tokio::spawn(async move { + registry + .acquire(key, |_| async { + panic!("cancelled caller's owned factory must remain single-flight"); + #[allow(unreachable_code)] + Ok(MockRuntime { id: 99 }) + }) + .await + .unwrap() + })); + } + assert_eq!(starts.load(Ordering::SeqCst), 1); + release.wait().await; + + let mut leases = Vec::new(); + for task in tasks { + let lease = task.await.unwrap(); + assert_eq!(lease.id, 7); + assert_eq!(lease.key(), &key); + leases.push(lease); + } + assert_eq!(starts.load(Ordering::SeqCst), 1); + assert_eq!(registry.entry_count(), 1); + drop(leases); +} + +#[tokio::test] +async fn failed_factory_is_shared_then_next_acquire_retries() { + let workspace = TempDir::new().unwrap(); + let key = registry_key("tenant-a", workspace.path(), 1).await; + let starts = Arc::new(AtomicUsize::new(0)); + let release = Arc::new(Barrier::new(2)); + let registry = registry(RegistryConfig::default(), Arc::new(Mutex::new(Vec::new()))); + let mut tasks = Vec::new(); + for _ in 0..2 { + let registry = registry.clone(); + let key = key.clone(); + let starts = Arc::clone(&starts); + let release = Arc::clone(&release); + tasks.push(tokio::spawn(async move { + registry + .acquire(key, move |_| async move { + starts.fetch_add(1, Ordering::SeqCst); + release.wait().await; + Err(TestError("start failed")) + }) + .await + })); + } + while starts.load(Ordering::SeqCst) == 0 { + tokio::task::yield_now().await; + } + loop { + let all_waiters_attached = { + let state = mutex_lock(®istry.inner.state); + state + .entries + .get(&key) + .is_some_and(|entry| Arc::strong_count(entry) >= 4) + }; + if all_waiters_attached { + break; + } + tokio::task::yield_now().await; + } + release.wait().await; + + let first = tasks.remove(0).await.unwrap().unwrap_err(); + let second = tasks.remove(0).await.unwrap().unwrap_err(); + let (RegistryAcquireError::Factory(first), RegistryAcquireError::Factory(second)) = + (first, second) + else { + panic!("expected shared factory errors"); + }; + assert!(Arc::ptr_eq(&first, &second)); + assert_eq!(starts.load(Ordering::SeqCst), 1); + assert_eq!(registry.entry_count(), 0); + + let starts_for_retry = Arc::clone(&starts); + let lease = registry + .acquire(key, move |_| async move { + starts_for_retry.fetch_add(1, Ordering::SeqCst); + Ok(MockRuntime { id: 8 }) + }) + .await + .unwrap(); + assert_eq!(lease.id, 8); + assert_eq!(starts.load(Ordering::SeqCst), 2); + + let panic_key = registry_key("tenant-a", workspace.path(), 2).await; + let panic = registry + .acquire(panic_key.clone(), |_| async { + panic!("factory panic"); + #[allow(unreachable_code)] + Ok(MockRuntime { id: 9 }) + }) + .await + .unwrap_err(); + assert!(matches!( + panic, + RegistryAcquireError::FactoryPanicked { .. } + )); + let recovered = registry + .acquire(panic_key, |_| async { Ok(MockRuntime { id: 9 }) }) + .await + .unwrap(); + assert_eq!(recovered.id, 9); +} + +#[tokio::test] +async fn tenant_root_and_layout_are_independent_isolation_dimensions() { + let first_root = TempDir::new().unwrap(); + let second_root = TempDir::new().unwrap(); + let keys = vec![ + registry_key("tenant-a", first_root.path(), 1).await, + registry_key("tenant-b", first_root.path(), 1).await, + registry_key("tenant-a", second_root.path(), 1).await, + registry_key("tenant-a", first_root.path(), 2).await, + ]; + let starts = Arc::new(AtomicUsize::new(0)); + let registry = registry(RegistryConfig::default(), Arc::new(Mutex::new(Vec::new()))); + let mut leases = Vec::new(); + + for key in &keys { + let starts = Arc::clone(&starts); + leases.push( + registry + .acquire(key.clone(), move |_| async move { + let id = starts.fetch_add(1, Ordering::SeqCst) + 1; + Ok(MockRuntime { id }) + }) + .await + .unwrap(), + ); + } + assert_eq!(starts.load(Ordering::SeqCst), 4); + assert_eq!(registry.entry_count(), 4); + + let reused = registry + .acquire(keys[0].clone(), |_| async move { + panic!("factory must not run for an existing key"); + #[allow(unreachable_code)] + Ok(MockRuntime { id: 99 }) + }) + .await + .unwrap(); + assert_eq!(reused.id, leases[0].id); + assert_eq!(starts.load(Ordering::SeqCst), 4); +} + +#[tokio::test] +async fn active_lease_blocks_cleanup_and_release_allows_ttl_reclaim() { + let workspace = TempDir::new().unwrap(); + let key = registry_key("tenant-a", workspace.path(), 1).await; + let shutdowns = Arc::new(Mutex::new(Vec::new())); + let registry = registry( + RegistryConfig::new(Duration::ZERO, 8), + Arc::clone(&shutdowns), + ); + let lease = registry + .acquire(key.clone(), |_| async { Ok(MockRuntime { id: 1 }) }) + .await + .unwrap(); + + let active = registry.cleanup_idle().await; + assert!(active.removed.is_empty()); + assert!(active.errors.is_empty()); + assert_eq!(registry.entry_count(), 1); + + drop(lease); + let idle = registry.cleanup_idle().await; + assert_eq!(idle.removed, vec![key]); + assert!(idle.errors.is_empty()); + assert_eq!(*mutex_lock(&shutdowns), vec![1]); + assert_eq!(registry.entry_count(), 0); +} + +#[tokio::test] +async fn cleanup_enforces_idle_lru_bound() { + let first_root = TempDir::new().unwrap(); + let second_root = TempDir::new().unwrap(); + let first_key = registry_key("tenant-a", first_root.path(), 1).await; + let second_key = registry_key("tenant-a", second_root.path(), 1).await; + let shutdowns = Arc::new(Mutex::new(Vec::new())); + let registry = registry( + RegistryConfig::new(Duration::from_secs(60 * 60), 1), + Arc::clone(&shutdowns), + ); + + let first = registry + .acquire(first_key.clone(), |_| async { Ok(MockRuntime { id: 1 }) }) + .await + .unwrap(); + drop(first); + let second = registry + .acquire(second_key.clone(), |_| async { Ok(MockRuntime { id: 2 }) }) + .await + .unwrap(); + drop(second); + + let report = registry.cleanup_idle().await; + assert_eq!(report.removed, vec![first_key]); + assert!(report.errors.is_empty()); + assert_eq!(*mutex_lock(&shutdowns), vec![1]); + assert_eq!(registry.entry_count(), 1); + + let reused = registry + .acquire(second_key, |_| async { + panic!("most recently used idle runtime should remain cached"); + #[allow(unreachable_code)] + Ok(MockRuntime { id: 99 }) + }) + .await + .unwrap(); + assert_eq!(reused.id, 2); +} + +#[tokio::test] +async fn shutdown_all_is_terminal_and_includes_active_runtimes() { + let first_root = TempDir::new().unwrap(); + let second_root = TempDir::new().unwrap(); + let first_key = registry_key("tenant-a", first_root.path(), 1).await; + let second_key = registry_key("tenant-a", second_root.path(), 1).await; + let shutdowns = Arc::new(Mutex::new(Vec::new())); + let registry = registry(RegistryConfig::default(), Arc::clone(&shutdowns)); + let active = registry + .acquire(first_key.clone(), |_| async { Ok(MockRuntime { id: 1 }) }) + .await + .unwrap(); + let idle = registry + .acquire(second_key.clone(), |_| async { Ok(MockRuntime { id: 2 }) }) + .await + .unwrap(); + drop(idle); + + let mut report = registry.shutdown_all().await; + report + .removed + .sort_by(|left, right| left.canonical_root().cmp(right.canonical_root())); + let mut expected = vec![first_key.clone(), second_key]; + expected.sort_by(|left, right| left.canonical_root().cmp(right.canonical_root())); + assert_eq!(report.removed, expected); + assert!(report.errors.is_empty()); + assert!(!registry.is_accepting()); + assert_eq!(registry.entry_count(), 0); + let mut stopped = mutex_lock(&shutdowns).clone(); + stopped.sort_unstable(); + assert_eq!(stopped, vec![1, 2]); + assert_eq!(active.id, 1); + + let starts = Arc::new(AtomicUsize::new(0)); + let starts_for_factory = Arc::clone(&starts); + let result = registry + .acquire(first_key, move |_| async move { + starts_for_factory.fetch_add(1, Ordering::SeqCst); + Ok(MockRuntime { id: 3 }) + }) + .await; + assert!(matches!(result, Err(RegistryAcquireError::ShuttingDown))); + assert_eq!(starts.load(Ordering::SeqCst), 0); + assert!(registry.shutdown_all().await.removed.is_empty()); +} diff --git a/core/src/code_intelligence/service.rs b/core/src/code_intelligence/service.rs new file mode 100644 index 00000000..4998f7a0 --- /dev/null +++ b/core/src/code_intelligence/service.rs @@ -0,0 +1,70 @@ +use super::{ + CodeDiagnostic, CodeIntelligenceResult, CodeIntelligenceStatus, CodeLocation, CodePosition, + CodeQueryResult, DocumentSymbol, NavigationKind, SymbolInformation, +}; +use crate::workspace::WorkspacePath; +use async_trait::async_trait; +use tokio::sync::watch; +use tokio_util::sync::CancellationToken; + +/// Workspace-scoped provider for read-only semantic code queries. +/// +/// Implementations operate on saved documents. Unsaved editor buffers are +/// intentionally outside this contract so independent views cannot overwrite +/// one another's semantic state. +#[async_trait] +pub trait WorkspaceCodeIntelligence: Send + Sync { + /// Subscribe to runtime lifecycle and capability changes. + fn subscribe_status(&self) -> watch::Receiver; + + /// Return the latest status snapshot without waiting for a state change. + fn status(&self) -> CodeIntelligenceStatus { + let receiver = self.subscribe_status(); + let status = receiver.borrow().clone(); + status + } + + /// Return the hierarchical symbol outline for a saved document. + async fn document_symbols( + &self, + path: &WorkspacePath, + cancellation: CancellationToken, + ) -> CodeIntelligenceResult>; + + /// Search symbols across the workspace, bounded by `limit`. + async fn search_symbols( + &self, + query: &str, + limit: usize, + cancellation: CancellationToken, + ) -> CodeIntelligenceResult>; + + /// Resolve one semantic navigation operation from a saved document. + async fn navigate( + &self, + kind: NavigationKind, + path: &WorkspacePath, + position: CodePosition, + cancellation: CancellationToken, + ) -> CodeIntelligenceResult>; + + /// Pull diagnostics for one saved document, or for the workspace when + /// `path` is `None`. + async fn diagnostics( + &self, + path: Option<&WorkspacePath>, + cancellation: CancellationToken, + ) -> CodeIntelligenceResult>; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_send_sync() {} + + #[test] + fn service_trait_is_object_safe_send_and_sync() { + assert_send_sync::(); + } +} diff --git a/core/src/code_intelligence/types.rs b/core/src/code_intelligence/types.rs new file mode 100644 index 00000000..0200a2a2 --- /dev/null +++ b/core/src/code_intelligence/types.rs @@ -0,0 +1,302 @@ +use crate::workspace::WorkspacePath; +use std::fmt; + +/// Extensible identifier for a programming language. +/// +/// Values use the language identifier understood by the active semantic +/// runtime. This is a newtype rather than an enum so adding language support +/// does not require changing the public contract. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct LanguageId(String); + +impl LanguageId { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From for LanguageId { + fn from(value: String) -> Self { + Self::new(value) + } +} + +impl From<&str> for LanguageId { + fn from(value: &str) -> Self { + Self::new(value) + } +} + +impl AsRef for LanguageId { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for LanguageId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Zero-based position in a text document. +/// +/// `character` is measured in UTF-16 code units, not Unicode scalar values, +/// grapheme clusters, or UTF-8 bytes. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct CodePosition { + pub line: u32, + pub character: u32, +} + +impl CodePosition { + pub const fn new(line: u32, character: u32) -> Self { + Self { line, character } + } +} + +/// Half-open range in a text document using zero-based UTF-16 positions. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct CodeRange { + pub start: CodePosition, + pub end: CodePosition, +} + +impl CodeRange { + pub const fn new(start: CodePosition, end: CodePosition) -> Self { + Self { start, end } + } +} + +/// Workspace-relative location of a semantic result. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct CodeLocation { + pub path: WorkspacePath, + pub range: CodeRange, +} + +/// Monotonic revision assigned to a saved document by the runtime. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct DocumentRevision(u64); + +impl DocumentRevision { + pub const fn new(value: u64) -> Self { + Self(value) + } + + pub const fn value(self) -> u64 { + self.0 + } +} + +impl fmt::Display for DocumentRevision { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +/// Saved-document evidence associated with a query result. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentSnapshot { + pub revision: DocumentRevision, + /// Opaque hash of the saved content observed by the runtime. + pub content_hash: String, + /// Whether the saved document changed before the query completed. + pub stale: bool, +} + +/// Bounded result returned by a semantic query. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodeQueryResult { + pub items: Vec, + pub truncated: bool, + /// Monotonic workspace revision observed by the query. + pub workspace_revision: u64, + /// Present for a query anchored to one saved document. + pub document: Option, +} + +/// Normalized kind of a code symbol. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum CodeSymbolKind { + File, + Module, + Namespace, + Package, + Class, + Method, + Property, + Field, + Constructor, + Enum, + Interface, + Function, + Variable, + Constant, + String, + Number, + Boolean, + Array, + Object, + Key, + Null, + EnumMember, + Struct, + Event, + Operator, + TypeParameter, + Unknown, +} + +/// Hierarchical symbol returned for one document. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentSymbol { + pub name: String, + pub detail: Option, + pub kind: CodeSymbolKind, + /// Full syntactic range of the symbol. + pub range: CodeRange, + /// Range callers should select when navigating to the symbol. + pub selection_range: CodeRange, + pub children: Vec, +} + +/// Symbol returned by a workspace-wide search. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SymbolInformation { + pub name: String, + pub kind: CodeSymbolKind, + pub location: CodeLocation, + pub container_name: Option, +} + +/// Supported semantic navigation operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum NavigationKind { + Definition, + Declaration, + References, + Implementations, +} + +/// Severity of a code diagnostic. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum CodeDiagnosticSeverity { + Error, + Warning, + Information, + Hint, +} + +/// Diagnostic associated with a saved workspace document. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodeDiagnostic { + pub location: CodeLocation, + pub severity: Option, + /// String-normalized diagnostic code when the runtime supplies one. + pub code: Option, + pub source: Option, + pub message: String, +} + +/// Read-only semantic operations currently available from a runtime. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct CodeIntelligenceCapabilities { + pub document_symbols: bool, + pub workspace_symbols: bool, + pub definition: bool, + pub declaration: bool, + pub references: bool, + pub implementations: bool, + pub diagnostics: bool, +} + +/// Lifecycle state of a workspace or language runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CodeIntelligenceState { + Starting, + Ready, + Degraded, + Unavailable, +} + +/// Status of one configured language runtime. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodeIntelligenceLanguageStatus { + pub language: LanguageId, + pub state: CodeIntelligenceState, + pub capabilities: CodeIntelligenceCapabilities, + pub message: Option, +} + +/// Current aggregate status of workspace code intelligence. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodeIntelligenceStatus { + pub state: CodeIntelligenceState, + /// Union of operations available from ready or degraded language runtimes. + pub capabilities: CodeIntelligenceCapabilities, + pub languages: Vec, + pub message: Option, +} + +impl Default for CodeIntelligenceStatus { + fn default() -> Self { + Self { + state: CodeIntelligenceState::Unavailable, + capabilities: CodeIntelligenceCapabilities::default(), + languages: Vec::new(), + message: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_send_sync() {} + + #[test] + fn public_value_types_are_send_and_sync() { + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::>(); + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + } + + #[test] + fn position_character_counts_utf16_code_units() { + let prefix = "a\u{1f980}\u{4e2d}"; + let position = CodePosition::new(0, prefix.encode_utf16().count() as u32); + + assert_eq!(position.character, 4); + } + + #[test] + fn language_id_is_open_ended() { + let language = LanguageId::from("example-language"); + + assert_eq!(language.as_str(), "example-language"); + assert_eq!(language.to_string(), "example-language"); + } + + #[test] + fn default_status_exposes_no_capabilities() { + let status = CodeIntelligenceStatus::default(); + + assert_eq!(status.state, CodeIntelligenceState::Unavailable); + assert_eq!(status.capabilities, CodeIntelligenceCapabilities::default()); + assert!(status.languages.is_empty()); + } +} diff --git a/core/src/code_intelligence/workspace_runtime.rs b/core/src/code_intelligence/workspace_runtime.rs new file mode 100644 index 00000000..43fbd31e --- /dev/null +++ b/core/src/code_intelligence/workspace_runtime.rs @@ -0,0 +1,897 @@ +//! One lazily-started semantic runtime generation for a workspace layout. + +#[cfg(test)] +mod integration_tests; +mod support; +#[cfg(test)] +mod tests; + +use support::*; + +use std::{ + path::Path, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, + }, + time::{Duration, Instant}, +}; + +use futures::{stream::FuturesUnordered, StreamExt}; +use tokio::sync::{watch, Mutex, RwLock}; +use tokio_util::sync::CancellationToken; + +use super::{ + diagnostics::DiagnosticsStore, + document_store::DocumentStore, + language_profile::LanguageServerProfile, + language_runtime::{LanguageRuntime, LanguageRuntimeError}, + project_layout::{ProjectLanguageProfile, ProjectLayout}, + CodeDiagnostic, CodeIntelligenceCapabilities, CodeIntelligenceError, + CodeIntelligenceLanguageStatus, CodeIntelligenceResult, CodeIntelligenceState, + CodeIntelligenceStatus, CodeLocation, CodePosition, CodeQueryResult, DocumentSymbol, + NavigationKind, SymbolInformation, +}; +use crate::workspace::{ + LocalWorkspaceManifestSnapshot, WorkspaceFileChange, WorkspaceFileSystem, WorkspacePath, +}; + +const DOCUMENT_CAPACITY: usize = 256; +const DIAGNOSTICS_CAPACITY: usize = 512; +const WORKSPACE_DIAGNOSTIC_LIMIT: usize = 2_000; +const WORKSPACE_DIAGNOSTIC_DOCUMENT_LIMIT: usize = 128; +const WORKSPACE_DIAGNOSTIC_CONCURRENCY: usize = 8; +const MAX_SYMBOL_LIMIT: usize = 1_000; +const START_RETRY_DELAY: Duration = Duration::from_secs(2); + +struct StartFailure { + at: Instant, + message: String, +} + +enum SlotState { + Dormant, + Ready(Arc), + Failed(StartFailure), +} + +struct LanguageSlot { + profile: LanguageServerProfile, + relevant: AtomicBool, + documents: Arc, + state: Arc>, +} + +impl LanguageSlot { + fn new(profile: LanguageServerProfile, relevant: bool, document_capacity: usize) -> Self { + Self { + profile, + relevant: AtomicBool::new(relevant), + documents: Arc::new(DocumentStore::new(document_capacity)), + state: Arc::new(Mutex::new(SlotState::Dormant)), + } + } +} + +/// Runtime processes and saved-document state for one stable layout hash. +pub(crate) struct WorkspaceRuntime { + canonical_root: std::path::PathBuf, + layout: ProjectLayout, + file_system: Arc, + diagnostics: Arc, + slots: Vec, + source_paths: RwLock>, + workspace_revision: AtomicU64, + timeout: Duration, + status: watch::Sender, + shutting_down: AtomicBool, + lifetime: CancellationToken, +} + +impl std::fmt::Debug for WorkspaceRuntime { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("WorkspaceRuntime") + .field("canonical_root", &self.canonical_root) + .field("layout_hash", &self.layout.layout_hash) + .field("workspace_revision", &self.workspace_revision()) + .field("language_slots", &self.slots.len()) + .finish_non_exhaustive() + } +} + +impl Drop for WorkspaceRuntime { + fn drop(&mut self) { + // Registry owners normally call `shutdown`, but a host can be dropped + // during runtime teardown when awaiting is no longer possible. Wake + // every process monitor so its final Arc is released and + // `LanguageRuntime::drop` can terminate the child process. + self.lifetime.cancel(); + } +} + +impl WorkspaceRuntime { + pub(crate) fn new( + canonical_root: std::path::PathBuf, + layout: ProjectLayout, + snapshot: &LocalWorkspaceManifestSnapshot, + file_system: Arc, + timeout: Duration, + ) -> Self { + let profiles = LanguageServerProfile::built_in_defaults(); + Self::new_with_profile_set( + canonical_root, + layout, + snapshot, + file_system, + timeout, + profiles, + DOCUMENT_CAPACITY, + ) + } + + #[cfg(test)] + pub(crate) fn new_with_profiles( + canonical_root: std::path::PathBuf, + layout: ProjectLayout, + snapshot: &LocalWorkspaceManifestSnapshot, + file_system: Arc, + timeout: Duration, + profiles: Vec, + ) -> Self { + Self::new_with_profile_set( + canonical_root, + layout, + snapshot, + file_system, + timeout, + profiles, + DOCUMENT_CAPACITY, + ) + } + + #[cfg(test)] + pub(crate) fn new_with_profiles_and_document_capacity( + canonical_root: std::path::PathBuf, + layout: ProjectLayout, + snapshot: &LocalWorkspaceManifestSnapshot, + file_system: Arc, + timeout: Duration, + profiles: Vec, + document_capacity: usize, + ) -> Self { + Self::new_with_profile_set( + canonical_root, + layout, + snapshot, + file_system, + timeout, + profiles, + document_capacity, + ) + } + + fn new_with_profile_set( + canonical_root: std::path::PathBuf, + layout: ProjectLayout, + snapshot: &LocalWorkspaceManifestSnapshot, + file_system: Arc, + timeout: Duration, + profiles: Vec, + document_capacity: usize, + ) -> Self { + let source_paths = supported_source_paths(snapshot, |path| { + profiles.iter().any(|profile| profile.supports_path(path)) + }); + let slots = profiles + .into_iter() + .map(|profile| { + let relevant = source_paths + .iter() + .any(|path| profile.supports_path(Path::new(path.as_str()))); + LanguageSlot::new(profile, relevant, document_capacity) + }) + .collect(); + let (status, _) = watch::channel(CodeIntelligenceStatus { + state: CodeIntelligenceState::Starting, + message: Some("Code Intelligence starts language runtimes on demand".to_owned()), + ..CodeIntelligenceStatus::default() + }); + Self { + canonical_root, + workspace_revision: AtomicU64::new(snapshot.version), + layout, + file_system, + diagnostics: Arc::new(DiagnosticsStore::new(DIAGNOSTICS_CAPACITY)), + slots, + source_paths: RwLock::new(source_paths), + timeout, + status, + shutting_down: AtomicBool::new(false), + lifetime: CancellationToken::new(), + } + } + + pub(crate) fn layout_hash(&self) -> u64 { + self.layout.layout_hash + } + + pub(crate) fn subscribe_status(&self) -> watch::Receiver { + self.status.subscribe() + } + + pub(crate) async fn update_snapshot(&self, snapshot: &LocalWorkspaceManifestSnapshot) { + let source_paths = supported_source_paths(snapshot, |path| { + self.slots + .iter() + .any(|slot| slot.profile.supports_path(path)) + }); + self.workspace_revision + .store(snapshot.version, Ordering::Release); + *self.source_paths.write().await = source_paths.clone(); + for slot in &self.slots { + let relevant = source_paths + .iter() + .any(|path| slot.profile.supports_path(Path::new(path.as_str()))); + let was_relevant = slot.relevant.swap(relevant, Ordering::AcqRel); + if was_relevant && !relevant { + let runtime = { + let mut state = slot.state.lock().await; + match std::mem::replace(&mut *state, SlotState::Dormant) { + SlotState::Ready(runtime) => Some(runtime), + SlotState::Dormant | SlotState::Failed(_) => None, + } + }; + if let Some(runtime) = runtime { + if let Err(error) = runtime.shutdown().await { + tracing::warn!( + language = %profile_language(slot.profile.id()), + error = %error, + "Code Intelligence could not stop an irrelevant language runtime" + ); + } + } + } + } + self.refresh_status().await; + } + + pub(crate) async fn document_symbols( + &self, + path: &WorkspacePath, + cancellation: CancellationToken, + ) -> CodeIntelligenceResult> { + let content = self.read_saved(path, &cancellation).await?; + let (profile, runtime) = self.runtime_for_path(path, &cancellation).await?; + let mut result = runtime + .document_symbols(path, &content, cancellation.clone()) + .await + .map_err(|error| map_language_error(profile, error))?; + self.complete_saved_query(path, &cancellation, &mut result) + .await?; + result.workspace_revision = self.workspace_revision(); + Ok(result) + } + + pub(crate) async fn search_symbols( + &self, + query: &str, + limit: usize, + cancellation: CancellationToken, + ) -> CodeIntelligenceResult> { + if cancellation.is_cancelled() { + return Err(CodeIntelligenceError::Cancelled); + } + let limit = limit.min(MAX_SYMBOL_LIMIT); + if limit == 0 { + return Ok(self.workspace_result(Vec::new(), false)); + } + + let mut queries = FuturesUnordered::new(); + for slot in self + .slots + .iter() + .filter(|slot| slot.relevant.load(Ordering::Acquire)) + { + let cancellation = cancellation.clone(); + queries.push(async move { + let runtime = self.ensure_runtime(slot, &cancellation).await?; + runtime + .search_symbols(query, limit, cancellation) + .await + .map_err(|error| map_language_error(slot.profile.id(), error)) + }); + } + + if queries.is_empty() { + return Err(CodeIntelligenceError::Unavailable { + message: "no supported saved source files were found in this workspace".to_owned(), + }); + } + + let mut items = Vec::new(); + let mut truncated = false; + let mut first_error = None; + let mut succeeded = false; + while let Some(result) = queries.next().await { + match result { + Ok(result) => { + succeeded = true; + truncated |= result.truncated; + items.extend(result.items); + } + Err(CodeIntelligenceError::Cancelled) => { + return Err(CodeIntelligenceError::Cancelled) + } + Err(error) => { + first_error.get_or_insert(error); + } + }; + } + if !succeeded { + return Err( + first_error.unwrap_or_else(|| CodeIntelligenceError::Unavailable { + message: "no language runtime could search workspace symbols".to_owned(), + }), + ); + } + + items.sort_by_cached_key(symbol_key); + items.dedup_by(|left, right| symbol_key(left) == symbol_key(right)); + truncated |= items.len() > limit; + items.truncate(limit); + Ok(self.workspace_result(items, truncated)) + } + + pub(crate) async fn navigate( + &self, + kind: NavigationKind, + path: &WorkspacePath, + position: CodePosition, + cancellation: CancellationToken, + ) -> CodeIntelligenceResult> { + let content = self.read_saved(path, &cancellation).await?; + let (profile, runtime) = self.runtime_for_path(path, &cancellation).await?; + let mut result = runtime + .navigate(kind, path, position, &content, cancellation.clone()) + .await + .map_err(|error| map_language_error(profile, error))?; + self.complete_saved_query(path, &cancellation, &mut result) + .await?; + result.workspace_revision = self.workspace_revision(); + Ok(result) + } + + pub(crate) async fn diagnostics( + &self, + path: Option<&WorkspacePath>, + cancellation: CancellationToken, + ) -> CodeIntelligenceResult> { + let Some(path) = path else { + return self.workspace_diagnostics(cancellation).await; + }; + + let content = self.read_saved(path, &cancellation).await?; + let (profile, runtime) = self.runtime_for_path(path, &cancellation).await?; + let mut result = runtime + .diagnostics(path, &content, cancellation.clone()) + .await + .map_err(|error| map_language_error(profile, error))?; + self.complete_saved_query(path, &cancellation, &mut result) + .await?; + result.workspace_revision = self.workspace_revision(); + Ok(result) + } + + async fn workspace_diagnostics( + &self, + cancellation: CancellationToken, + ) -> CodeIntelligenceResult> { + if cancellation.is_cancelled() { + return Err(CodeIntelligenceError::Cancelled); + } + let source_paths = tokio::select! { + _ = cancellation.cancelled() => return Err(CodeIntelligenceError::Cancelled), + paths = self.source_paths.read() => paths.clone(), + }; + let relevant_slots = self + .slots + .iter() + .enumerate() + .filter(|(_, slot)| slot.relevant.load(Ordering::Acquire)) + .collect::>(); + if relevant_slots.is_empty() { + return Err(CodeIntelligenceError::Unavailable { + message: "no supported saved source files were found in this workspace".to_owned(), + }); + } + + // Start each relevant profile independently so one missing executable + // cannot prevent diagnostics from another language. + let mut starts = FuturesUnordered::new(); + for (slot_index, slot) in relevant_slots { + let cancellation = cancellation.clone(); + starts + .push(async move { (slot_index, self.ensure_runtime(slot, &cancellation).await) }); + } + let mut runtimes = vec![None; self.slots.len()]; + let mut first_error = None; + let mut truncated = false; + while let Some((slot_index, result)) = starts.next().await { + match result { + Ok(runtime) => runtimes[slot_index] = Some(runtime), + Err(CodeIntelligenceError::Cancelled) => { + return Err(CodeIntelligenceError::Cancelled) + } + Err(error) => { + truncated = true; + first_error.get_or_insert(error); + } + } + } + + let mut diagnostic_slots = Vec::new(); + for (slot_index, runtime) in runtimes.iter().enumerate() { + let Some(runtime) = runtime else { + continue; + }; + if runtime.capabilities().diagnostics { + diagnostic_slots.push(slot_index); + } else { + truncated = true; + first_error.get_or_insert_with(|| { + map_language_error( + self.slots[slot_index].profile.id(), + LanguageRuntimeError::Unsupported { + operation: "diagnostics", + }, + ) + }); + } + } + if diagnostic_slots.is_empty() { + return Err( + first_error.unwrap_or_else(|| CodeIntelligenceError::Unavailable { + message: "no language runtime can provide workspace diagnostics".to_owned(), + }), + ); + } + + let (documents, selection_truncated) = select_workspace_diagnostic_paths( + &self.slots, + &diagnostic_slots, + &source_paths, + WORKSPACE_DIAGNOSTIC_DOCUMENT_LIMIT, + ); + truncated |= selection_truncated; + if documents.is_empty() { + return Err( + first_error.unwrap_or_else(|| CodeIntelligenceError::Unavailable { + message: "no supported saved source files could be queried".to_owned(), + }), + ); + } + + let mut diagnostic_documents = Vec::with_capacity(documents.len()); + for (slot_index, path) in documents { + let Some(runtime) = runtimes[slot_index].as_ref() else { + truncated = true; + first_error.get_or_insert_with(|| CodeIntelligenceError::Unavailable { + message: format!( + "the {} language runtime became unavailable before diagnostics", + profile_language(self.slots[slot_index].profile.id()) + ), + }); + continue; + }; + diagnostic_documents.push(( + self.slots[slot_index].profile.id(), + Arc::clone(runtime), + path, + )); + } + if diagnostic_documents.is_empty() { + return Err( + first_error.unwrap_or_else(|| CodeIntelligenceError::Unavailable { + message: "no language runtime remained available for diagnostics".to_owned(), + }), + ); + } + + let queries = diagnostic_documents + .into_iter() + .map(|(profile, runtime, path)| { + let cancellation = cancellation.clone(); + async move { + let content = self.read_saved(&path, &cancellation).await?; + runtime + .diagnostics(&path, &content, cancellation) + .await + .map_err(|error| map_language_error(profile, error)) + } + }); + let mut queries = + futures::stream::iter(queries).buffer_unordered(WORKSPACE_DIAGNOSTIC_CONCURRENCY); + let mut items = Vec::new(); + let mut succeeded = 0_usize; + while let Some(result) = queries.next().await { + match result { + Ok(result) => { + succeeded += 1; + truncated |= result.truncated; + truncated |= + append_bounded(&mut items, result.items, WORKSPACE_DIAGNOSTIC_LIMIT); + } + Err(CodeIntelligenceError::Cancelled) => { + return Err(CodeIntelligenceError::Cancelled) + } + Err(error) => { + truncated = true; + first_error.get_or_insert(error); + } + } + } + if succeeded == 0 { + return Err( + first_error.unwrap_or_else(|| CodeIntelligenceError::Unavailable { + message: "no saved source document returned diagnostics".to_owned(), + }), + ); + } + + items.sort_by(diagnostic_order); + Ok(self.workspace_result(items, truncated)) + } + + pub(crate) async fn notify_file_changes(&self, changes: &[WorkspaceFileChange]) { + let runtimes = self.ready_runtimes().await; + for runtime in runtimes { + if let Err(error) = runtime.notify_file_changes(changes).await { + tracing::warn!(error = %error, "Code Intelligence file-change notification failed"); + } + } + } + + pub(crate) async fn shutdown(&self) { + if self.shutting_down.swap(true, Ordering::AcqRel) { + return; + } + self.lifetime.cancel(); + let runtimes = self.ready_runtimes().await; + for runtime in runtimes { + if let Err(error) = runtime.shutdown().await { + tracing::warn!(error = %error, "Code Intelligence runtime shutdown failed"); + } + } + self.status.send_replace(CodeIntelligenceStatus { + state: CodeIntelligenceState::Unavailable, + message: Some("Code Intelligence runtime is shut down".to_owned()), + ..CodeIntelligenceStatus::default() + }); + } + + async fn runtime_for_path( + &self, + path: &WorkspacePath, + cancellation: &CancellationToken, + ) -> CodeIntelligenceResult<(ProjectLanguageProfile, Arc)> { + let slot = self + .slots + .iter() + .find(|slot| slot.profile.supports_path(Path::new(path.as_str()))) + .ok_or_else(|| CodeIntelligenceError::Unsupported { + operation: "language".to_owned(), + message: format!( + "no language profile supports saved document {}", + path.as_str() + ), + })?; + slot.relevant.store(true, Ordering::Release); + let runtime = self.ensure_runtime(slot, cancellation).await?; + Ok((slot.profile.id(), runtime)) + } + + async fn ensure_runtime( + &self, + slot: &LanguageSlot, + cancellation: &CancellationToken, + ) -> CodeIntelligenceResult> { + if self.shutting_down.load(Ordering::Acquire) { + return Err(CodeIntelligenceError::Unavailable { + message: "the workspace runtime is shutting down".to_owned(), + }); + } + let mut state = tokio::select! { + _ = cancellation.cancelled() => return Err(CodeIntelligenceError::Cancelled), + state = slot.state.lock() => state, + }; + let retiring = match &*state { + SlotState::Ready(runtime) => { + if let Some(message) = runtime.unavailable_message() { + Some((Arc::clone(runtime), message)) + } else { + return Ok(Arc::clone(runtime)); + } + } + SlotState::Failed(failure) if failure.at.elapsed() < START_RETRY_DELAY => { + return Err(CodeIntelligenceError::Unavailable { + message: failure.message.clone(), + }); + } + SlotState::Dormant | SlotState::Failed(_) => None, + }; + if let Some((runtime, message)) = retiring { + tracing::warn!( + language = %profile_language(slot.profile.id()), + message, + "Code Intelligence will restart an exited language runtime" + ); + // The client can observe protocol EOF before the process monitor + // has reaped a server that kept running. Keep the slot locked and + // finish the old generation before making it startable again. + if let Err(error) = runtime.shutdown().await { + tracing::warn!( + language = %profile_language(slot.profile.id()), + error = %error, + "Code Intelligence could not fully retire an exited language runtime" + ); + // Keep the failed generation in Ready. A later query may retry + // cleanup, but no replacement may start while the old process + // has not been confirmed reaped. + return Err(map_language_error(slot.profile.id(), error)); + } + *state = SlotState::Dormant; + } + if cancellation.is_cancelled() { + return Err(CodeIntelligenceError::Cancelled); + } + if self.shutting_down.load(Ordering::Acquire) { + return Err(CodeIntelligenceError::Unavailable { + message: "the workspace runtime is shutting down".to_owned(), + }); + } + + let result = LanguageRuntime::start( + slot.profile.clone(), + self.canonical_root.clone(), + self.layout.clone(), + Arc::clone(&slot.documents), + Arc::clone(&self.diagnostics), + cancellation.clone(), + self.timeout, + ) + .await; + if cancellation.is_cancelled() || self.shutting_down.load(Ordering::Acquire) { + if let Ok(runtime) = result { + if let Err(error) = runtime.shutdown().await { + tracing::warn!( + language = %profile_language(slot.profile.id()), + error = %error, + "Code Intelligence could not retire a cancelled language runtime start" + ); + } + } + *state = SlotState::Dormant; + return if cancellation.is_cancelled() { + Err(CodeIntelligenceError::Cancelled) + } else { + Err(CodeIntelligenceError::Unavailable { + message: "the workspace runtime is shutting down".to_owned(), + }) + }; + } + match result { + Ok(runtime) => { + let runtime = Arc::new(runtime); + *state = SlotState::Ready(Arc::clone(&runtime)); + drop(state); + self.spawn_runtime_monitor(slot, Arc::clone(&runtime)); + self.refresh_status().await; + Ok(runtime) + } + Err(error) => { + let message = error.to_string(); + let public = map_language_error(slot.profile.id(), error); + *state = SlotState::Failed(StartFailure { + at: Instant::now(), + message, + }); + drop(state); + self.refresh_status().await; + Err(public) + } + } + } + + fn spawn_runtime_monitor(&self, slot: &LanguageSlot, runtime: Arc) { + let state = Arc::clone(&slot.state); + let status = self.status.clone(); + let lifetime = self.lifetime.clone(); + let language = profile_language(slot.profile.id()); + let mut process_state = runtime.subscribe_process_state(); + tokio::spawn(async move { + loop { + if !matches!( + *process_state.borrow(), + super::lsp::process::LspProcessState::Running + ) { + break; + } + tokio::select! { + _ = lifetime.cancelled() => return, + changed = process_state.changed() => { + if changed.is_err() { + break; + } + } + } + } + if lifetime.is_cancelled() { + return; + } + let message = runtime + .unavailable_message() + .unwrap_or_else(|| "the language runtime stopped unexpectedly".to_owned()); + let mut slot_state = state.lock().await; + let is_current = matches!( + &*slot_state, + SlotState::Ready(current) if Arc::ptr_eq(current, &runtime) + ); + if !is_current { + return; + } + if let Err(error) = runtime.shutdown().await { + tracing::warn!( + language = %language, + error = %error, + "Code Intelligence could not clean up a stopped language runtime" + ); + // Leave the current generation installed. `ensure_runtime` + // will retry its cleanup and must not start a replacement + // until shutdown confirms the process has been reaped. + drop(slot_state); + publish_stopped_language_status( + &status, + language, + format!("{message}; cleanup failed: {error}"), + ); + return; + } + *slot_state = SlotState::Dormant; + drop(slot_state); + publish_stopped_language_status(&status, language, message); + }); + } + + async fn read_saved( + &self, + path: &WorkspacePath, + cancellation: &CancellationToken, + ) -> CodeIntelligenceResult { + let read = self.file_system.read_text(path); + tokio::select! { + _ = cancellation.cancelled() => Err(CodeIntelligenceError::Cancelled), + result = tokio::time::timeout(self.timeout, read) => match result { + Ok(Ok(content)) => Ok(content), + Ok(Err(error)) => Err(map_workspace_error(path, error)), + Err(_) => Err(CodeIntelligenceError::Timeout { + operation: "read_saved_document".to_owned(), + duration: self.timeout, + }), + } + } + } + + /// Confirm that the saved bytes which produced a semantic result are + /// still current. File watchers are intentionally not part of this check: + /// their delivery may lag behind a query that overlaps an external save. + async fn complete_saved_query( + &self, + path: &WorkspacePath, + cancellation: &CancellationToken, + result: &mut CodeQueryResult, + ) -> CodeIntelligenceResult<()> { + let Some(snapshot) = result.document.as_mut() else { + return Ok(()); + }; + match self.read_saved(path, cancellation).await { + Ok(content) => { + snapshot.stale |= sha256::digest(content.as_bytes()) != snapshot.content_hash; + Ok(()) + } + Err(CodeIntelligenceError::Cancelled) => Err(CodeIntelligenceError::Cancelled), + Err(_) => { + // A result based on a file that disappeared or became + // unreadable during the request is still useful, but cannot + // be presented as current. + snapshot.stale = true; + Ok(()) + } + } + } + + async fn ready_runtimes(&self) -> Vec> { + let mut runtimes = Vec::new(); + for slot in &self.slots { + if let SlotState::Ready(runtime) = &*slot.state.lock().await { + runtimes.push(Arc::clone(runtime)); + } + } + runtimes + } + + async fn refresh_status(&self) { + let mut languages = Vec::new(); + let mut capabilities = CodeIntelligenceCapabilities::default(); + let mut ready = 0_usize; + let mut failed = 0_usize; + let mut dormant = 0_usize; + for slot in &self.slots { + if !slot.relevant.load(Ordering::Acquire) { + continue; + } + let state = slot.state.lock().await; + let (runtime_state, runtime_capabilities, message) = match &*state { + SlotState::Dormant => { + dormant += 1; + ( + CodeIntelligenceState::Starting, + CodeIntelligenceCapabilities::default(), + Some("starts on first semantic query".to_owned()), + ) + } + SlotState::Ready(runtime) => { + ready += 1; + let current = runtime.capabilities(); + union_capabilities(&mut capabilities, current); + (CodeIntelligenceState::Ready, current, None) + } + SlotState::Failed(failure) => { + failed += 1; + ( + CodeIntelligenceState::Unavailable, + CodeIntelligenceCapabilities::default(), + Some(failure.message.clone()), + ) + } + }; + languages.push(CodeIntelligenceLanguageStatus { + language: profile_language(slot.profile.id()), + state: runtime_state, + capabilities: runtime_capabilities, + message, + }); + } + let state = if ready > 0 && failed > 0 { + CodeIntelligenceState::Degraded + } else if ready > 0 { + CodeIntelligenceState::Ready + } else if failed > 0 && dormant == 0 { + CodeIntelligenceState::Unavailable + } else if dormant > 0 { + CodeIntelligenceState::Starting + } else { + CodeIntelligenceState::Unavailable + }; + self.status.send_replace(CodeIntelligenceStatus { + state, + capabilities, + languages, + message: (failed > 0) + .then(|| "one or more language runtimes are unavailable".to_owned()), + }); + } + + fn workspace_revision(&self) -> u64 { + self.workspace_revision.load(Ordering::Acquire) + } + + fn workspace_result(&self, items: Vec, truncated: bool) -> CodeQueryResult { + CodeQueryResult { + items, + truncated, + workspace_revision: self.workspace_revision(), + document: None, + } + } +} diff --git a/core/src/code_intelligence/workspace_runtime/integration_tests.rs b/core/src/code_intelligence/workspace_runtime/integration_tests.rs new file mode 100644 index 00000000..6942a17b --- /dev/null +++ b/core/src/code_intelligence/workspace_runtime/integration_tests.rs @@ -0,0 +1,474 @@ +use std::{ + path::{Path, PathBuf}, + process::Command, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::Duration, +}; + +use async_trait::async_trait; +use tokio_util::sync::CancellationToken; + +use super::WorkspaceRuntime; +use crate::{ + code_intelligence::{ + language_profile::LanguageServerProfile, + project_layout::{ProjectLanguageProfile, ProjectLayoutResolver}, + CodeIntelligenceState, + }, + workspace::{ + LocalWorkspaceBackend, LocalWorkspaceFile, LocalWorkspaceFileStatus, + LocalWorkspaceManifestSnapshot, WorkspaceDirEntry, WorkspaceFileSystem, WorkspacePath, + WorkspaceResult, WorkspaceWriteOutcome, + }, +}; + +struct ChangeOnSecondReadFileSystem { + inner: LocalWorkspaceBackend, + root: PathBuf, + target: WorkspacePath, + replacement: String, + target_reads: AtomicUsize, +} + +impl ChangeOnSecondReadFileSystem { + fn new(root: PathBuf, target: WorkspacePath, replacement: impl Into) -> Self { + Self { + inner: LocalWorkspaceBackend::new(root.clone()), + root, + target, + replacement: replacement.into(), + target_reads: AtomicUsize::new(0), + } + } +} + +#[async_trait] +impl WorkspaceFileSystem for ChangeOnSecondReadFileSystem { + async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult { + if path == &self.target && self.target_reads.fetch_add(1, Ordering::AcqRel) == 1 { + tokio::fs::write(self.root.join(path.as_str()), &self.replacement) + .await + .unwrap(); + } + self.inner.read_text(path).await + } + + async fn write_text( + &self, + path: &WorkspacePath, + content: &str, + ) -> WorkspaceResult { + self.inner.write_text(path, content).await + } + + async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult> { + self.inner.list_dir(path).await + } +} + +fn manifest_file(path: &str) -> LocalWorkspaceFile { + LocalWorkspaceFile { + path: path.to_owned(), + size: 1, + modified_ms: Some(1), + language: None, + status: LocalWorkspaceFileStatus::Tracked, + binary: false, + generated: false, + } +} + +fn snapshot(root: &Path, paths: &[&str]) -> LocalWorkspaceManifestSnapshot { + LocalWorkspaceManifestSnapshot { + version: 7, + root: root.to_path_buf(), + files: paths.iter().map(|path| manifest_file(path)).collect(), + scanned_at_ms: 1, + } +} + +fn write_workspace_files(root: &Path, files: &[(&str, &str)]) { + for (path, content) in files { + let path = root.join(path); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, content).unwrap(); + } +} + +fn compile_fake_server(output: &Path) { + let source = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/code_intelligence_fake_lsp.rs"); + let result = Command::new("rustc") + .arg("--edition=2021") + .arg(source) + .arg("-o") + .arg(output) + .output() + .expect("rustc must be available while Cargo tests are running"); + assert!( + result.status.success(), + "failed to compile fake language server: {}", + String::from_utf8_lossy(&result.stderr) + ); +} + +fn test_runtime( + root: &Path, + snapshot: &LocalWorkspaceManifestSnapshot, + profiles: Vec, +) -> WorkspaceRuntime { + let file_system: Arc = + Arc::new(LocalWorkspaceBackend::new(root.to_path_buf())); + WorkspaceRuntime::new_with_profiles( + root.to_path_buf(), + ProjectLayoutResolver::resolve(snapshot), + snapshot, + file_system, + Duration::from_secs(5), + profiles, + ) +} + +fn test_runtime_with_file_system( + root: &Path, + snapshot: &LocalWorkspaceManifestSnapshot, + profiles: Vec, + file_system: Arc, + document_capacity: usize, +) -> WorkspaceRuntime { + WorkspaceRuntime::new_with_profiles_and_document_capacity( + root.to_path_buf(), + ProjectLayoutResolver::resolve(snapshot), + snapshot, + file_system, + Duration::from_secs(5), + profiles, + document_capacity, + ) +} + +#[tokio::test] +async fn saved_file_change_during_query_marks_the_document_result_stale() { + let workspace = tempfile::tempdir().unwrap(); + let original = "pub fn answer() -> u32 { 42 }\n"; + let replacement = "pub fn answer() -> u32 { 43 }\n"; + write_workspace_files( + workspace.path(), + &[ + ("Cargo.toml", "[package]\nname='fixture'\n"), + ("src/lib.rs", original), + ], + ); + let root = std::fs::canonicalize(workspace.path()).unwrap(); + let snapshot = snapshot(&root, &["Cargo.toml", "src/lib.rs"]); + let server_dir = tempfile::tempdir().unwrap(); + let server = server_dir.path().join(if cfg!(windows) { + "stale-query-fake-lsp.exe" + } else { + "stale-query-fake-lsp" + }); + compile_fake_server(&server); + let path = WorkspacePath::from_normalized("src/lib.rs"); + let file_system: Arc = Arc::new(ChangeOnSecondReadFileSystem::new( + root.clone(), + path.clone(), + replacement, + )); + let runtime = test_runtime_with_file_system( + &root, + &snapshot, + vec![LanguageServerProfile::rust(&server)], + file_system, + 8, + ); + + let result = runtime + .document_symbols(&path, CancellationToken::new()) + .await + .unwrap(); + runtime.shutdown().await; + + let document = result.document.unwrap(); + assert!(document.stale); + assert_eq!(document.content_hash, sha256::digest(original.as_bytes())); + assert_eq!( + std::fs::read_to_string(root.join(path.as_str())).unwrap(), + replacement + ); +} + +#[tokio::test] +async fn per_language_document_capacity_evicts_only_from_the_owning_runtime() { + let workspace = tempfile::tempdir().unwrap(); + write_workspace_files( + workspace.path(), + &[ + ("Cargo.toml", "[package]\nname='fixture'\n"), + ("package.json", "{}\n"), + ("src/first.rs", "pub fn first() {}\n"), + ("src/second.rs", "pub fn second() {}\n"), + ("web/main.ts", "export function main() {}\n"), + ], + ); + let root = std::fs::canonicalize(workspace.path()).unwrap(); + let snapshot = snapshot( + &root, + &[ + "Cargo.toml", + "package.json", + "src/first.rs", + "src/second.rs", + "web/main.ts", + ], + ); + let server_dir = tempfile::tempdir().unwrap(); + let rust_server = server_dir.path().join(if cfg!(windows) { + "rust-owner-fake-lsp.exe" + } else { + "rust-owner-fake-lsp" + }); + let typescript_server = server_dir.path().join(if cfg!(windows) { + "typescript-owner-fake-lsp.exe" + } else { + "typescript-owner-fake-lsp" + }); + compile_fake_server(&rust_server); + compile_fake_server(&typescript_server); + let file_system: Arc = + Arc::new(LocalWorkspaceBackend::new(root.clone())); + let runtime = test_runtime_with_file_system( + &root, + &snapshot, + vec![ + LanguageServerProfile::rust(&rust_server), + LanguageServerProfile::typescript_javascript(&typescript_server), + ], + file_system, + 1, + ); + let first_rust = WorkspacePath::from_normalized("src/first.rs"); + let second_rust = WorkspacePath::from_normalized("src/second.rs"); + let typescript = WorkspacePath::from_normalized("web/main.ts"); + + runtime + .document_symbols(&first_rust, CancellationToken::new()) + .await + .unwrap(); + runtime + .document_symbols(&typescript, CancellationToken::new()) + .await + .unwrap(); + runtime + .document_symbols(&second_rust, CancellationToken::new()) + .await + .unwrap(); + + let rust_slot = runtime + .slots + .iter() + .find(|slot| slot.profile.id() == ProjectLanguageProfile::Rust) + .unwrap(); + let typescript_slot = runtime + .slots + .iter() + .find(|slot| slot.profile.id() == ProjectLanguageProfile::TypeScriptJavaScript) + .unwrap(); + assert_eq!(rust_slot.documents.len().await, 1); + assert!(rust_slot.documents.snapshot(&first_rust).await.is_none()); + assert!(rust_slot.documents.snapshot(&second_rust).await.is_some()); + assert_eq!(typescript_slot.documents.len().await, 1); + assert!(typescript_slot + .documents + .snapshot(&typescript) + .await + .is_some()); + + let rust_log = std::fs::read_to_string(rust_server.with_extension("log")).unwrap(); + let typescript_log = std::fs::read_to_string(typescript_server.with_extension("log")).unwrap(); + assert_eq!( + rust_log + .matches("\"method\":\"textDocument/didClose\"") + .count(), + 1, + "the Rust owner must close its own evicted document: {rust_log}" + ); + assert!( + !typescript_log.contains("\"method\":\"textDocument/didClose\""), + "Rust eviction must not close the TypeScript document: {typescript_log}" + ); + + runtime.shutdown().await; +} + +#[tokio::test] +async fn fresh_workspace_diagnostics_starts_the_manifest_language_runtime() { + let workspace = tempfile::tempdir().unwrap(); + write_workspace_files( + workspace.path(), + &[ + ("Cargo.toml", "[package]\nname='fixture'\n"), + ("src/lib.rs", "pub fn answer() -> u32 { 42 }\n"), + ], + ); + let root = std::fs::canonicalize(workspace.path()).unwrap(); + let snapshot = snapshot(&root, &["Cargo.toml", "src/lib.rs"]); + let server_dir = tempfile::tempdir().unwrap(); + let server = server_dir.path().join(if cfg!(windows) { + "workspace-diagnostics-fake-lsp.exe" + } else { + "workspace-diagnostics-fake-lsp" + }); + compile_fake_server(&server); + let runtime = test_runtime(&root, &snapshot, vec![LanguageServerProfile::rust(&server)]); + let status = runtime.subscribe_status(); + + let query = runtime.diagnostics(None, CancellationToken::new()).await; + let current_status = status.borrow().clone(); + runtime.shutdown().await; + + let result = query.unwrap(); + assert_eq!(result.workspace_revision, 7); + assert!(result.document.is_none()); + assert!(!result.truncated); + assert_eq!(result.items.len(), 1); + assert_eq!(result.items[0].location.path.as_str(), "src/lib.rs"); + assert_eq!(result.items[0].message, "fixture warning"); + assert_eq!(current_status.state, CodeIntelligenceState::Ready); + assert_eq!(current_status.languages.len(), 1); + assert_eq!( + current_status.languages[0].state, + CodeIntelligenceState::Ready + ); + + let protocol_log = std::fs::read_to_string(server.with_extension("log")).unwrap(); + for method in [ + "initialize", + "textDocument/didOpen", + "textDocument/diagnostic", + ] { + assert!( + protocol_log.contains(&format!("\"method\":\"{method}\"")), + "protocol log did not contain {method}: {protocol_log}" + ); + } +} + +#[tokio::test] +async fn mixed_workspace_diagnostics_aggregates_each_supported_language() { + let workspace = tempfile::tempdir().unwrap(); + write_workspace_files( + workspace.path(), + &[ + ("Cargo.toml", "[package]\nname='fixture'\n"), + ("package.json", "{}\n"), + ("src/lib.rs", "pub fn answer() -> u32 { 42 }\n"), + ("web/main.ts", "export function answer() { return 42; }\n"), + ], + ); + let root = std::fs::canonicalize(workspace.path()).unwrap(); + let snapshot = snapshot( + &root, + &["Cargo.toml", "package.json", "src/lib.rs", "web/main.ts"], + ); + let server_dir = tempfile::tempdir().unwrap(); + let server = server_dir.path().join(if cfg!(windows) { + "mixed-workspace-fake-lsp.exe" + } else { + "mixed-workspace-fake-lsp" + }); + compile_fake_server(&server); + let runtime = test_runtime( + &root, + &snapshot, + vec![ + LanguageServerProfile::rust(&server), + LanguageServerProfile::typescript_javascript(&server), + ], + ); + let status = runtime.subscribe_status(); + + let query = runtime.diagnostics(None, CancellationToken::new()).await; + let current_status = status.borrow().clone(); + runtime.shutdown().await; + + let result = query.unwrap(); + assert!(!result.truncated); + assert_eq!( + result + .items + .iter() + .map(|diagnostic| diagnostic.location.path.as_str()) + .collect::>(), + ["src/lib.rs", "web/main.ts"] + ); + assert_eq!(current_status.state, CodeIntelligenceState::Ready); + assert_eq!(current_status.languages.len(), 2); + assert!(current_status + .languages + .iter() + .all(|language| language.state == CodeIntelligenceState::Ready)); +} + +#[tokio::test] +async fn missing_mixed_language_runtime_returns_available_diagnostics_and_degraded_status() { + let workspace = tempfile::tempdir().unwrap(); + write_workspace_files( + workspace.path(), + &[ + ("Cargo.toml", "[package]\nname='fixture'\n"), + ("package.json", "{}\n"), + ("src/lib.rs", "pub fn answer() -> u32 { 42 }\n"), + ("web/main.ts", "export function answer() { return 42; }\n"), + ], + ); + let root = std::fs::canonicalize(workspace.path()).unwrap(); + let snapshot = snapshot( + &root, + &["Cargo.toml", "package.json", "src/lib.rs", "web/main.ts"], + ); + let server_dir = tempfile::tempdir().unwrap(); + let server = server_dir.path().join(if cfg!(windows) { + "degraded-workspace-fake-lsp.exe" + } else { + "degraded-workspace-fake-lsp" + }); + compile_fake_server(&server); + let missing = server_dir.path().join("missing-typescript-language-server"); + let runtime = test_runtime( + &root, + &snapshot, + vec![ + LanguageServerProfile::rust(&server), + LanguageServerProfile::typescript_javascript(missing), + ], + ); + let status = runtime.subscribe_status(); + + let query = runtime.diagnostics(None, CancellationToken::new()).await; + let current_status = status.borrow().clone(); + runtime.shutdown().await; + + let result = query.unwrap(); + assert!(result.truncated); + assert_eq!(result.items.len(), 1); + assert_eq!(result.items[0].location.path.as_str(), "src/lib.rs"); + assert_eq!(current_status.state, CodeIntelligenceState::Degraded); + let rust = current_status + .languages + .iter() + .find(|language| language.language.as_str() == "rust") + .unwrap(); + let typescript = current_status + .languages + .iter() + .find(|language| language.language.as_str() == "typescript-javascript") + .unwrap(); + assert_eq!(rust.state, CodeIntelligenceState::Ready); + assert_eq!(typescript.state, CodeIntelligenceState::Unavailable); + assert!(typescript.message.is_some()); +} diff --git a/core/src/code_intelligence/workspace_runtime/support.rs b/core/src/code_intelligence/workspace_runtime/support.rs new file mode 100644 index 00000000..cda4271b --- /dev/null +++ b/core/src/code_intelligence/workspace_runtime/support.rs @@ -0,0 +1,301 @@ +use std::path::Path; + +use tokio::sync::watch; + +use super::LanguageSlot; +use crate::code_intelligence::{ + language_runtime::LanguageRuntimeError, lsp::client::LspClientError, + project_layout::ProjectLanguageProfile, CodeDiagnostic, CodeIntelligenceCapabilities, + CodeIntelligenceError, CodeIntelligenceLanguageStatus, CodeIntelligenceState, + CodeIntelligenceStatus, LanguageId, SymbolInformation, +}; +use crate::workspace::{LocalWorkspaceManifestSnapshot, WorkspaceError, WorkspacePath}; + +pub(super) fn supported_source_paths( + snapshot: &LocalWorkspaceManifestSnapshot, + supports_path: impl Fn(&Path) -> bool, +) -> Vec { + let mut paths = snapshot + .files + .iter() + .filter(|file| !file.binary && !file.generated) + .filter(|file| supports_path(Path::new(&file.path))) + .map(|file| WorkspacePath::from_normalized(file.path.clone())) + .collect::>(); + paths.sort_by(|left, right| left.as_str().cmp(right.as_str())); + paths.dedup_by(|left, right| left.as_str() == right.as_str()); + paths +} + +/// Select documents round-robin by language so a large profile cannot consume +/// the entire workspace query budget before another profile is represented. +pub(super) fn select_workspace_diagnostic_paths( + slots: &[LanguageSlot], + slot_indexes: &[usize], + source_paths: &[WorkspacePath], + limit: usize, +) -> (Vec<(usize, WorkspacePath)>, bool) { + let mut buckets = slot_indexes + .iter() + .map(|slot_index| (*slot_index, Vec::new())) + .collect::>(); + for path in source_paths { + let Some((_, bucket)) = buckets.iter_mut().find(|(slot_index, _)| { + slots[*slot_index] + .profile + .supports_path(Path::new(path.as_str())) + }) else { + continue; + }; + bucket.push(path.clone()); + } + + let candidate_count = buckets.iter().map(|(_, paths)| paths.len()).sum::(); + let mut offsets = vec![0_usize; buckets.len()]; + let mut selected = Vec::with_capacity(limit.min(candidate_count)); + 'selection: loop { + let mut progressed = false; + for (bucket_index, (slot_index, paths)) in buckets.iter().enumerate() { + let Some(path) = paths.get(offsets[bucket_index]) else { + continue; + }; + if selected.len() == limit { + break 'selection; + } + selected.push((*slot_index, path.clone())); + offsets[bucket_index] += 1; + progressed = true; + } + if !progressed { + break; + } + } + let truncated = selected.len() < candidate_count; + (selected, truncated) +} + +pub(super) fn append_bounded(target: &mut Vec, incoming: Vec, limit: usize) -> bool { + let original_len = target.len(); + target.truncate(limit); + let remaining = limit.saturating_sub(target.len()); + let truncated = original_len > limit || incoming.len() > remaining; + target.extend(incoming.into_iter().take(remaining)); + truncated +} + +pub(super) fn diagnostic_order( + left: &CodeDiagnostic, + right: &CodeDiagnostic, +) -> std::cmp::Ordering { + left.location + .path + .as_str() + .cmp(right.location.path.as_str()) + .then_with(|| { + left.location + .range + .start + .line + .cmp(&right.location.range.start.line) + }) + .then_with(|| { + left.location + .range + .start + .character + .cmp(&right.location.range.start.character) + }) + .then_with(|| { + left.location + .range + .end + .line + .cmp(&right.location.range.end.line) + }) + .then_with(|| { + left.location + .range + .end + .character + .cmp(&right.location.range.end.character) + }) + .then_with(|| left.message.cmp(&right.message)) + .then_with(|| left.severity.cmp(&right.severity)) + .then_with(|| left.code.cmp(&right.code)) + .then_with(|| left.source.cmp(&right.source)) +} + +pub(super) fn map_workspace_error( + path: &WorkspacePath, + error: WorkspaceError, +) -> CodeIntelligenceError { + match error { + WorkspaceError::NotFound { .. } | WorkspaceError::InvalidArgument { .. } => { + CodeIntelligenceError::InvalidPath { + path: path.clone(), + message: error.to_string(), + } + } + WorkspaceError::Timeout { op, duration } => CodeIntelligenceError::Timeout { + operation: op, + duration, + }, + other => CodeIntelligenceError::Unavailable { + message: format!("failed to read saved document {}: {other}", path.as_str()), + }, + } +} + +pub(super) fn map_language_error( + profile: ProjectLanguageProfile, + error: LanguageRuntimeError, +) -> CodeIntelligenceError { + match error { + LanguageRuntimeError::InvalidPath { path, message } => { + CodeIntelligenceError::InvalidPath { path, message } + } + LanguageRuntimeError::UnsupportedPath { path } => CodeIntelligenceError::Unsupported { + operation: "language".to_owned(), + message: format!("saved document {} is not supported", path.as_str()), + }, + LanguageRuntimeError::Unsupported { operation } => CodeIntelligenceError::Unsupported { + operation: operation.to_owned(), + message: "the active language runtime did not advertise this capability".to_owned(), + }, + LanguageRuntimeError::InvalidPosition { path, position } => { + CodeIntelligenceError::InvalidPosition { path, position } + } + LanguageRuntimeError::PendingDiagnostics { path } => CodeIntelligenceError::Unavailable { + message: format!( + "diagnostics for {} have not been received yet", + path.as_str() + ), + }, + LanguageRuntimeError::Cancelled => CodeIntelligenceError::Cancelled, + LanguageRuntimeError::Timeout { + operation, + duration, + } => CodeIntelligenceError::Timeout { + operation: operation.to_owned(), + duration, + }, + LanguageRuntimeError::Client { + source: LspClientError::Cancelled, + .. + } => CodeIntelligenceError::Cancelled, + LanguageRuntimeError::Client { + source: LspClientError::Timeout { method, duration }, + .. + } => CodeIntelligenceError::Timeout { + operation: method, + duration, + }, + LanguageRuntimeError::Client { + source: LspClientError::Closed { message }, + .. + } + | LanguageRuntimeError::Client { + source: LspClientError::Transport { message }, + .. + } => CodeIntelligenceError::ProcessExited { + language: profile_language(profile), + message, + }, + LanguageRuntimeError::InvalidRoot { root, message } => CodeIntelligenceError::Unavailable { + message: format!("invalid workspace root {root:?}: {message}"), + }, + LanguageRuntimeError::Process { operation, source } => CodeIntelligenceError::Unavailable { + message: format!("language runtime could not {operation}: {source}"), + }, + other => CodeIntelligenceError::Protocol { + message: other.to_string(), + }, + } +} + +pub(super) fn profile_language(profile: ProjectLanguageProfile) -> LanguageId { + match profile { + ProjectLanguageProfile::Rust => LanguageId::from("rust"), + ProjectLanguageProfile::TypeScriptJavaScript => LanguageId::from("typescript-javascript"), + } +} + +pub(super) fn union_capabilities( + target: &mut CodeIntelligenceCapabilities, + source: CodeIntelligenceCapabilities, +) { + target.document_symbols |= source.document_symbols; + target.workspace_symbols |= source.workspace_symbols; + target.definition |= source.definition; + target.declaration |= source.declaration; + target.references |= source.references; + target.implementations |= source.implementations; + target.diagnostics |= source.diagnostics; +} + +pub(super) fn publish_stopped_language_status( + sender: &watch::Sender, + language: LanguageId, + message: String, +) { + let mut status = sender.borrow().clone(); + if let Some(current) = status + .languages + .iter_mut() + .find(|current| current.language == language) + { + current.state = CodeIntelligenceState::Unavailable; + current.capabilities = CodeIntelligenceCapabilities::default(); + current.message = Some(message); + } else { + status.languages.push(CodeIntelligenceLanguageStatus { + language, + state: CodeIntelligenceState::Unavailable, + capabilities: CodeIntelligenceCapabilities::default(), + message: Some(message), + }); + } + + let mut capabilities = CodeIntelligenceCapabilities::default(); + let mut ready = 0_usize; + let mut unavailable = 0_usize; + let mut starting = 0_usize; + for current in &status.languages { + match current.state { + CodeIntelligenceState::Ready => { + ready += 1; + union_capabilities(&mut capabilities, current.capabilities); + } + CodeIntelligenceState::Degraded => { + ready += 1; + unavailable += 1; + union_capabilities(&mut capabilities, current.capabilities); + } + CodeIntelligenceState::Starting => starting += 1, + CodeIntelligenceState::Unavailable => unavailable += 1, + } + } + status.state = if ready > 0 && unavailable > 0 { + CodeIntelligenceState::Degraded + } else if ready > 0 { + CodeIntelligenceState::Ready + } else if unavailable > 0 && starting == 0 { + CodeIntelligenceState::Unavailable + } else if starting > 0 { + CodeIntelligenceState::Starting + } else { + CodeIntelligenceState::Unavailable + }; + status.capabilities = capabilities; + status.message = Some("one or more language runtimes stopped unexpectedly".to_owned()); + sender.send_replace(status); +} + +pub(super) fn symbol_key(symbol: &SymbolInformation) -> (String, u32, u32, String) { + ( + symbol.location.path.as_str().to_owned(), + symbol.location.range.start.line, + symbol.location.range.start.character, + symbol.name.clone(), + ) +} diff --git a/core/src/code_intelligence/workspace_runtime/tests.rs b/core/src/code_intelligence/workspace_runtime/tests.rs new file mode 100644 index 00000000..0488b0b7 --- /dev/null +++ b/core/src/code_intelligence/workspace_runtime/tests.rs @@ -0,0 +1,167 @@ +use super::*; +use crate::code_intelligence::{project_layout::ProjectLayoutResolver, CodeRange, LanguageId}; +use crate::workspace::{LocalWorkspaceBackend, LocalWorkspaceFile, LocalWorkspaceFileStatus}; + +fn snapshot(root: &Path, version: u64, paths: &[&str]) -> LocalWorkspaceManifestSnapshot { + LocalWorkspaceManifestSnapshot { + version, + root: root.to_path_buf(), + files: paths + .iter() + .map(|path| LocalWorkspaceFile { + path: (*path).to_owned(), + size: 1, + modified_ms: Some(1), + language: None, + status: LocalWorkspaceFileStatus::Tracked, + binary: false, + generated: false, + }) + .collect(), + scanned_at_ms: 1, + } +} + +#[test] +fn stopped_runtime_status_is_retained_without_live_subscribers() { + let rust_capabilities = CodeIntelligenceCapabilities { + definition: true, + ..CodeIntelligenceCapabilities::default() + }; + let web_capabilities = CodeIntelligenceCapabilities { + references: true, + ..CodeIntelligenceCapabilities::default() + }; + let (status, receiver) = watch::channel(CodeIntelligenceStatus { + state: CodeIntelligenceState::Ready, + capabilities: CodeIntelligenceCapabilities { + definition: true, + references: true, + ..CodeIntelligenceCapabilities::default() + }, + languages: vec![ + CodeIntelligenceLanguageStatus { + language: LanguageId::from("rust"), + state: CodeIntelligenceState::Ready, + capabilities: rust_capabilities, + message: None, + }, + CodeIntelligenceLanguageStatus { + language: LanguageId::from("typescript-javascript"), + state: CodeIntelligenceState::Ready, + capabilities: web_capabilities, + message: None, + }, + ], + message: None, + }); + drop(receiver); + + publish_stopped_language_status( + &status, + LanguageId::from("rust"), + "process exited".to_owned(), + ); + + let current = status.borrow(); + assert_eq!(current.state, CodeIntelligenceState::Degraded); + assert!(!current.capabilities.definition); + assert!(current.capabilities.references); + assert_eq!( + current.languages[0].state, + CodeIntelligenceState::Unavailable + ); + assert_eq!( + current.languages[0].message.as_deref(), + Some("process exited") + ); +} + +#[test] +fn workspace_diagnostic_aggregation_never_exceeds_the_hard_limit() { + let diagnostic = |index| CodeDiagnostic { + location: CodeLocation { + path: WorkspacePath::from_normalized(format!("src/{index}.rs")), + range: CodeRange::new(CodePosition::new(0, 0), CodePosition::new(0, 1)), + }, + severity: None, + code: None, + source: Some("test".to_owned()), + message: format!("diagnostic-{index}"), + }; + let mut items = (0..WORKSPACE_DIAGNOSTIC_LIMIT - 1) + .map(diagnostic) + .collect::>(); + + let truncated = append_bounded( + &mut items, + vec![ + diagnostic(WORKSPACE_DIAGNOSTIC_LIMIT), + diagnostic(WORKSPACE_DIAGNOSTIC_LIMIT + 1), + ], + WORKSPACE_DIAGNOSTIC_LIMIT, + ); + + assert!(truncated); + assert_eq!(items.len(), WORKSPACE_DIAGNOSTIC_LIMIT); + assert_eq!( + items.last().unwrap().message, + format!("diagnostic-{WORKSPACE_DIAGNOSTIC_LIMIT}") + ); +} + +#[test] +fn workspace_diagnostic_selection_round_robins_languages() { + let slots = vec![ + LanguageSlot::new(LanguageServerProfile::rust("rust"), true, DOCUMENT_CAPACITY), + LanguageSlot::new( + LanguageServerProfile::typescript_javascript("typescript"), + true, + DOCUMENT_CAPACITY, + ), + ]; + let paths = vec![ + WorkspacePath::from_normalized("src/a.rs"), + WorkspacePath::from_normalized("src/b.rs"), + WorkspacePath::from_normalized("web/main.ts"), + ]; + + let (selected, truncated) = select_workspace_diagnostic_paths(&slots, &[0, 1], &paths, 2); + + assert!(truncated); + assert_eq!(selected[0], (0, WorkspacePath::from_normalized("src/a.rs"))); + assert_eq!( + selected[1], + (1, WorkspacePath::from_normalized("web/main.ts")) + ); +} + +#[tokio::test] +async fn removing_the_last_supported_source_resets_failed_runtime_state() { + let workspace = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(workspace.path()).unwrap(); + let initial = snapshot(&root, 1, &["src/lib.rs"]); + let file_system: Arc = + Arc::new(LocalWorkspaceBackend::new(root.clone())); + let runtime = WorkspaceRuntime::new( + root.clone(), + ProjectLayoutResolver::resolve(&initial), + &initial, + file_system, + Duration::from_secs(1), + ); + let rust = runtime + .slots + .iter() + .find(|slot| slot.profile.id() == ProjectLanguageProfile::Rust) + .unwrap(); + *rust.state.lock().await = SlotState::Failed(StartFailure { + at: Instant::now(), + message: "failed".to_owned(), + }); + + runtime.update_snapshot(&snapshot(&root, 2, &[])).await; + + assert!(!rust.relevant.load(Ordering::Acquire)); + assert!(matches!(*rust.state.lock().await, SlotState::Dormant)); +} diff --git a/core/src/event_protocol.rs b/core/src/event_protocol.rs index 28012741..67638cba 100644 --- a/core/src/event_protocol.rs +++ b/core/src/event_protocol.rs @@ -1,4 +1,4 @@ -//! Versioned wire protocol for [`AgentEvent`](crate::AgentEvent). +//! Versioned wire protocol for [`AgentEvent`]. //! //! `AgentEvent` is the runtime enum. [`EventEnvelopeV1`] is its stable, //! language-neutral representation. SDKs consume the envelope instead of diff --git a/core/src/hitl/tests.rs b/core/src/hitl/tests.rs index 7cc2cca5..aedd15b6 100644 --- a/core/src/hitl/tests.rs +++ b/core/src/hitl/tests.rs @@ -8,6 +8,10 @@ use super::*; fn test_session_lane() { assert_eq!(SessionLane::from_tool_name("read"), SessionLane::Query); assert_eq!(SessionLane::from_tool_name("grep"), SessionLane::Query); + assert_eq!( + SessionLane::from_tool_name("code_navigation"), + SessionLane::Query + ); assert_eq!(SessionLane::from_tool_name("bash"), SessionLane::Execute); assert_eq!(SessionLane::from_tool_name("write"), SessionLane::Execute); } @@ -27,7 +31,17 @@ fn test_session_lane_priority() { #[test] fn test_session_lane_all_query() { - let query_tools = ["read", "glob", "ls", "grep", "list_files", "search"]; + let query_tools = [ + "read", + "glob", + "ls", + "grep", + "list_files", + "search", + "code_symbols", + "code_navigation", + "code_diagnostics", + ]; for tool in query_tools { assert_eq!( SessionLane::from_tool_name(tool), diff --git a/core/src/host_env.rs b/core/src/host_env.rs index 446b549f..4da5c9fc 100644 --- a/core/src/host_env.rs +++ b/core/src/host_env.rs @@ -13,7 +13,7 @@ //! //! Hosts plug a custom impl via //! [`SessionOptions::with_host_env`](crate::agent_api::SessionOptions::with_host_env); -//! the framework uses [`SystemHostEnv`] (the wall-clock + random-UUID +//! the framework uses [`HostEnv::system`] (the wall-clock + random-UUID //! default) when none is supplied — observably identical to pre-P2 //! behaviour. @@ -43,7 +43,7 @@ pub trait Clock: Send + Sync + std::fmt::Debug { } /// Bundle of host-environment capabilities. Used as the single -/// `Option>` slot on [`AgentConfig`](crate::agent::AgentConfig) +/// `Option>` slot on `AgentConfig` /// and [`SessionOptions`](crate::agent_api::SessionOptions) — avoids /// growing two parallel `Arc` fields. #[derive(Debug, Clone)] diff --git a/core/src/language.rs b/core/src/language.rs new file mode 100644 index 00000000..c2b7e1f6 --- /dev/null +++ b/core/src/language.rs @@ -0,0 +1,112 @@ +//! Shared programming-language identification. + +use std::path::Path; + +/// Stateless catalog for the language identifiers used across the crate. +pub(crate) struct LanguageCatalog; + +impl LanguageCatalog { + /// Return the language identifier associated with a path extension. + /// + /// Extension matching is intentionally case-sensitive to preserve the + /// workspace manifest's existing classification behavior. + pub(crate) fn id_for_path(path: &Path) -> Option<&'static str> { + match path.extension().and_then(|ext| ext.to_str())? { + "rs" => Some("rust"), + "toml" => Some("toml"), + "hcl" => Some("hcl"), + "js" | "mjs" | "cjs" => Some("javascript"), + "jsx" => Some("javascript-react"), + "ts" | "mts" | "cts" => Some("typescript"), + "tsx" => Some("typescript-react"), + "json" => Some("json"), + "md" | "mdx" => Some("markdown"), + "py" => Some("python"), + "go" => Some("go"), + "java" => Some("java"), + "kt" | "kts" => Some("kotlin"), + "swift" => Some("swift"), + "c" | "h" => Some("c"), + "cc" | "cpp" | "cxx" | "hpp" => Some("cpp"), + "cs" => Some("csharp"), + "rb" => Some("ruby"), + "php" => Some("php"), + "sh" | "bash" | "zsh" => Some("shell"), + "yml" | "yaml" => Some("yaml"), + "html" | "htm" => Some("html"), + "css" => Some("css"), + "scss" | "sass" => Some("scss"), + "sql" => Some("sql"), + "xml" => Some("xml"), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::LanguageCatalog; + use std::path::Path; + + #[test] + fn preserves_all_manifest_language_identifiers_and_aliases() { + let cases = [ + ("main.rs", "rust"), + ("Cargo.toml", "toml"), + ("agent.hcl", "hcl"), + ("index.js", "javascript"), + ("index.mjs", "javascript"), + ("index.cjs", "javascript"), + ("view.jsx", "javascript-react"), + ("index.ts", "typescript"), + ("index.mts", "typescript"), + ("index.cts", "typescript"), + ("view.tsx", "typescript-react"), + ("data.json", "json"), + ("README.md", "markdown"), + ("guide.mdx", "markdown"), + ("main.py", "python"), + ("main.go", "go"), + ("Main.java", "java"), + ("Main.kt", "kotlin"), + ("build.kts", "kotlin"), + ("main.swift", "swift"), + ("main.c", "c"), + ("main.h", "c"), + ("main.cc", "cpp"), + ("main.cpp", "cpp"), + ("main.cxx", "cpp"), + ("main.hpp", "cpp"), + ("Main.cs", "csharp"), + ("main.rb", "ruby"), + ("index.php", "php"), + ("run.sh", "shell"), + ("run.bash", "shell"), + ("run.zsh", "shell"), + ("config.yml", "yaml"), + ("config.yaml", "yaml"), + ("index.html", "html"), + ("index.htm", "html"), + ("style.css", "css"), + ("style.scss", "scss"), + ("style.sass", "scss"), + ("schema.sql", "sql"), + ("document.xml", "xml"), + ]; + + for (path, expected) in cases { + assert_eq!( + LanguageCatalog::id_for_path(Path::new(path)), + Some(expected), + "unexpected language for {path}" + ); + } + } + + #[test] + fn keeps_extension_matching_case_sensitive() { + assert_eq!(LanguageCatalog::id_for_path(Path::new("main.RS")), None); + assert_eq!(LanguageCatalog::id_for_path(Path::new("README")), None); + assert_eq!(LanguageCatalog::id_for_path(Path::new("archive.zip")), None); + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index 44a6b89b..1ed406d6 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -78,6 +78,7 @@ pub(crate) mod agent; pub(crate) mod agent_api; pub mod budget; pub(crate) mod child_run; +pub mod code_intelligence; pub mod commands; pub(crate) mod compaction; pub mod config; @@ -90,6 +91,7 @@ pub(crate) mod git; pub mod hitl; pub mod hooks; pub mod host_env; +pub(crate) mod language; pub mod llm; pub mod loop_checkpoint; pub mod mcp; @@ -134,6 +136,13 @@ pub use agent::{AgentEvent, AgentResult}; pub use agent_api::{ Agent, AgentSession, ReadFileOptions, SessionBuilder, SessionOptions, ToolCallResult, }; +pub use code_intelligence::{ + CodeDiagnostic, CodeDiagnosticSeverity, CodeIntelligenceCapabilities, CodeIntelligenceError, + CodeIntelligenceLanguageStatus, CodeIntelligenceResult, CodeIntelligenceState, + CodeIntelligenceStatus, CodeLocation, CodePosition, CodeQueryResult, CodeRange, CodeSymbolKind, + DocumentRevision, DocumentSnapshot, DocumentSymbol, LanguageId, LocalCodeIntelligence, + NavigationKind, SymbolInformation, WorkspaceCodeIntelligence, +}; pub use config::{ AutoDelegationConfig, CodeConfig, ModelConfig, ModelCost, ModelLimit, ModelModalities, OsConfig, ProviderConfig, @@ -194,16 +203,17 @@ pub use workspace::{ LocalWorkspaceManifestSnapshot, ManifestWorkspaceBackend, RecentWorkspaceFile, RemoteGitBackend, RemoteGitBackendConfig, RemoteGitConflict, VirtualPathResolver, WorkspaceCapabilities, WorkspaceCommandRunner, WorkspaceDirEntry, WorkspaceError, - WorkspaceFileSystem, WorkspaceFileSystemExt, WorkspaceFileType, WorkspaceGit, - WorkspaceGitBranch, WorkspaceGitCheckoutOutput, WorkspaceGitCheckoutRequest, - WorkspaceGitCommit, WorkspaceGitCreateBranchRequest, WorkspaceGitCreateWorktreeRequest, - WorkspaceGitDiffRequest, WorkspaceGitRemote, WorkspaceGitRemoveWorktreeRequest, - WorkspaceGitStash, WorkspaceGitStashProvider, WorkspaceGitStashRequest, WorkspaceGitStatus, - WorkspaceGitWorktree, WorkspaceGitWorktreeMutation, WorkspaceGitWorktreeProvider, - WorkspaceGlobRequest, WorkspaceGlobResult, WorkspaceGrepOutcome, WorkspaceGrepRequest, - WorkspaceGrepResult, WorkspacePath, WorkspacePathResolver, WorkspaceRef, WorkspaceResult, - WorkspaceSearch, WorkspaceServices, WorkspaceServicesBuilder, WorkspaceTextRange, - WorkspaceTextReader, WorkspaceVersionConflict, WorkspaceWriteOutcome, + WorkspaceFileChange, WorkspaceFileChangeKind, WorkspaceFileSystem, WorkspaceFileSystemExt, + WorkspaceFileType, WorkspaceGit, WorkspaceGitBranch, WorkspaceGitCheckoutOutput, + WorkspaceGitCheckoutRequest, WorkspaceGitCommit, WorkspaceGitCreateBranchRequest, + WorkspaceGitCreateWorktreeRequest, WorkspaceGitDiffRequest, WorkspaceGitRemote, + WorkspaceGitRemoveWorktreeRequest, WorkspaceGitStash, WorkspaceGitStashProvider, + WorkspaceGitStashRequest, WorkspaceGitStatus, WorkspaceGitWorktree, + WorkspaceGitWorktreeMutation, WorkspaceGitWorktreeProvider, WorkspaceGlobRequest, + WorkspaceGlobResult, WorkspaceGrepOutcome, WorkspaceGrepRequest, WorkspaceGrepResult, + WorkspacePath, WorkspacePathResolver, WorkspaceRef, WorkspaceResult, WorkspaceSearch, + WorkspaceServices, WorkspaceServicesBuilder, WorkspaceTextRange, WorkspaceTextReader, + WorkspaceVersionConflict, WorkspaceWriteOutcome, }; #[cfg(feature = "s3")] pub use workspace::{S3BackendConfig, S3WorkspaceBackend}; diff --git a/core/src/mcp/manager.rs b/core/src/mcp/manager.rs index 0f8a3ac0..c8cebe81 100644 --- a/core/src/mcp/manager.rs +++ b/core/src/mcp/manager.rs @@ -321,7 +321,8 @@ impl McpManager { /// Get all MCP tools, grouped by server name. /// /// Returns `(server_name, tool)` pairs — the caller is responsible for - /// constructing the `mcp____` prefix (e.g. via [`create_mcp_tools`]). + /// constructing the `mcp____` prefix (e.g. via + /// [`create_mcp_tools`](crate::mcp::create_mcp_tools)). pub async fn get_all_tools(&self) -> Vec<(String, McpTool)> { let clients = self.clients.read().await; let mut all_tools = Vec::new(); diff --git a/core/src/queue.rs b/core/src/queue.rs index 9ddb8a31..1a68206f 100644 --- a/core/src/queue.rs +++ b/core/src/queue.rs @@ -33,7 +33,7 @@ use std::time::{Duration, Instant}; pub enum SessionLane { /// Control operations (P0) - pause, resume, cancel Control, - /// Query operations (P1) - read, glob, ls, grep + /// Query operations (P1) - workspace reads and Code Intelligence queries Query, /// Execute operations (P2) - bash, write, edit Execute, @@ -56,7 +56,9 @@ impl SessionLane { pub fn from_tool_name(tool_name: &str) -> Self { match tool_name { "read" | "glob" | "ls" | "grep" | "list_files" | "search" | "web_fetch" - | "web_search" => SessionLane::Query, + | "web_search" | "code_symbols" | "code_navigation" | "code_diagnostics" => { + SessionLane::Query + } "bash" | "write" | "edit" | "delete" | "move" | "copy" | "execute" => { SessionLane::Execute } diff --git a/core/src/sandbox.rs b/core/src/sandbox.rs index 9a937205..baec3a2a 100644 --- a/core/src/sandbox.rs +++ b/core/src/sandbox.rs @@ -1,6 +1,7 @@ //! Sandbox integration for bash tool execution. //! -//! When a [`BashSandbox`] is provided via [`ToolContext::with_sandbox`], the +//! When a [`BashSandbox`] is provided via +//! [`ToolContext::with_sandbox`](crate::tools::ToolContext::with_sandbox), the //! `bash` built-in tool routes commands through that sandbox instead of //! `std::process::Command`. The workspace directory is mounted read-write //! at `/workspace` inside the sandbox. @@ -28,7 +29,7 @@ pub struct SandboxOutput { /// /// Implement this trait to provide a custom sandbox backend. The host /// application constructs the implementation and passes it to the session -/// via [`ToolContext::with_sandbox`]. +/// via [`ToolContext::with_sandbox`](crate::tools::ToolContext::with_sandbox). #[async_trait] pub trait BashSandbox: Send + Sync { /// Execute a shell command inside the sandbox. diff --git a/core/src/tools/builtin/bash.rs b/core/src/tools/builtin/bash.rs index b1198298..13465fdb 100644 --- a/core/src/tools/builtin/bash.rs +++ b/core/src/tools/builtin/bash.rs @@ -120,11 +120,7 @@ pub(crate) fn spawn_shell( .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - cmd.as_std_mut().process_group(0); - } + crate::tools::process::configure_process_group(&mut cmd); if let Some(env) = command_env { cmd.envs(env); } diff --git a/core/src/tools/builtin/code_intelligence/diagnostics.rs b/core/src/tools/builtin/code_intelligence/diagnostics.rs new file mode 100644 index 00000000..e652b376 --- /dev/null +++ b/core/src/tools/builtin/code_intelligence/diagnostics.rs @@ -0,0 +1,70 @@ +use anyhow::Result; +use async_trait::async_trait; +use serde_json::Value; + +use crate::tools::{Tool, ToolContext, ToolOutput}; + +use super::{ + code_intelligence_error, format, invalid_argument, provider, query_capabilities, + structured_success, unavailable, +}; + +pub(super) struct CodeDiagnosticsTool; + +#[async_trait] +impl Tool for CodeDiagnosticsTool { + fn name(&self) -> &str { + "code_diagnostics" + } + + fn description(&self) -> &str { + "Return language diagnostics for saved workspace code. Provide path for one document or omit it for a bounded manifest-backed workspace query. For document queries only, an empty items array means diagnostics were received and cleared. Workspace queries start relevant language runtimes; truncated can also mean partial language coverage." + } + + fn parameters(&self) -> Value { + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "minLength": 1, + "description": "Optional saved workspace document path. Omit for workspace diagnostics." + } + }, + "examples": [{}, {"path": "src/lib.rs"}] + }) + } + + fn capabilities(&self, _args: &Value) -> crate::tools::ToolCapabilities { + query_capabilities() + } + + async fn execute(&self, args: &Value, ctx: &ToolContext) -> Result { + let path = match args.get("path") { + Some(Value::String(path)) if !path.trim().is_empty() => { + match ctx.resolve_workspace_path(path) { + Ok(path) => Some(path), + Err(error) => { + return Ok(invalid_argument(format!( + "failed to resolve workspace path: {error}" + ))) + } + } + } + Some(Value::String(_)) => return Ok(invalid_argument("path must not be empty")), + Some(_) => return Ok(invalid_argument("path must be a string")), + None => None, + }; + let Some(provider) = provider(ctx) else { + return Ok(unavailable("diagnostics")); + }; + match provider + .diagnostics(path.as_ref(), ctx.cancellation_token()) + .await + { + Ok(result) => Ok(structured_success(format::diagnostics(result))), + Err(error) => Ok(code_intelligence_error("diagnostics", error)), + } + } +} diff --git a/core/src/tools/builtin/code_intelligence/format.rs b/core/src/tools/builtin/code_intelligence/format.rs new file mode 100644 index 00000000..97e80440 --- /dev/null +++ b/core/src/tools/builtin/code_intelligence/format.rs @@ -0,0 +1,131 @@ +use serde_json::{json, Value}; + +use crate::code_intelligence::{ + CodeDiagnostic, CodeDiagnosticSeverity, CodeLocation, CodePosition, CodeQueryResult, CodeRange, + CodeSymbolKind, DocumentSnapshot, DocumentSymbol, SymbolInformation, +}; + +pub(super) fn document_symbols(result: CodeQueryResult) -> Value { + query_result(result, document_symbol) +} + +pub(super) fn workspace_symbols(result: CodeQueryResult) -> Value { + query_result(result, symbol_information) +} + +pub(super) fn locations(result: CodeQueryResult) -> Value { + query_result(result, location) +} + +pub(super) fn diagnostics(result: CodeQueryResult) -> Value { + query_result(result, diagnostic) +} + +fn query_result(result: CodeQueryResult, map: impl FnMut(T) -> Value) -> Value { + json!({ + "items": result.items.into_iter().map(map).collect::>(), + "truncated": result.truncated, + "workspace_revision": result.workspace_revision, + "document": result.document.map(snapshot), + }) +} + +fn snapshot(snapshot: DocumentSnapshot) -> Value { + json!({ + "revision": snapshot.revision.value(), + "content_hash": snapshot.content_hash, + "stale": snapshot.stale, + }) +} + +fn document_symbol(symbol: DocumentSymbol) -> Value { + json!({ + "name": symbol.name, + "detail": symbol.detail, + "kind": symbol_kind(symbol.kind), + "range": range(symbol.range), + "selection_range": range(symbol.selection_range), + "children": symbol.children.into_iter().map(document_symbol).collect::>(), + }) +} + +fn symbol_information(symbol: SymbolInformation) -> Value { + json!({ + "name": symbol.name, + "kind": symbol_kind(symbol.kind), + "location": location(symbol.location), + "container_name": symbol.container_name, + }) +} + +fn diagnostic(diagnostic: CodeDiagnostic) -> Value { + json!({ + "location": location(diagnostic.location), + "severity": diagnostic.severity.map(diagnostic_severity), + "code": diagnostic.code, + "source": diagnostic.source, + "message": diagnostic.message, + }) +} + +fn location(location: CodeLocation) -> Value { + json!({ + "path": location.path.as_str(), + "range": range(location.range), + }) +} + +fn range(range: CodeRange) -> Value { + json!({ + "start": position(range.start), + "end": position(range.end), + }) +} + +fn position(position: CodePosition) -> Value { + json!({ + "line": position.line, + "character": position.character, + }) +} + +fn diagnostic_severity(severity: CodeDiagnosticSeverity) -> &'static str { + match severity { + CodeDiagnosticSeverity::Error => "error", + CodeDiagnosticSeverity::Warning => "warning", + CodeDiagnosticSeverity::Information => "information", + CodeDiagnosticSeverity::Hint => "hint", + } +} + +fn symbol_kind(kind: CodeSymbolKind) -> &'static str { + match kind { + CodeSymbolKind::File => "file", + CodeSymbolKind::Module => "module", + CodeSymbolKind::Namespace => "namespace", + CodeSymbolKind::Package => "package", + CodeSymbolKind::Class => "class", + CodeSymbolKind::Method => "method", + CodeSymbolKind::Property => "property", + CodeSymbolKind::Field => "field", + CodeSymbolKind::Constructor => "constructor", + CodeSymbolKind::Enum => "enum", + CodeSymbolKind::Interface => "interface", + CodeSymbolKind::Function => "function", + CodeSymbolKind::Variable => "variable", + CodeSymbolKind::Constant => "constant", + CodeSymbolKind::String => "string", + CodeSymbolKind::Number => "number", + CodeSymbolKind::Boolean => "boolean", + CodeSymbolKind::Array => "array", + CodeSymbolKind::Object => "object", + CodeSymbolKind::Key => "key", + CodeSymbolKind::Null => "null", + CodeSymbolKind::EnumMember => "enum_member", + CodeSymbolKind::Struct => "struct", + CodeSymbolKind::Event => "event", + CodeSymbolKind::Operator => "operator", + CodeSymbolKind::TypeParameter => "type_parameter", + CodeSymbolKind::Unknown => "unknown", + } +} diff --git a/core/src/tools/builtin/code_intelligence/mod.rs b/core/src/tools/builtin/code_intelligence/mod.rs new file mode 100644 index 00000000..238bd170 --- /dev/null +++ b/core/src/tools/builtin/code_intelligence/mod.rs @@ -0,0 +1,85 @@ +//! Read-only Code Intelligence tools backed by workspace services. + +mod diagnostics; +mod format; +mod navigation; +mod symbols; + +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use crate::code_intelligence::{CodeIntelligenceError, WorkspaceCodeIntelligence}; +use crate::tools::{ + ToolCapabilities, ToolContext, ToolErrorKind, ToolOutput, ToolOutputKind, ToolRegistry, +}; + +pub(super) fn register(registry: &ToolRegistry) { + registry.register_builtin(Arc::new(symbols::CodeSymbolsTool)); + registry.register_builtin(Arc::new(navigation::CodeNavigationTool)); + registry.register_builtin(Arc::new(diagnostics::CodeDiagnosticsTool)); +} + +fn provider(ctx: &ToolContext) -> Option> { + ctx.workspace_services.code_intelligence() +} + +fn unavailable(operation: &str) -> ToolOutput { + code_intelligence_error( + operation, + CodeIntelligenceError::Unavailable { + message: "this workspace did not provide a Code Intelligence runtime".to_owned(), + }, + ) +} + +fn invalid_argument(message: impl Into) -> ToolOutput { + let message = message.into(); + ToolOutput::error(message.clone()).with_error_kind(ToolErrorKind::InvalidArgument { message }) +} + +fn code_intelligence_error(operation: &str, error: CodeIntelligenceError) -> ToolOutput { + let error_kind = match &error { + CodeIntelligenceError::Unsupported { message, .. } => Some(ToolErrorKind::Unsupported { + message: message.clone(), + }), + CodeIntelligenceError::InvalidPath { .. } + | CodeIntelligenceError::InvalidPosition { .. } => Some(ToolErrorKind::InvalidArgument { + message: error.to_string(), + }), + CodeIntelligenceError::Cancelled => Some(ToolErrorKind::Cancelled { + op: operation.to_owned(), + }), + CodeIntelligenceError::Timeout { duration, .. } => Some(ToolErrorKind::Timeout { + op: operation.to_owned(), + duration_ms: duration.as_millis().try_into().unwrap_or(u64::MAX), + }), + _ => None, + }; + let mut output = ToolOutput::error(format!("Code Intelligence query failed: {error}")) + .with_metadata(serde_json::json!({ + "code": error.code(), + "operation": operation, + })); + if let Some(error_kind) = error_kind { + output = output.with_error_kind(error_kind); + } + output +} + +fn structured_success(value: serde_json::Value) -> ToolOutput { + match serde_json::to_string_pretty(&value) { + Ok(content) => ToolOutput::success(content).with_metadata(value), + Err(error) => ToolOutput::error(format!( + "Code Intelligence result could not be serialized: {error}" + )), + } +} + +fn query_capabilities() -> ToolCapabilities { + ToolCapabilities { + output_kind: ToolOutputKind::Structured, + ..ToolCapabilities::parallel_safe_read(8) + } +} diff --git a/core/src/tools/builtin/code_intelligence/navigation.rs b/core/src/tools/builtin/code_intelligence/navigation.rs new file mode 100644 index 00000000..7da6f19d --- /dev/null +++ b/core/src/tools/builtin/code_intelligence/navigation.rs @@ -0,0 +1,126 @@ +use anyhow::Result; +use async_trait::async_trait; +use serde_json::Value; + +use crate::{ + code_intelligence::{CodePosition, NavigationKind}, + tools::{Tool, ToolContext, ToolOutput}, +}; + +use super::{ + code_intelligence_error, format, invalid_argument, provider, query_capabilities, + structured_success, unavailable, +}; + +pub(super) struct CodeNavigationTool; + +#[async_trait] +impl Tool for CodeNavigationTool { + fn name(&self) -> &str { + "code_navigation" + } + + fn description(&self) -> &str { + "Navigate saved workspace code by language semantics. Resolves definitions, declarations, references, or implementations from a zero-based UTF-16 position. Returns locations only, not source code." + } + + fn parameters(&self) -> Value { + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "operation": { + "type": "string", + "enum": ["definition", "declaration", "references", "implementations"] + }, + "path": { + "type": "string", + "minLength": 1, + "description": "Saved workspace document path." + }, + "line": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295_u64, + "description": "Zero-based line." + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295_u64, + "description": "Zero-based UTF-16 code-unit offset." + } + }, + "required": ["operation", "path", "line", "character"], + "examples": [{ + "operation": "definition", + "path": "src/lib.rs", + "line": 12, + "character": 8 + }] + }) + } + + fn capabilities(&self, _args: &Value) -> crate::tools::ToolCapabilities { + query_capabilities() + } + + async fn execute(&self, args: &Value, ctx: &ToolContext) -> Result { + let (operation, kind) = match args.get("operation").and_then(Value::as_str) { + Some("definition") => ("definition", NavigationKind::Definition), + Some("declaration") => ("declaration", NavigationKind::Declaration), + Some("references") => ("references", NavigationKind::References), + Some("implementations") => ("implementations", NavigationKind::Implementations), + Some(_) => { + return Ok(invalid_argument( + "operation must be definition, declaration, references, or implementations", + )) + } + None => return Ok(invalid_argument("operation parameter is required")), + }; + let Some(path) = args.get("path").and_then(Value::as_str) else { + return Ok(invalid_argument("path parameter is required")); + }; + if path.trim().is_empty() { + return Ok(invalid_argument("path must not be empty")); + } + let path = match ctx.resolve_workspace_path(path) { + Ok(path) => path, + Err(error) => { + return Ok(invalid_argument(format!( + "failed to resolve workspace path: {error}" + ))) + } + }; + let line = match u32_argument(args, "line") { + Ok(value) => value, + Err(message) => return Ok(invalid_argument(message)), + }; + let character = match u32_argument(args, "character") { + Ok(value) => value, + Err(message) => return Ok(invalid_argument(message)), + }; + let Some(provider) = provider(ctx) else { + return Ok(unavailable(operation)); + }; + match provider + .navigate( + kind, + &path, + CodePosition::new(line, character), + ctx.cancellation_token(), + ) + .await + { + Ok(result) => Ok(structured_success(format::locations(result))), + Err(error) => Ok(code_intelligence_error(operation, error)), + } + } +} + +fn u32_argument(args: &Value, name: &str) -> std::result::Result { + args.get(name) + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| format!("{name} must be an integer from 0 to 4294967295")) +} diff --git a/core/src/tools/builtin/code_intelligence/symbols.rs b/core/src/tools/builtin/code_intelligence/symbols.rs new file mode 100644 index 00000000..cf90bce2 --- /dev/null +++ b/core/src/tools/builtin/code_intelligence/symbols.rs @@ -0,0 +1,135 @@ +use anyhow::Result; +use async_trait::async_trait; +use serde_json::Value; + +use crate::tools::{Tool, ToolContext, ToolOutput}; + +use super::{ + code_intelligence_error, format, invalid_argument, provider, query_capabilities, + structured_success, unavailable, +}; + +const DEFAULT_SEARCH_LIMIT: usize = 100; +const MAX_SEARCH_LIMIT: usize = 500; + +pub(super) struct CodeSymbolsTool; + +#[async_trait] +impl Tool for CodeSymbolsTool { + fn name(&self) -> &str { + "code_symbols" + } + + fn description(&self) -> &str { + "Query semantic symbols from saved workspace files. Use operation='outline' for a document hierarchy or operation='search' for workspace symbols. Returns symbol metadata and locations, not source code; use read when source text is needed." + } + + fn parameters(&self) -> Value { + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "operation": { + "type": "string", + "enum": ["outline", "search"] + }, + "path": { + "type": "string", + "minLength": 1, + "description": "Saved workspace document path. Required for outline." + }, + "query": { + "type": "string", + "minLength": 1, + "description": "Semantic symbol query. Required for search." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": MAX_SEARCH_LIMIT, + "description": "Maximum workspace symbols for search. Default: 100; maximum: 500." + } + }, + "required": ["operation"], + "oneOf": [ + { + "properties": {"operation": {"const": "outline"}}, + "required": ["path"] + }, + { + "properties": {"operation": {"const": "search"}}, + "required": ["query"] + } + ], + "examples": [ + {"operation": "outline", "path": "src/lib.rs"}, + {"operation": "search", "query": "WorkspaceClient", "limit": 50} + ] + }) + } + + fn capabilities(&self, _args: &Value) -> crate::tools::ToolCapabilities { + query_capabilities() + } + + async fn execute(&self, args: &Value, ctx: &ToolContext) -> Result { + let operation = match args.get("operation").and_then(Value::as_str) { + Some(operation @ ("outline" | "search")) => operation, + Some(_) => return Ok(invalid_argument("operation must be 'outline' or 'search'")), + None => return Ok(invalid_argument("operation parameter is required")), + }; + let Some(provider) = provider(ctx) else { + return Ok(unavailable(operation)); + }; + + if operation == "outline" { + let Some(path) = args.get("path").and_then(Value::as_str) else { + return Ok(invalid_argument("path is required for outline")); + }; + if path.trim().is_empty() { + return Ok(invalid_argument("path must not be empty")); + } + let path = match ctx.resolve_workspace_path(path) { + Ok(path) => path, + Err(error) => { + return Ok(invalid_argument(format!( + "failed to resolve workspace path: {error}" + ))) + } + }; + match provider + .document_symbols(&path, ctx.cancellation_token()) + .await + { + Ok(result) => Ok(structured_success(format::document_symbols(result))), + Err(error) => Ok(code_intelligence_error(operation, error)), + } + } else { + let Some(query) = args.get("query").and_then(Value::as_str) else { + return Ok(invalid_argument("query is required for search")); + }; + let query = query.trim(); + if query.is_empty() { + return Ok(invalid_argument("query must not be empty")); + } + let limit = match args.get("limit") { + Some(limit) => match limit.as_u64().and_then(|limit| usize::try_from(limit).ok()) { + Some(limit) if (1..=MAX_SEARCH_LIMIT).contains(&limit) => limit, + _ => { + return Ok(invalid_argument(format!( + "limit must be an integer between 1 and {MAX_SEARCH_LIMIT}" + ))) + } + }, + None => DEFAULT_SEARCH_LIMIT, + }; + match provider + .search_symbols(query, limit, ctx.cancellation_token()) + .await + { + Ok(result) => Ok(structured_success(format::workspace_symbols(result))), + Err(error) => Ok(code_intelligence_error(operation, error)), + } + } + } +} diff --git a/core/src/tools/builtin/code_intelligence/tests.rs b/core/src/tools/builtin/code_intelligence/tests.rs new file mode 100644 index 00000000..1f16f77c --- /dev/null +++ b/core/src/tools/builtin/code_intelligence/tests.rs @@ -0,0 +1,440 @@ +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; + +use async_trait::async_trait; +use serde_json::{json, Value}; +use tokio::sync::{watch, Mutex}; +use tokio_util::sync::CancellationToken; + +use super::{ + diagnostics::CodeDiagnosticsTool, navigation::CodeNavigationTool, symbols::CodeSymbolsTool, +}; +use crate::{ + code_intelligence::{ + CodeDiagnostic, CodeDiagnosticSeverity, CodeIntelligenceCapabilities, + CodeIntelligenceError, CodeIntelligenceResult, CodeIntelligenceState, + CodeIntelligenceStatus, CodeLocation, CodePosition, CodeQueryResult, CodeRange, + CodeSymbolKind, DocumentRevision, DocumentSnapshot, DocumentSymbol, NavigationKind, + SymbolInformation, WorkspaceCodeIntelligence, + }, + tools::{Tool, ToolContext, ToolErrorKind, ToolOutputKind}, + workspace::{WorkspacePath, WorkspaceServices}, +}; + +struct TestProvider { + status: watch::Sender, + calls: AtomicUsize, + fail: bool, + last_navigation: Mutex>, + last_search: Mutex>, +} + +impl TestProvider { + fn new(fail: bool) -> Arc { + let (status, _) = watch::channel(CodeIntelligenceStatus { + state: CodeIntelligenceState::Ready, + capabilities: CodeIntelligenceCapabilities { + document_symbols: true, + workspace_symbols: true, + definition: true, + declaration: true, + references: true, + implementations: true, + diagnostics: true, + }, + languages: Vec::new(), + message: None, + }); + Arc::new(Self { + status, + calls: AtomicUsize::new(0), + fail, + last_navigation: Mutex::new(None), + last_search: Mutex::new(None), + }) + } + + fn unavailable(&self) -> CodeIntelligenceResult { + Err(CodeIntelligenceError::Unavailable { + message: "test runtime unavailable".to_owned(), + }) + } + + fn snapshot() -> DocumentSnapshot { + DocumentSnapshot { + revision: DocumentRevision::new(7), + content_hash: "saved-content-hash".to_owned(), + stale: false, + } + } + + fn result(items: Vec, document: Option) -> CodeQueryResult { + CodeQueryResult { + items, + truncated: false, + workspace_revision: 11, + document, + } + } +} + +#[async_trait] +impl WorkspaceCodeIntelligence for TestProvider { + fn subscribe_status(&self) -> watch::Receiver { + self.status.subscribe() + } + + async fn document_symbols( + &self, + _path: &WorkspacePath, + _cancellation: CancellationToken, + ) -> CodeIntelligenceResult> { + self.calls.fetch_add(1, Ordering::Relaxed); + if self.fail { + return self.unavailable(); + } + Ok(Self::result( + vec![DocumentSymbol { + name: "WorkspaceClient".to_owned(), + detail: Some("struct".to_owned()), + kind: CodeSymbolKind::Struct, + range: test_range(), + selection_range: test_range(), + children: vec![DocumentSymbol { + name: "navigate".to_owned(), + detail: None, + kind: CodeSymbolKind::Method, + range: test_range(), + selection_range: test_range(), + children: Vec::new(), + }], + }], + Some(Self::snapshot()), + )) + } + + async fn search_symbols( + &self, + query: &str, + limit: usize, + _cancellation: CancellationToken, + ) -> CodeIntelligenceResult> { + self.calls.fetch_add(1, Ordering::Relaxed); + *self.last_search.lock().await = Some((query.to_owned(), limit)); + if self.fail { + return self.unavailable(); + } + Ok(Self::result( + vec![SymbolInformation { + name: "WorkspaceClient".to_owned(), + kind: CodeSymbolKind::Struct, + location: CodeLocation { + path: WorkspacePath::from_normalized("src/client.rs"), + range: test_range(), + }, + container_name: Some("runtime".to_owned()), + }], + None, + )) + } + + async fn navigate( + &self, + kind: NavigationKind, + path: &WorkspacePath, + position: CodePosition, + _cancellation: CancellationToken, + ) -> CodeIntelligenceResult> { + self.calls.fetch_add(1, Ordering::Relaxed); + *self.last_navigation.lock().await = Some((kind, path.clone(), position)); + if self.fail { + return self.unavailable(); + } + Ok(Self::result( + vec![CodeLocation { + path: WorkspacePath::from_normalized("src/implementation.rs"), + range: test_range(), + }], + Some(Self::snapshot()), + )) + } + + async fn diagnostics( + &self, + path: Option<&WorkspacePath>, + _cancellation: CancellationToken, + ) -> CodeIntelligenceResult> { + self.calls.fetch_add(1, Ordering::Relaxed); + if self.fail { + return self.unavailable(); + } + let path = path + .cloned() + .unwrap_or_else(|| WorkspacePath::from_normalized("src/lib.rs")); + Ok(Self::result( + vec![CodeDiagnostic { + location: CodeLocation { + path, + range: test_range(), + }, + severity: Some(CodeDiagnosticSeverity::Warning), + code: Some("unused".to_owned()), + source: Some("compiler".to_owned()), + message: "unused symbol".to_owned(), + }], + Some(Self::snapshot()), + )) + } +} + +fn test_range() -> CodeRange { + CodeRange::new(CodePosition::new(2, 4), CodePosition::new(2, 12)) +} + +fn context(provider: Arc) -> (tempfile::TempDir, ToolContext) { + let temp = tempfile::tempdir().unwrap(); + let provider: Arc = provider; + let services = WorkspaceServices::local(temp.path()).with_code_intelligence(provider); + let context = ToolContext::new(temp.path().to_path_buf()).with_workspace_services(services); + (temp, context) +} + +fn output_json(output: &crate::tools::ToolOutput) -> Value { + serde_json::from_str(&output.content).unwrap() +} + +#[tokio::test] +async fn missing_and_failing_providers_return_typed_unavailable_errors() { + let temp = tempfile::tempdir().unwrap(); + let missing = CodeSymbolsTool + .execute( + &json!({"operation": "search", "query": "Client"}), + &ToolContext::new(temp.path().to_path_buf()), + ) + .await + .unwrap(); + assert!(!missing.success); + assert!(missing.content.contains("Code Intelligence")); + assert_eq!( + missing.metadata.unwrap()["code"], + "CODE_INTELLIGENCE_UNAVAILABLE" + ); + + let failing = TestProvider::new(true); + let (_temp, context) = context(Arc::clone(&failing)); + let output = CodeDiagnosticsTool + .execute(&json!({}), &context) + .await + .unwrap(); + assert!(!output.success); + assert_eq!( + output.metadata.unwrap()["code"], + "CODE_INTELLIGENCE_UNAVAILABLE" + ); + assert_eq!(failing.calls.load(Ordering::Relaxed), 1); +} + +#[tokio::test] +async fn invalid_arguments_and_escaping_paths_never_reach_provider() { + let provider = TestProvider::new(false); + let (_temp, context) = context(Arc::clone(&provider)); + + for output in [ + CodeSymbolsTool + .execute(&json!({"operation": "search", "query": " "}), &context) + .await + .unwrap(), + CodeSymbolsTool + .execute( + &json!({"operation": "outline", "path": "../outside.rs"}), + &context, + ) + .await + .unwrap(), + CodeNavigationTool + .execute( + &json!({ + "operation": "definition", + "path": "src/lib.rs", + "line": -1, + "character": 0 + }), + &context, + ) + .await + .unwrap(), + CodeDiagnosticsTool + .execute(&json!({"path": "../../outside.rs"}), &context) + .await + .unwrap(), + ] { + assert!(!output.success); + assert!(matches!( + output.error_kind, + Some(ToolErrorKind::InvalidArgument { .. }) + )); + } + assert_eq!(provider.calls.load(Ordering::Relaxed), 0); +} + +#[tokio::test] +async fn symbols_tool_maps_outline_and_bounded_workspace_search() { + let provider = TestProvider::new(false); + let (_temp, context) = context(Arc::clone(&provider)); + + let outline = CodeSymbolsTool + .execute( + &json!({"operation": "outline", "path": "src/lib.rs"}), + &context, + ) + .await + .unwrap(); + assert!(outline.success); + let outline = output_json(&outline); + assert_eq!(outline["items"][0]["name"], "WorkspaceClient"); + assert_eq!(outline["items"][0]["kind"], "struct"); + assert_eq!(outline["items"][0]["children"][0]["kind"], "method"); + assert_eq!(outline["document"]["revision"], 7); + + let search = CodeSymbolsTool + .execute( + &json!({"operation": "search", "query": " Client ", "limit": 25}), + &context, + ) + .await + .unwrap(); + assert!(search.success); + let search = output_json(&search); + assert_eq!(search["items"][0]["location"]["path"], "src/client.rs"); + assert_eq!( + *provider.last_search.lock().await, + Some(("Client".to_owned(), 25)) + ); +} + +#[tokio::test] +async fn navigation_tool_uses_typed_utf16_position_and_kind() { + let provider = TestProvider::new(false); + let (_temp, context) = context(Arc::clone(&provider)); + let output = CodeNavigationTool + .execute( + &json!({ + "operation": "implementations", + "path": "src/lib.rs", + "line": 3, + "character": 9 + }), + &context, + ) + .await + .unwrap(); + + assert!(output.success); + assert_eq!( + output_json(&output)["items"][0]["path"], + "src/implementation.rs" + ); + assert_eq!( + *provider.last_navigation.lock().await, + Some(( + NavigationKind::Implementations, + WorkspacePath::from_normalized("src/lib.rs"), + CodePosition::new(3, 9), + )) + ); +} + +#[tokio::test] +async fn diagnostics_tool_maps_typed_diagnostics_and_saved_snapshot() { + let provider = TestProvider::new(false); + let (_temp, context) = context(provider); + let output = CodeDiagnosticsTool + .execute(&json!({"path": "src/lib.rs"}), &context) + .await + .unwrap(); + + assert!(output.success); + let value = output_json(&output); + assert_eq!(value["items"][0]["location"]["path"], "src/lib.rs"); + assert_eq!(value["items"][0]["severity"], "warning"); + assert_eq!(value["items"][0]["code"], "unused"); + assert_eq!(value["document"]["content_hash"], "saved-content-hash"); +} + +#[tokio::test] +async fn registration_is_capability_gated_and_tools_are_structured_query_reads() { + let temp = tempfile::tempdir().unwrap(); + let plain_services = WorkspaceServices::local(temp.path()); + let plain = crate::tools::ToolExecutor::new_with_workspace_services( + temp.path().display().to_string(), + plain_services, + ); + for name in ["code_symbols", "code_navigation", "code_diagnostics"] { + assert!(!plain.registry().contains(name)); + } + + let test_provider = TestProvider::new(false); + let provider: Arc = test_provider.clone(); + let services = WorkspaceServices::local(temp.path()).with_code_intelligence(provider); + let enabled = crate::tools::ToolExecutor::new_with_workspace_services( + temp.path().display().to_string(), + services, + ); + for name in ["code_symbols", "code_navigation", "code_diagnostics"] { + assert!(enabled.registry().contains(name)); + let capabilities = enabled.registry().capabilities(name, &json!({})).unwrap(); + assert!(capabilities.read_only); + assert!(capabilities.idempotent); + assert!(capabilities.cancellation_safe); + assert_eq!(capabilities.output_kind, ToolOutputKind::Structured); + assert_eq!( + crate::queue::SessionLane::from_tool_name(name), + crate::queue::SessionLane::Query + ); + } + assert!(enabled + .registry() + .validate_arguments( + "code_symbols", + &json!({"operation": "outline", "path": "src/lib.rs"}), + ) + .is_ok()); + assert!(enabled + .registry() + .validate_arguments("code_symbols", &json!({"operation": "outline"})) + .is_err()); + assert!(enabled + .registry() + .validate_arguments( + "code_navigation", + &json!({ + "operation": "definition", + "path": "src/lib.rs", + "line": 0, + "character": 0 + }), + ) + .is_ok()); + assert!(enabled + .registry() + .validate_arguments("code_diagnostics", &json!({})) + .is_ok()); + + let escaped = enabled + .execute( + "code_navigation", + &json!({ + "operation": "definition", + "path": "../outside.rs", + "line": 0, + "character": 0 + }), + ) + .await + .unwrap(); + assert_eq!(escaped.exit_code, 1); + assert!(escaped.output.contains("Workspace boundary check failed")); + assert_eq!(test_provider.calls.load(Ordering::Relaxed), 0); +} diff --git a/core/src/tools/builtin/mod.rs b/core/src/tools/builtin/mod.rs index 48be5161..088b43a9 100644 --- a/core/src/tools/builtin/mod.rs +++ b/core/src/tools/builtin/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod bash; pub mod batch; +mod code_intelligence; mod edit; mod generate_object; pub(crate) mod git; @@ -45,8 +46,9 @@ pub(crate) fn safe_http_source_url(value: &str) -> Option { /// and must be registered after the registry is wrapped in an Arc. pub fn register_builtins( registry: &ToolRegistry, - capabilities: &crate::workspace::WorkspaceCapabilities, + workspace_services: &crate::workspace::WorkspaceServices, ) { + let capabilities = workspace_services.capabilities(); if capabilities.read { registry.register_builtin(Arc::new(read::ReadTool)); registry.register_builtin(Arc::new(ls::LsTool)); @@ -65,6 +67,9 @@ pub fn register_builtins( registry.register_builtin(Arc::new(grep::GrepTool)); registry.register_builtin(Arc::new(glob_tool::GlobTool)); } + if workspace_services.code_intelligence().is_some() { + code_intelligence::register(registry); + } if capabilities.git { registry.register_builtin(Arc::new(git::GitTool)); } diff --git a/core/src/tools/mod.rs b/core/src/tools/mod.rs index f44d0026..c84c4d38 100644 --- a/core/src/tools/mod.rs +++ b/core/src/tools/mod.rs @@ -6,7 +6,7 @@ //! //! ```text //! ToolRegistry -//! └── builtin tools (bash, read, write, edit, grep, glob, ls, patch, web_fetch, web_search) +//! └── builtin tools (file, search, execution, web, and Code Intelligence queries) //! ``` mod agent_dir_script_tool; @@ -328,7 +328,7 @@ impl ToolExecutor { // Register native Rust built-in tools — only those whose required // workspace capability is available, so the model never sees a tool // the backend cannot service. - builtin::register_builtins(®istry, &workspace_services.capabilities()); + builtin::register_builtins(®istry, &workspace_services); // Batch tool requires Arc, registered separately builtin::register_batch(®istry); builtin::register_program(®istry); @@ -347,7 +347,9 @@ impl ToolExecutor { ) -> Result<()> { let path_field = match name { "read" | "write" | "edit" | "patch" => Some("file_path"), - "ls" | "grep" | "glob" => Some("path"), + "ls" | "grep" | "glob" | "code_symbols" | "code_navigation" | "code_diagnostics" => { + Some("path") + } _ => None, }; diff --git a/core/src/tools/process.rs b/core/src/tools/process.rs index 5ce3000a..76b64958 100644 --- a/core/src/tools/process.rs +++ b/core/src/tools/process.rs @@ -4,7 +4,7 @@ use super::MAX_OUTPUT_SIZE; use crate::workspace::{CommandOutputObserver, CommandOutputSummary}; use std::collections::VecDeque; use tokio::io::AsyncReadExt; -use tokio::process::Child; +use tokio::process::{Child, Command}; const READ_CHUNK_BYTES: usize = 8 * 1024; const OUTPUT_HEAD_BYTES: usize = 64 * 1024; @@ -64,35 +64,70 @@ impl BoundedCapture { } } -#[cfg(unix)] -struct ProcessGroupGuard { +/// Configure a child as the leader of its own process group when supported. +pub(crate) fn configure_process_group(command: &mut Command) { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.as_std_mut().process_group(0); + } + #[cfg(not(unix))] + let _ = command; +} + +/// Configure a blocking child as the leader of its own process group when +/// supported. Blocking workspace discovery commands use the same cancellation +/// semantics as Tokio-managed tools and language servers. +pub(crate) fn configure_std_process_group(command: &mut std::process::Command) { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + #[cfg(not(unix))] + let _ = command; +} + +pub(crate) struct ProcessGroupGuard { + #[cfg(unix)] process_group: Option, } -#[cfg(unix)] impl ProcessGroupGuard { - fn for_child(child: &Child) -> Self { + pub(crate) fn for_child(child: &Child) -> Self { + Self::for_process_id(child.id()) + } + + pub(crate) fn for_process_id(process_id: Option) -> Self { + #[cfg(not(unix))] + let _ = process_id; Self { - process_group: child.id().and_then(|id| i32::try_from(id).ok()), + #[cfg(unix)] + process_group: process_id.and_then(|id| i32::try_from(id).ok()), } } - fn kill(&mut self) { - if let Some(process_group) = self.process_group.take() { - // The shell is spawned as the leader of a dedicated process group. - // A negative PID addresses the whole group, including grandchildren. - unsafe { - libc::kill(-process_group, libc::SIGKILL); + pub(crate) fn kill(&mut self) { + #[cfg(unix)] + { + if let Some(process_group) = self.process_group.take() { + // A negative PID addresses the whole group, including + // grandchildren spawned by the language server or shell. + unsafe { + libc::kill(-process_group, libc::SIGKILL); + } } } } - fn disarm(&mut self) { - self.process_group = None; + pub(crate) fn disarm(&mut self) { + #[cfg(unix)] + { + self.process_group = None; + } } } -#[cfg(unix)] impl Drop for ProcessGroupGuard { fn drop(&mut self) { self.kill(); @@ -117,7 +152,6 @@ pub(crate) async fn read_process_output( } }; - #[cfg(unix)] let mut process_group = ProcessGroupGuard::for_child(child); let mut capture = BoundedCapture::new(); let mut stdout_done = false; @@ -169,7 +203,6 @@ pub(crate) async fn read_process_output( .is_err(); if timed_out { - #[cfg(unix)] process_group.kill(); child.start_kill().ok(); let _ = tokio::time::timeout( @@ -178,7 +211,6 @@ pub(crate) async fn read_process_output( ) .await; } else { - #[cfg(unix)] process_group.disarm(); } diff --git a/core/src/tools/selector.rs b/core/src/tools/selector.rs index c5384642..b7243485 100644 --- a/core/src/tools/selector.rs +++ b/core/src/tools/selector.rs @@ -12,6 +12,9 @@ const CORE_TOOLS: &[&str] = &[ "grep", "glob", "ls", + "code_symbols", + "code_navigation", + "code_diagnostics", "task", "parallel_task", "Skill", @@ -183,6 +186,9 @@ mod tests { &defs(&[ "read", "write", + "code_symbols", + "code_navigation", + "code_diagnostics", "web_search", "web_fetch", "git", @@ -199,6 +205,9 @@ mod tests { let names: Vec<_> = selected.iter().map(|t| t.name.as_str()).collect(); assert!(names.contains(&"read")); + assert!(names.contains(&"code_symbols")); + assert!(names.contains(&"code_navigation")); + assert!(names.contains(&"code_diagnostics")); assert!(names.contains(&"task")); assert!(names.contains(&"Skill")); assert!(names.contains(&"search_skills")); diff --git a/core/src/tools/skill.rs b/core/src/tools/skill.rs index ea5c6f6d..8ffc0520 100644 --- a/core/src/tools/skill.rs +++ b/core/src/tools/skill.rs @@ -7,7 +7,7 @@ //! //! ## Usage //! -//! ```rust +//! ```text //! // Agent calls: Skill("data-processor") //! // The skill's allowed-tools are temporarily granted //! // After execution, permissions are restored diff --git a/core/src/tools/task/parallel_execution.rs b/core/src/tools/task/parallel_execution.rs index 27c8c72e..59093a44 100644 --- a/core/src/tools/task/parallel_execution.rs +++ b/core/src/tools/task/parallel_execution.rs @@ -13,7 +13,7 @@ impl TaskExecutor { /// /// Spawns all tasks concurrently and waits for all to complete. /// Returns results in the same order as the input tasks. Routed through - /// the [`AgentExecutor`](crate::orchestration::AgentExecutor) seam so the + /// the [`AgentExecutor`] seam so the /// same fan-out works whether steps run locally (default) or are placed /// on remote nodes by a host. pub async fn execute_parallel( diff --git a/core/src/workspace/manifest.rs b/core/src/workspace/manifest.rs index db1299d7..fa005408 100644 --- a/core/src/workspace/manifest.rs +++ b/core/src/workspace/manifest.rs @@ -20,21 +20,26 @@ use super::{ }; use anyhow::{anyhow, Result}; use async_trait::async_trait; -use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; use std::collections::{hash_map::DefaultHasher, HashMap, HashSet}; use std::hash::{Hash, Hasher}; use std::path::{Component, Path, PathBuf}; -use std::sync::{Arc, RwLock}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use tokio::sync::{broadcast, mpsc}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, RwLock, +}; +#[cfg(test)] +use std::time::Duration; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::sync::broadcast; mod scanner; +mod watcher; use scanner::is_relevant_event; pub use scanner::scan_workspace_files; +use watcher::run_manifest_task; -const WATCH_DEBOUNCE: Duration = Duration::from_millis(150); -const WATCH_STARTUP_SCAN_INTERVAL: Duration = Duration::from_secs(1); const SNAPSHOT_CHANNEL_CAPACITY: usize = 16; +const FILE_CHANGE_CHANNEL_CAPACITY: usize = 256; const RECENT_FILE_LIMIT: usize = 128; const RECENT_DECAY_HALF_LIFE_MS: f32 = 10.0 * 60.0 * 1000.0; const RECENT_FREQUENCY_NORMALIZER: f32 = 16.0; @@ -81,6 +86,21 @@ pub struct LocalWorkspaceManifestSnapshot { pub scanned_at_ms: u64, } +/// The normalized kind of a workspace file change. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum WorkspaceFileChangeKind { + Created, + Changed, + Deleted, +} + +/// A filesystem change for one normalized workspace-relative file path. +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct WorkspaceFileChange { + pub path: WorkspacePath, + pub kind: WorkspaceFileChangeKind, +} + impl LocalWorkspaceManifestSnapshot { pub fn empty(root: PathBuf) -> Self { Self { @@ -101,6 +121,8 @@ pub struct LocalWorkspaceManifest { state: Arc>, recent: Arc>, snapshots: broadcast::Sender, + changes: broadcast::Sender, + scan_cancelled: Arc, task: tokio::task::JoinHandle<()>, } @@ -117,15 +139,28 @@ impl LocalWorkspaceManifest { })); let recent = Arc::new(RwLock::new(RecentFiles::default())); let (snapshots, _) = broadcast::channel(SNAPSHOT_CHANNEL_CAPACITY); + let (changes, _) = broadcast::channel(FILE_CHANGE_CHANNEL_CAPACITY); + let scan_cancelled = Arc::new(AtomicBool::new(false)); let task_state = Arc::clone(&state); let task_snapshots = snapshots.clone(); + let task_changes = changes.clone(); + let task_scan_cancelled = Arc::clone(&scan_cancelled); let task = tokio::spawn(async move { - run_manifest_task(root, task_state, task_snapshots).await; + run_manifest_task( + root, + task_state, + task_snapshots, + task_changes, + task_scan_cancelled, + ) + .await; }); Arc::new(Self { state, recent, snapshots, + changes, + scan_cancelled, task, }) } @@ -141,6 +176,21 @@ impl LocalWorkspaceManifest { self.snapshots.subscribe() } + /// Subscribe to debounced, workspace-relative filesystem changes. + pub fn subscribe_changes(&self) -> broadcast::Receiver { + self.changes.subscribe() + } + + /// Stop background discovery without waiting for an in-flight synchronous scan. + /// + /// Hosts with an explicit lifecycle should call this before shutting down + /// their Tokio runtime. [`Drop`] is only a fallback because other background + /// services may retain an `Arc` to the manifest until runtime teardown. + pub fn shutdown(&self) { + self.scan_cancelled.store(true, Ordering::Release); + self.task.abort(); + } + /// Record that a workspace-relative file was opened, read, or written. /// /// This intentionally does not require the initial manifest scan to have @@ -183,7 +233,10 @@ impl LocalWorkspaceManifest { impl Drop for LocalWorkspaceManifest { fn drop(&mut self) { - self.task.abort(); + // Aborting the async owner does not stop synchronous discovery that has + // already begun. Signal the scanner first so a detached traversal stops + // consuming filesystem resources after its host has gone away. + self.shutdown(); } } @@ -685,81 +738,6 @@ impl WorkspaceGitWorktreeProvider for ManifestWorkspaceBackend { } } -async fn run_manifest_task( - root: PathBuf, - state: Arc>, - snapshots: broadcast::Sender, -) { - let (event_tx, mut event_rx) = mpsc::unbounded_channel(); - // Readiness must not depend on the platform watcher service. Watcher - // construction is blocking on some platforms and can be slow or fail - // under resource pressure, while the initial manifest is still useful. - publish_scan(&root, &state, &snapshots).await; - - let watcher_root = root.clone(); - let mut watcher_task = tokio::task::spawn_blocking(move || { - RecommendedWatcher::new( - move |event| { - let _ = event_tx.send(event); - }, - Config::default(), - ) - .and_then(|mut watcher| { - watcher.watch(&watcher_root, RecursiveMode::Recursive)?; - Ok(watcher) - }) - }); - let watcher = loop { - tokio::select! { - result = &mut watcher_task => break result, - _ = tokio::time::sleep(WATCH_STARTUP_SCAN_INTERVAL) => { - // Continue providing a fresh manifest while the platform - // watcher service is slow to initialize. - publish_scan(&root, &state, &snapshots).await; - } - } - }; - let Ok(Ok(_watcher)) = watcher else { - return; - }; - // Close the scan-to-watch registration window: files changed while the - // watcher was being constructed are captured by this second scan. - publish_scan(&root, &state, &snapshots).await; - - while let Some(event) = event_rx.recv().await { - let Ok(event) = event else { - continue; - }; - if !is_relevant_event(&event, &root) { - continue; - } - tokio::time::sleep(WATCH_DEBOUNCE).await; - while let Ok(event) = event_rx.try_recv() { - if let Ok(event) = event { - if !is_relevant_event(&event, &root) { - continue; - } - } - } - publish_scan(&root, &state, &snapshots).await; - } -} - -async fn publish_scan( - root: &Path, - state: &Arc>, - snapshots: &broadcast::Sender, -) { - let root = root.to_path_buf(); - let Ok(files) = tokio::task::spawn_blocking(move || scan_workspace_files(&root)).await else { - return; - }; - let Some(snapshot) = update_state(state, files) else { - return; - }; - let _ = snapshots.send(snapshot); -} - fn update_state( state: &Arc>, files: Vec, diff --git a/core/src/workspace/manifest/scanner.rs b/core/src/workspace/manifest/scanner.rs index 90816500..a67ba33d 100644 --- a/core/src/workspace/manifest/scanner.rs +++ b/core/src/workspace/manifest/scanner.rs @@ -3,35 +3,72 @@ use super::{ normalize_relative_path_lossy, system_time_ms, LocalWorkspaceFile, LocalWorkspaceFileStatus, }; +use crate::language::LanguageCatalog; use ignore::WalkBuilder; use notify::{Event, EventKind}; use std::collections::HashMap; +use std::io::Read; use std::path::{Component, Path, PathBuf}; -use std::process::Command; +use std::process::{Command, ExitStatus, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::Duration; pub fn scan_workspace_files(root: &Path) -> Vec { + let cancelled = AtomicBool::new(false); + scan_workspace_files_cancellable(root, &cancelled).unwrap_or_default() +} + +pub(super) fn scan_workspace_files_cancellable( + root: &Path, + cancelled: &AtomicBool, +) -> Option> { + scan_workspace_files_with(root, || cancelled.load(Ordering::Acquire)) +} + +fn scan_workspace_files_with( + root: &Path, + is_cancelled: impl Fn() -> bool, +) -> Option> { + if is_cancelled() { + return None; + } let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); - let mut files = scan_with_ignore(&root); - if let Some(paths) = git_workspace_paths(&root) { + let mut files = scan_with_ignore(&root, &is_cancelled)?; + if is_cancelled() { + return None; + } + if let Some(paths) = git_workspace_paths(&root, &is_cancelled) { + if is_cancelled() { + return None; + } apply_git_statuses(&root, &mut files, paths); } - sorted_dedup(files) + (!is_cancelled()).then(|| sorted_dedup(files)) } -fn git_workspace_paths(root: &Path) -> Option> { +fn git_workspace_paths( + root: &Path, + is_cancelled: &impl Fn() -> bool, +) -> Option> { let mut out = Vec::new(); let tracked = git_ls_files( root, &["ls-files", "--cached", "--recurse-submodules", "-z"], + is_cancelled, ) - .or_else(|| git_ls_files(root, &["ls-files", "--cached", "-z"]))?; + .or_else(|| git_ls_files(root, &["ls-files", "--cached", "-z"], is_cancelled))?; out.extend( tracked .into_iter() .map(|path| (path, LocalWorkspaceFileStatus::Tracked)), ); - let untracked = git_ls_files(root, &["ls-files", "--others", "--exclude-standard", "-z"]) - .unwrap_or_default(); + let untracked = git_ls_files( + root, + &["ls-files", "--others", "--exclude-standard", "-z"], + is_cancelled, + ) + .unwrap_or_default(); out.extend( untracked .into_iter() @@ -40,19 +77,22 @@ fn git_workspace_paths(root: &Path) -> Option Option> { - let output = Command::new("git") - .arg("-C") - .arg(root) - .args(args) - .output() - .ok()?; - if !output.status.success() { +fn git_ls_files( + root: &Path, + args: &[&str], + is_cancelled: &impl Fn() -> bool, +) -> Option> { + if is_cancelled() { + return None; + } + let mut command = Command::new("git"); + command.arg("-C").arg(root).args(args); + let (status, stdout) = command_stdout_cancellable(command, is_cancelled)?; + if !status.success() { return None; } Some( - output - .stdout + stdout .split(|byte| *byte == 0) .filter(|raw| !raw.is_empty()) .map(|raw| PathBuf::from(String::from_utf8_lossy(raw).into_owned())) @@ -60,6 +100,46 @@ fn git_ls_files(root: &Path, args: &[&str]) -> Option> { ) } +fn command_stdout_cancellable( + mut command: Command, + is_cancelled: &impl Fn() -> bool, +) -> Option<(ExitStatus, Vec)> { + command.stdout(Stdio::piped()).stderr(Stdio::null()); + crate::tools::process::configure_std_process_group(&mut command); + let mut child = command.spawn().ok()?; + let mut process_group = + crate::tools::process::ProcessGroupGuard::for_process_id(Some(child.id())); + let mut stdout = child.stdout.take()?; + let reader = thread::spawn(move || { + let mut bytes = Vec::new(); + stdout.read_to_end(&mut bytes).map(|_| bytes) + }); + + let status = loop { + if is_cancelled() { + process_group.kill(); + let _ = child.kill(); + let _ = child.wait(); + let _ = reader.join(); + return None; + } + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => thread::sleep(Duration::from_millis(10)), + Err(_) => { + process_group.kill(); + let _ = child.kill(); + let _ = child.wait(); + let _ = reader.join(); + return None; + } + } + }; + process_group.disarm(); + let stdout = reader.join().ok()?.ok()?; + Some((status, stdout)) +} + fn apply_git_statuses( root: &Path, files: &mut Vec, @@ -107,9 +187,12 @@ fn preferred_status( } } -fn scan_with_ignore(root: &Path) -> Vec { +fn scan_with_ignore( + root: &Path, + is_cancelled: &impl Fn() -> bool, +) -> Option> { let filter_root = root.to_path_buf(); - WalkBuilder::new(root) + let walker = WalkBuilder::new(root) .hidden(false) .parents(true) .ignore(true) @@ -123,17 +206,27 @@ fn scan_with_ignore(root: &Path) -> Vec { .map(|relative| !path_has_noise_component(relative)) .unwrap_or(true) }) - .build() - .filter_map(Result::ok) - .filter_map(|entry| { - let path = entry.path(); - if path == root { - return None; - } - let relative = path.strip_prefix(root).ok()?; - workspace_file(root, relative, LocalWorkspaceFileStatus::Unknown) - }) - .collect() + .build(); + let mut files = Vec::new(); + for entry in walker { + if is_cancelled() { + return None; + } + let Ok(entry) = entry else { + continue; + }; + let path = entry.path(); + if path == root { + continue; + } + let Some(relative) = path.strip_prefix(root).ok() else { + continue; + }; + if let Some(file) = workspace_file(root, relative, LocalWorkspaceFileStatus::Unknown) { + files.push(file); + } + } + Some(files) } fn workspace_file( @@ -151,7 +244,7 @@ fn workspace_file( return None; } Some(LocalWorkspaceFile { - language: language_for_path(Path::new(&relative)).map(str::to_string), + language: LanguageCatalog::id_for_path(Path::new(&relative)).map(str::to_string), binary: is_binary_file(&full_path, metadata.len()), generated: is_generated_path(Path::new(&relative)), modified_ms: metadata.modified().ok().map(system_time_ms), @@ -179,7 +272,7 @@ fn sorted_dedup(files: Vec) -> Vec { files.sort_by(|a, b| a.path.cmp(&b.path)); files } -fn path_has_noise_component(path: &Path) -> bool { +pub(super) fn path_has_noise_component(path: &Path) -> bool { path.components().any(|component| { let Component::Normal(name) = component else { return false; @@ -203,38 +296,6 @@ fn is_generated_path(path: &Path) -> bool { }) } -fn language_for_path(path: &Path) -> Option<&'static str> { - match path.extension().and_then(|ext| ext.to_str())? { - "rs" => Some("rust"), - "toml" => Some("toml"), - "hcl" => Some("hcl"), - "js" | "mjs" | "cjs" => Some("javascript"), - "jsx" => Some("javascript-react"), - "ts" | "mts" | "cts" => Some("typescript"), - "tsx" => Some("typescript-react"), - "json" => Some("json"), - "md" | "mdx" => Some("markdown"), - "py" => Some("python"), - "go" => Some("go"), - "java" => Some("java"), - "kt" | "kts" => Some("kotlin"), - "swift" => Some("swift"), - "c" | "h" => Some("c"), - "cc" | "cpp" | "cxx" | "hpp" => Some("cpp"), - "cs" => Some("csharp"), - "rb" => Some("ruby"), - "php" => Some("php"), - "sh" | "bash" | "zsh" => Some("shell"), - "yml" | "yaml" => Some("yaml"), - "html" | "htm" => Some("html"), - "css" => Some("css"), - "scss" | "sass" => Some("scss"), - "sql" => Some("sql"), - "xml" => Some("xml"), - _ => None, - } -} - fn is_binary_file(path: &Path, size: u64) -> bool { if matches!( path.extension() @@ -282,7 +343,7 @@ fn is_binary_file(path: &Path, size: u64) -> bool { } fn is_known_text_path(path: &Path) -> bool { - if language_for_path(path).is_some() { + if LanguageCatalog::id_for_path(path).is_some() { return true; } if matches!( @@ -315,3 +376,62 @@ pub(super) fn is_relevant_event(event: &Event, root: &Path) -> bool { .unwrap_or(false) }) } + +#[cfg(test)] +mod cancellation_tests { + use super::*; + use std::cell::Cell; + use std::sync::Arc; + + #[test] + fn cancellable_scan_skips_a_pre_cancelled_workspace() { + let workspace = tempfile::tempdir().unwrap(); + std::fs::write(workspace.path().join("lib.rs"), "fn main() {}\n").unwrap(); + let cancelled = AtomicBool::new(true); + + assert!(scan_workspace_files_cancellable(workspace.path(), &cancelled).is_none()); + } + + #[test] + fn cancellable_scan_stops_during_traversal() { + let workspace = tempfile::tempdir().unwrap(); + for index in 0..32 { + std::fs::write( + workspace.path().join(format!("file-{index}.rs")), + "fn item() {}\n", + ) + .unwrap(); + } + let checks = Cell::new(0_usize); + + let result = scan_workspace_files_with(workspace.path(), || { + checks.set(checks.get() + 1); + checks.get() >= 5 + }); + + assert!(result.is_none()); + assert_eq!(checks.get(), 5); + } + + #[cfg(unix)] + #[test] + fn cancellable_command_kills_a_blocked_process_group() { + let cancelled = Arc::new(AtomicBool::new(false)); + let trigger = Arc::clone(&cancelled); + let cancel_task = thread::spawn(move || { + thread::sleep(Duration::from_millis(50)); + trigger.store(true, Ordering::Release); + }); + let mut command = Command::new("sh"); + // Keep a shell leader and a separate descendant alive so the test + // fails if cancellation kills only the direct child. + command.args(["-c", "sleep 30 & wait"]); + let started = std::time::Instant::now(); + + let output = command_stdout_cancellable(command, &|| cancelled.load(Ordering::Acquire)); + + cancel_task.join().unwrap(); + assert!(output.is_none()); + assert!(started.elapsed() < Duration::from_secs(2)); + } +} diff --git a/core/src/workspace/manifest/tests.rs b/core/src/workspace/manifest/tests.rs index 9c411227..ffb3981e 100644 --- a/core/src/workspace/manifest/tests.rs +++ b/core/src/workspace/manifest/tests.rs @@ -1,4 +1,10 @@ +use super::watcher::normalize_file_changes; use super::*; +use notify::{ + event::{ModifyKind, RenameMode}, + Event, EventKind, +}; +use std::collections::HashSet; use std::process::Command; fn write(path: &Path, body: &[u8]) { @@ -237,6 +243,148 @@ async fn manifest_refreshes_after_file_event() { assert!(updated.version > initial.version); } +#[test] +fn file_change_batch_normalizes_and_merges_duplicate_events() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let created = root.join("src/new.rs"); + let changed = root.join("src/lib.rs"); + let ephemeral = root.join("src/ephemeral.rs"); + let events = vec![ + Event::new(EventKind::Create(notify::event::CreateKind::File)).add_path(created.clone()), + Event::new(EventKind::Modify(ModifyKind::Any)).add_path(created.clone()), + Event::new(EventKind::Modify(ModifyKind::Any)).add_path(changed.clone()), + Event::new(EventKind::Modify(ModifyKind::Any)).add_path(changed), + Event::new(EventKind::Create(notify::event::CreateKind::File)).add_path(ephemeral.clone()), + Event::new(EventKind::Remove(notify::event::RemoveKind::File)).add_path(ephemeral), + ]; + + let changes = normalize_file_changes(&root, &events, &HashSet::new()); + + assert_eq!( + changes, + vec![ + WorkspaceFileChange { + path: WorkspacePath::from_normalized("src/new.rs"), + kind: WorkspaceFileChangeKind::Created, + }, + WorkspaceFileChange { + path: WorkspacePath::from_normalized("src/lib.rs"), + kind: WorkspaceFileChangeKind::Changed, + }, + ] + ); +} + +#[test] +fn file_change_batch_treats_create_for_known_file_as_change() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let existing = root.join("src/lib.rs"); + let events = + vec![Event::new(EventKind::Create(notify::event::CreateKind::File)).add_path(existing)]; + let known_paths = HashSet::from(["src/lib.rs".to_string()]); + + assert_eq!( + normalize_file_changes(&root, &events, &known_paths), + vec![WorkspaceFileChange { + path: WorkspacePath::from_normalized("src/lib.rs"), + kind: WorkspaceFileChangeKind::Changed, + }] + ); +} + +#[test] +fn file_change_batch_reports_create_delete_and_rename_in_order() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let created = root.join("src/created.rs"); + let deleted = root.join("src/deleted.rs"); + let rename_from = root.join("src/old.rs"); + let rename_to = root.join("src/new.rs"); + let events = vec![ + Event::new(EventKind::Create(notify::event::CreateKind::File)).add_path(created), + Event::new(EventKind::Remove(notify::event::RemoveKind::File)).add_path(deleted), + Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::Both))) + .add_path(rename_from) + .add_path(rename_to), + ]; + + let known_paths = HashSet::from(["src/new.rs".to_string()]); + let changes = normalize_file_changes(&root, &events, &known_paths); + + assert_eq!( + changes, + vec![ + WorkspaceFileChange { + path: WorkspacePath::from_normalized("src/created.rs"), + kind: WorkspaceFileChangeKind::Created, + }, + WorkspaceFileChange { + path: WorkspacePath::from_normalized("src/deleted.rs"), + kind: WorkspaceFileChangeKind::Deleted, + }, + WorkspaceFileChange { + path: WorkspacePath::from_normalized("src/old.rs"), + kind: WorkspaceFileChangeKind::Deleted, + }, + WorkspaceFileChange { + path: WorkspacePath::from_normalized("src/new.rs"), + kind: WorkspaceFileChangeKind::Created, + }, + ] + ); +} + +#[test] +fn file_change_batch_ignores_external_and_invalid_paths() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let outside = root.parent().unwrap().join("outside.rs"); + let invalid = root.join("../escape.rs"); + let ignored = root.join("node_modules/package/index.js"); + let events = vec![ + Event::new(EventKind::Modify(ModifyKind::Any)).add_path(outside), + Event::new(EventKind::Modify(ModifyKind::Any)).add_path(invalid), + Event::new(EventKind::Modify(ModifyKind::Any)).add_path(ignored), + Event::new(EventKind::Modify(ModifyKind::Any)).add_path(root.clone()), + ]; + + assert!(normalize_file_changes(&root, &events, &HashSet::new()).is_empty()); +} + +#[tokio::test] +async fn manifest_change_subscription_reports_same_size_content_changes() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("src/lib.rs"); + write(&path, b"aaaa\n"); + let manifest = LocalWorkspaceManifest::start(temp.path()); + let mut snapshots = manifest.subscribe(); + let mut changes = manifest.subscribe_changes(); + tokio::time::timeout(Duration::from_secs(5), snapshots.recv()) + .await + .unwrap() + .unwrap(); + + let received = tokio::time::timeout(Duration::from_secs(10), async { + let contents = [b"bbbb\n".as_slice(), b"cccc\n".as_slice()]; + let mut attempt = 0; + loop { + assert!(attempt < 20, "no content change was observed"); + write(&path, contents[attempt % contents.len()]); + attempt += 1; + match tokio::time::timeout(Duration::from_millis(400), changes.recv()).await { + Ok(Ok(change)) if change.path.as_str() == "src/lib.rs" => break change, + Ok(Ok(_)) | Ok(Err(_)) | Err(_) => {} + } + } + }) + .await + .unwrap(); + + assert_eq!(received.kind, WorkspaceFileChangeKind::Changed); +} + #[tokio::test] async fn manifest_search_falls_back_before_initial_scan() { let temp = tempfile::tempdir().unwrap(); diff --git a/core/src/workspace/manifest/watcher.rs b/core/src/workspace/manifest/watcher.rs new file mode 100644 index 00000000..ae0178c2 --- /dev/null +++ b/core/src/workspace/manifest/watcher.rs @@ -0,0 +1,329 @@ +//! Filesystem watcher event batching and normalization. + +use super::scanner::path_has_noise_component; +use super::scanner::scan_workspace_files_cancellable; +use super::{ + is_relevant_event, normalize_relative_path_lossy, update_state, LocalWorkspaceManifestSnapshot, + ManifestState, WorkspaceFileChange, WorkspaceFileChangeKind, WorkspacePath, +}; +use notify::{ + event::{ModifyKind, RenameMode}, + Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher, +}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, RwLock, +}; +use std::thread; +use std::time::Duration; +use tokio::sync::{broadcast, mpsc, oneshot}; + +const WATCH_DEBOUNCE: Duration = Duration::from_millis(150); +const WATCH_STARTUP_SCAN_INTERVAL: Duration = Duration::from_secs(1); +const WATCH_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(50); + +pub(super) async fn run_manifest_task( + root: PathBuf, + state: Arc>, + snapshots: broadcast::Sender, + changes: broadcast::Sender, + scan_cancelled: Arc, +) { + let (event_tx, mut event_rx) = mpsc::unbounded_channel(); + // Readiness must not depend on the platform watcher service. Watcher + // construction is blocking on some platforms and can be slow or fail + // under resource pressure, while the initial manifest is still useful. + publish_scan(&root, &state, &snapshots, &scan_cancelled).await; + if scan_cancelled.load(Ordering::Acquire) { + return; + } + + let watcher_root = root.clone(); + let watcher_cancelled = Arc::clone(&scan_cancelled); + let (watcher_tx, mut watcher_rx) = oneshot::channel(); + // Watcher construction and recursive registration are synchronous platform + // calls with no cancellation API. Keep them outside Tokio's blocking pool: + // dropping a runtime waits for every spawn_blocking job, while a detached + // setup thread cannot hold runtime teardown hostage. + let _ = thread::Builder::new() + .name("a3s-workspace-watcher".to_owned()) + .spawn(move || { + if watcher_cancelled.load(Ordering::Acquire) { + return; + } + let watcher = RecommendedWatcher::new( + move |event| { + let _ = event_tx.send(event); + }, + Config::default(), + ) + .and_then(|mut watcher| { + if watcher_cancelled.load(Ordering::Acquire) { + return Ok(watcher); + } + watcher.watch(&watcher_root, RecursiveMode::Recursive)?; + Ok(watcher) + }); + match watcher { + Ok(watcher) => { + if watcher_cancelled.load(Ordering::Acquire) { + return; + } + if watcher_tx.send(Ok(())).is_err() { + return; + } + // Keep construction, registration, ownership, and Drop on + // this detached thread. On macOS, dropping an FSEvents + // watcher can join its platform thread and must never hold + // a Tokio worker or runtime teardown hostage. + while !watcher_cancelled.load(Ordering::Acquire) { + thread::park_timeout(WATCH_SHUTDOWN_POLL_INTERVAL); + } + drop(watcher); + } + Err(error) => { + let _ = watcher_tx.send(Err(error)); + } + } + }); + let watcher_ready = loop { + tokio::select! { + result = &mut watcher_rx => break result, + _ = tokio::time::sleep(WATCH_STARTUP_SCAN_INTERVAL) => { + // Continue providing a fresh manifest while the platform + // watcher service is slow to initialize. + publish_scan(&root, &state, &snapshots, &scan_cancelled).await; + } + } + }; + let Ok(Ok(())) = watcher_ready else { + return; + }; + // Close the scan-to-watch registration window: files changed while the + // watcher was being constructed are captured by this second scan. + publish_scan(&root, &state, &snapshots, &scan_cancelled).await; + + while let Some(event) = event_rx.recv().await { + let Ok(event) = event else { + continue; + }; + if !is_relevant_event(&event, &root) { + continue; + } + let mut events = vec![event]; + tokio::time::sleep(WATCH_DEBOUNCE).await; + while let Ok(event) = event_rx.try_recv() { + if let Ok(event) = event { + if !is_relevant_event(&event, &root) { + continue; + } + events.push(event); + } + } + let known_paths = state + .read() + .map(|state| state.index.by_path.keys().cloned().collect::>()) + .unwrap_or_default(); + let file_changes = normalize_file_changes(&root, &events, &known_paths); + publish_scan(&root, &state, &snapshots, &scan_cancelled).await; + for change in file_changes { + let _ = changes.send(change); + } + } +} + +pub(super) fn normalize_file_changes( + root: &Path, + events: &[Event], + known_paths: &HashSet, +) -> Vec { + let mut changes = Vec::new(); + for event in events { + match event.kind { + EventKind::Access(_) => {} + EventKind::Create(_) => { + push_created_event_paths(&mut changes, root, &event.paths, known_paths); + } + EventKind::Remove(_) => { + push_event_paths( + &mut changes, + root, + &event.paths, + WorkspaceFileChangeKind::Deleted, + ); + } + EventKind::Modify(ModifyKind::Name(mode)) => { + push_rename_changes(&mut changes, root, &event.paths, mode); + } + EventKind::Modify(_) | EventKind::Any | EventKind::Other => { + push_event_paths( + &mut changes, + root, + &event.paths, + WorkspaceFileChangeKind::Changed, + ); + } + } + } + changes +} + +fn push_rename_changes( + changes: &mut Vec, + root: &Path, + paths: &[PathBuf], + mode: RenameMode, +) { + match mode { + RenameMode::Both if paths.len() >= 2 => { + push_file_change(changes, root, &paths[0], WorkspaceFileChangeKind::Deleted); + push_file_change( + changes, + root, + &paths[paths.len() - 1], + WorkspaceFileChangeKind::Created, + ); + } + RenameMode::From => { + push_event_paths(changes, root, paths, WorkspaceFileChangeKind::Deleted) + } + RenameMode::To => push_event_paths(changes, root, paths, WorkspaceFileChangeKind::Created), + RenameMode::Any | RenameMode::Other if paths.len() >= 2 => { + push_file_change(changes, root, &paths[0], WorkspaceFileChangeKind::Deleted); + push_file_change( + changes, + root, + &paths[paths.len() - 1], + WorkspaceFileChangeKind::Created, + ); + } + RenameMode::Any | RenameMode::Other => { + push_event_paths(changes, root, paths, WorkspaceFileChangeKind::Changed) + } + RenameMode::Both => {} + } +} + +fn push_created_event_paths( + changes: &mut Vec, + root: &Path, + paths: &[PathBuf], + known_paths: &HashSet, +) { + for path in paths { + let Some(path) = normalize_event_path(root, path) else { + continue; + }; + let kind = if known_paths.contains(path.as_str()) + && !changes.iter().any(|change| { + change.path == path && change.kind == WorkspaceFileChangeKind::Deleted + }) { + WorkspaceFileChangeKind::Changed + } else { + WorkspaceFileChangeKind::Created + }; + push_normalized_file_change(changes, path, kind); + } +} + +fn push_event_paths( + changes: &mut Vec, + root: &Path, + paths: &[PathBuf], + kind: WorkspaceFileChangeKind, +) { + for path in paths { + push_file_change(changes, root, path, kind); + } +} + +fn push_file_change( + changes: &mut Vec, + root: &Path, + path: &Path, + kind: WorkspaceFileChangeKind, +) { + let Some(path) = normalize_event_path(root, path) else { + return; + }; + push_normalized_file_change(changes, path, kind); +} + +fn push_normalized_file_change( + changes: &mut Vec, + path: WorkspacePath, + kind: WorkspaceFileChangeKind, +) { + let Some(existing_index) = changes.iter().position(|change| change.path == path) else { + changes.push(WorkspaceFileChange { path, kind }); + return; + }; + let existing = changes[existing_index].kind; + match merge_file_change_kinds(existing, kind) { + Some(kind) => changes[existing_index].kind = kind, + None => { + changes.remove(existing_index); + } + } +} + +fn normalize_event_path(root: &Path, path: &Path) -> Option { + let relative = path.strip_prefix(root).ok()?; + if path_has_noise_component(relative) { + return None; + } + let normalized = normalize_relative_path_lossy(relative)?; + (!normalized.is_empty()).then(|| WorkspacePath::from_normalized(normalized)) +} + +fn merge_file_change_kinds( + existing: WorkspaceFileChangeKind, + incoming: WorkspaceFileChangeKind, +) -> Option { + use WorkspaceFileChangeKind::{Changed, Created, Deleted}; + + match (existing, incoming) { + (Created, Deleted) => None, + (Deleted, Created) => Some(Changed), + (Created, _) => Some(Created), + (Changed, Deleted) => Some(Deleted), + (Changed, _) => Some(Changed), + (Deleted, _) => Some(Deleted), + } +} + +async fn publish_scan( + root: &Path, + state: &Arc>, + snapshots: &broadcast::Sender, + scan_cancelled: &Arc, +) { + let root = root.to_path_buf(); + let scan_cancelled_for_task = Arc::clone(scan_cancelled); + let (scan_tx, scan_rx) = oneshot::channel(); + // Filesystem traversal can block inside one platform call even after the + // cooperative flag is set. A detached standard thread keeps that call out + // of Tokio's blocking pool, so runtime teardown never waits for it. + if thread::Builder::new() + .name("a3s-workspace-scan".to_owned()) + .spawn(move || { + let files = scan_workspace_files_cancellable(&root, &scan_cancelled_for_task); + let _ = scan_tx.send(files); + }) + .is_err() + { + return; + } + let Ok(Some(files)) = scan_rx.await else { + return; + }; + if scan_cancelled.load(Ordering::Acquire) { + return; + } + let Some(snapshot) = update_state(state, files) else { + return; + }; + let _ = snapshots.send(snapshot); +} diff --git a/core/src/workspace/mod.rs b/core/src/workspace/mod.rs index 36f8c40e..bdf205f1 100644 --- a/core/src/workspace/mod.rs +++ b/core/src/workspace/mod.rs @@ -16,12 +16,14 @@ mod path; mod remote_git; #[cfg(feature = "s3")] mod s3; +mod services; pub use error::{WorkspaceError, WorkspaceResult}; pub use local::LocalWorkspaceBackend; pub use manifest::{ scan_workspace_files, LocalWorkspaceFile, LocalWorkspaceFileStatus, LocalWorkspaceManifest, LocalWorkspaceManifestSnapshot, ManifestWorkspaceBackend, RecentWorkspaceFile, + WorkspaceFileChange, WorkspaceFileChangeKind, }; pub(crate) use path::validate_relative_pattern; pub use path::VirtualPathResolver; @@ -32,11 +34,11 @@ use path::{ pub use remote_git::{RemoteGitBackend, RemoteGitBackendConfig, RemoteGitConflict}; #[cfg(feature = "s3")] pub use s3::{S3BackendConfig, S3WorkspaceBackend}; +pub use services::{WorkspaceServices, WorkspaceServicesBuilder}; -use anyhow::{anyhow, Result}; +use anyhow::Result; use async_trait::async_trait; use std::collections::HashMap; -use std::path::{Path, PathBuf}; use std::sync::Arc; /// Identity and display metadata for a workspace. @@ -548,477 +550,6 @@ pub trait WorkspaceGitWorktreeProvider: Send + Sync { ) -> Result; } -/// The host-provided workspace capability bundle used by tool execution. -pub struct WorkspaceServices { - workspace_ref: WorkspaceRef, - capabilities: WorkspaceCapabilities, - path_resolver: Arc, - file_system: Arc, - file_system_ext: Option>, - text_reader: Option>, - command_runner: Option>, - search: Option>, - git: Option>, - git_stash: Option>, - git_worktree: Option>, - /// Default timeout applied to non-bash workspace operations (file system, - /// search, git). Bash uses its own per-call timeout in [`CommandRequest`]. - /// `None` means no enforced timeout — appropriate for the local backend. - operation_timeout: Option, - local_root: Option, -} - -impl std::fmt::Debug for WorkspaceServices { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("WorkspaceServices") - .field("workspace_ref", &self.workspace_ref) - .field("capabilities", &self.capabilities) - .field("file_system_ext", &self.file_system_ext.is_some()) - .field("text_reader", &self.text_reader.is_some()) - .field("command_runner", &self.command_runner.is_some()) - .field("search", &self.search.is_some()) - .field("git", &self.git.is_some()) - .field("git_stash", &self.git_stash.is_some()) - .field("git_worktree", &self.git_worktree.is_some()) - .field("local_root", &self.local_root) - .finish() - } -} - -impl WorkspaceServices { - pub(crate) fn new_with_git( - workspace_ref: WorkspaceRef, - mut capabilities: WorkspaceCapabilities, - path_resolver: Arc, - file_system: Arc, - command_runner: Option>, - search: Option>, - git: Option>, - ) -> Self { - if command_runner.is_none() { - capabilities.exec = false; - } - if search.is_none() { - capabilities.search = false; - } - if git.is_none() { - capabilities.git = false; - } - Self { - workspace_ref, - capabilities, - path_resolver, - file_system, - file_system_ext: None, - text_reader: None, - command_runner, - search, - git, - git_stash: None, - git_worktree: None, - operation_timeout: None, - local_root: None, - } - } - - pub fn builder( - workspace_ref: WorkspaceRef, - file_system: Arc, - ) -> WorkspaceServicesBuilder { - WorkspaceServicesBuilder::new(workspace_ref, file_system) - } - - pub fn local(root: impl Into) -> Arc { - let backend = Arc::new(LocalWorkspaceBackend::new(root.into())); - let workspace_ref = WorkspaceRef::new( - backend.root.display().to_string(), - backend.root.display().to_string(), - ); - let path_resolver: Arc = backend.clone(); - let file_system: Arc = backend.clone(); - let text_reader: Arc = backend.clone(); - let command_runner: Arc = backend.clone(); - let search: Arc = backend.clone(); - let git: Arc = backend.clone(); - let git_stash: Arc = backend.clone(); - let git_worktree: Arc = backend.clone(); - Arc::new(Self { - workspace_ref, - capabilities: WorkspaceCapabilities::local_default(), - path_resolver, - file_system, - file_system_ext: None, - text_reader: Some(text_reader), - command_runner: Some(command_runner), - search: Some(search), - git: Some(git), - git_stash: Some(git_stash), - git_worktree: Some(git_worktree), - operation_timeout: None, - local_root: Some(backend.root.clone()), - }) - } - - /// Local workspace services backed by an in-memory file manifest for - /// search. `read`/`write`/`ls`/`bash`/`git` preserve local backend - /// behavior; `glob` and `grep` use the manifest once the initial scan has - /// completed and fall back to filesystem search before that. - pub fn local_with_manifest(root: impl Into) -> Arc { - let backend = ManifestWorkspaceBackend::new(root); - Self::local_with_manifest_backend(backend) - } - - /// Build local workspace services from a shared manifest backend. Hosts - /// can keep the same manifest for UI file pickers and agent tools. - pub fn local_with_manifest_backend(backend: Arc) -> Arc { - let workspace_ref = WorkspaceRef::new( - backend.local_root().display().to_string(), - backend.local_root().display().to_string(), - ); - let path_resolver: Arc = backend.clone(); - let file_system: Arc = backend.clone(); - let text_reader: Arc = backend.clone(); - let command_runner: Arc = backend.clone(); - let search: Arc = backend.clone(); - let git: Arc = backend.clone(); - let git_stash: Arc = backend.clone(); - let git_worktree: Arc = backend.clone(); - Arc::new(Self { - workspace_ref, - capabilities: WorkspaceCapabilities::local_default(), - path_resolver, - file_system, - file_system_ext: None, - text_reader: Some(text_reader), - command_runner: Some(command_runner), - search: Some(search), - git: Some(git), - git_stash: Some(git_stash), - git_worktree: Some(git_worktree), - operation_timeout: None, - local_root: Some(backend.local_root().to_path_buf()), - }) - } - - pub fn workspace_ref(&self) -> &WorkspaceRef { - &self.workspace_ref - } - - pub fn capabilities(&self) -> WorkspaceCapabilities { - self.capabilities - } - - pub fn normalize_path(&self, input: &str) -> Result { - self.path_resolver.normalize(input) - } - - pub fn fs(&self) -> Arc { - Arc::clone(&self.file_system) - } - - /// Optional compare-and-swap file system extensions. - /// - /// Returns `Some` when the backend supports version-aware writes (e.g. - /// S3 via ETag). Tools that perform read-modify-write cycles should - /// route through [`Self::read_for_edit`] and [`Self::write_for_edit`] - /// rather than touching this directly. - pub fn fs_ext(&self) -> Option> { - self.file_system_ext.clone() - } - - pub fn text_reader(&self) -> Option> { - self.text_reader.clone() - } - - pub fn command_runner(&self) -> Option> { - self.command_runner.clone() - } - - pub fn search(&self) -> Option> { - self.search.clone() - } - - pub fn git(&self) -> Option> { - self.git.clone() - } - - pub fn git_stash(&self) -> Option> { - self.git_stash.clone() - } - - pub fn git_worktree(&self) -> Option> { - self.git_worktree.clone() - } - - /// Internal helper used by decorators (`with_remote_git` and any - /// future git-provider override) to swap the git layer of an existing - /// `WorkspaceServices` without losing unrelated fields. - /// - /// Every field is **explicitly listed** in the returned struct - /// literal. This is the point of the helper — adding a new field to - /// `WorkspaceServices` will trip a compile error here, and the author - /// of that new field has to decide whether a git-provider swap - /// preserves it. Previously the decorator went through - /// `WorkspaceServicesBuilder`, which silently dropped any field the - /// builder did not know about (notably `local_root`). - /// - /// `git_worktree` is reset to `None` because worktree operations are - /// part of the same domain as the git provider — keeping the local - /// worktree provider while routing `status`/`log`/`diff` to a remote - /// server would surface inconsistent state to the model. - pub(crate) fn with_git_provider( - &self, - git: Arc, - git_stash: Option>, - ) -> Arc { - let mut capabilities = self.capabilities; - capabilities.git = true; - Arc::new(Self { - workspace_ref: self.workspace_ref.clone(), - capabilities, - path_resolver: Arc::clone(&self.path_resolver), - file_system: Arc::clone(&self.file_system), - file_system_ext: self.file_system_ext.clone(), - text_reader: self.text_reader.clone(), - command_runner: self.command_runner.clone(), - search: self.search.clone(), - git: Some(git), - git_stash, - git_worktree: None, - operation_timeout: self.operation_timeout, - local_root: self.local_root.clone(), - }) - } - - /// Default timeout applied to non-bash workspace operations. - /// - /// `None` means no enforced timeout. Backends that may stall (remote, - /// browser, DFS) should set this so tools using [`Self::run_with_timeout`] - /// surface a timeout error instead of letting the agent loop hang. - pub fn operation_timeout(&self) -> Option { - self.operation_timeout - } - - /// Run a workspace future under the configured operation timeout. - /// - /// Tools that route through file system / search / git providers should - /// wrap their calls with this helper so non-local backends never stall - /// the agent loop indefinitely. - /// - /// Polymorphic in the error type so the helper works equally well for - /// futures returning `anyhow::Result` (the legacy callers — search, - /// git, etc.) and for futures returning [`WorkspaceResult`] (the - /// migrated `WorkspaceFileSystem` callers). The `E: From` - /// bound is satisfied by both `anyhow::Error` (trivially) and - /// [`WorkspaceError`] (via its `#[from]` `Backend` variant); a timeout - /// surfaces as that From conversion of an `anyhow!(...)` message. - pub async fn run_with_timeout( - &self, - op: &'static str, - fut: F, - ) -> std::result::Result - where - F: std::future::Future>, - E: From, - { - match self.operation_timeout { - Some(d) => tokio::time::timeout(d, fut).await.map_err(|_| { - E::from(anyhow!( - "workspace operation '{}' timed out after {:?}", - op, - d - )) - })?, - None => fut.await, - } - } - - /// Read a file for a subsequent modify-write cycle, requesting a version - /// token when the backend supports compare-and-swap writes. - /// - /// Returns `(content, Some(version))` when [`Self::fs_ext`] is available - /// (e.g. on S3, where the version is the object ETag); `(content, None)` - /// otherwise. Pair with [`Self::write_for_edit`]. - pub async fn read_for_edit( - &self, - path: &WorkspacePath, - ) -> WorkspaceResult<(String, Option)> { - if let Some(ext) = self.fs_ext() { - let path = path.clone(); - return self - .run_with_timeout("read_text_with_version", async move { - let (content, version) = ext.read_text_with_version(&path).await?; - Ok((content, Some(version))) - }) - .await; - } - let fs = self.fs(); - let path_owned = path.clone(); - let content = self - .run_with_timeout("read_text", async move { fs.read_text(&path_owned).await }) - .await?; - Ok((content, None)) - } - - /// Companion to [`Self::read_for_edit`]. Performs a compare-and-swap - /// write when both [`Self::fs_ext`] is available *and* a version token - /// was returned by the prior read; falls back to a plain write - /// otherwise. On version mismatch the returned error is the typed - /// [`WorkspaceError::VersionConflict`] variant; callers can also still - /// downcast `anyhow::Error::downcast_ref::()` - /// when the value has been lifted into an `anyhow::Result`. - pub async fn write_for_edit( - &self, - path: &WorkspacePath, - content: &str, - expected_version: Option<&str>, - ) -> WorkspaceResult { - if let (Some(ext), Some(version)) = (self.fs_ext(), expected_version) { - let path = path.clone(); - let content = content.to_string(); - let expected = version.to_string(); - return self - .run_with_timeout("write_text_if_version", async move { - ext.write_text_if_version(&path, &content, &expected).await - }) - .await; - } - let fs = self.fs(); - let path = path.clone(); - let content = content.to_string(); - self.run_with_timeout( - "write_text", - async move { fs.write_text(&path, &content).await }, - ) - .await - } - - pub fn local_root(&self) -> Option<&Path> { - self.local_root.as_deref() - } - - pub fn display_path(&self, path: &WorkspacePath) -> String { - if path.is_root() { - return self.workspace_ref.display_root.clone(); - } - - let root = self.workspace_ref.display_root.trim_end_matches('/'); - if root.is_empty() { - path.as_str().to_string() - } else { - format!("{root}/{}", path.as_str()) - } - } -} - -/// Builder for assembling workspace services without constructor arity churn. -pub struct WorkspaceServicesBuilder { - workspace_ref: WorkspaceRef, - capabilities: WorkspaceCapabilities, - path_resolver: Arc, - file_system: Arc, - file_system_ext: Option>, - text_reader: Option>, - command_runner: Option>, - search: Option>, - git: Option>, - git_stash: Option>, - git_worktree: Option>, - operation_timeout: Option, -} - -impl WorkspaceServicesBuilder { - pub fn new(workspace_ref: WorkspaceRef, file_system: Arc) -> Self { - Self { - workspace_ref, - capabilities: WorkspaceCapabilities::read_write(), - path_resolver: Arc::new(VirtualPathResolver), - file_system, - file_system_ext: None, - text_reader: None, - command_runner: None, - search: None, - git: None, - git_stash: None, - git_worktree: None, - operation_timeout: None, - } - } - - pub fn capabilities(mut self, capabilities: WorkspaceCapabilities) -> Self { - self.capabilities = capabilities; - self - } - - pub fn command_runner(mut self, command_runner: Arc) -> Self { - self.capabilities.exec = true; - self.command_runner = Some(command_runner); - self - } - - pub fn search(mut self, search: Arc) -> Self { - self.capabilities.search = true; - self.search = Some(search); - self - } - - pub fn git(mut self, git: Arc) -> Self { - self.capabilities.git = true; - self.git = Some(git); - self - } - - pub fn git_stash(mut self, git_stash: Arc) -> Self { - self.git_stash = Some(git_stash); - self - } - - pub fn git_worktree(mut self, git_worktree: Arc) -> Self { - self.git_worktree = Some(git_worktree); - self - } - - /// Attach optional compare-and-swap file system extensions - /// ([`WorkspaceFileSystemExt`]). Tools that perform read-modify-write - /// cycles will pick this up via [`WorkspaceServices::read_for_edit`] - /// and [`WorkspaceServices::write_for_edit`]. - pub fn file_system_ext(mut self, ext: Arc) -> Self { - self.file_system_ext = Some(ext); - self - } - - pub fn text_reader(mut self, reader: Arc) -> Self { - self.text_reader = Some(reader); - self - } - - /// Apply a default timeout to non-bash workspace operations (file system, - /// search, git). Backends that may stall — remote, browser, DFS — should - /// set this so tools surface a timeout error rather than hanging. - pub fn operation_timeout(mut self, timeout: std::time::Duration) -> Self { - self.operation_timeout = Some(timeout); - self - } - - pub fn build(self) -> Arc { - let mut services = WorkspaceServices::new_with_git( - self.workspace_ref, - self.capabilities, - self.path_resolver, - self.file_system, - self.command_runner, - self.search, - self.git, - ); - services.file_system_ext = self.file_system_ext; - services.text_reader = self.text_reader; - services.git_stash = self.git_stash; - services.git_worktree = self.git_worktree; - services.operation_timeout = self.operation_timeout; - Arc::new(services) - } -} - #[cfg(test)] #[path = "tests.rs"] mod tests; diff --git a/core/src/workspace/remote_git.rs b/core/src/workspace/remote_git.rs index e10ef861..ee739fab 100644 --- a/core/src/workspace/remote_git.rs +++ b/core/src/workspace/remote_git.rs @@ -817,7 +817,7 @@ fn emit_remote_git_event( } impl super::WorkspaceServices { - /// Attach a remote git provider to an existing [`WorkspaceServices`]. + /// Attach a remote git provider to an existing [`super::WorkspaceServices`]. /// /// Returns a new `Arc` with `git` and `git_stash` /// wired to the remote backend. The original `WorkspaceServices` is @@ -826,8 +826,7 @@ impl super::WorkspaceServices { /// onto a remote service (see RFC §8). All other fields — including /// `local_root`, the command runner, the search provider, the /// optional `file_system_ext` (S3 CAS), and `operation_timeout` — are - /// preserved verbatim via - /// [`super::WorkspaceServices::with_git_provider`]. + /// preserved verbatim via the internal `with_git_provider` constructor. pub fn with_remote_git(self: Arc, config: RemoteGitBackendConfig) -> Result> { let backend = RemoteGitBackend::new(config)?; let git: Arc = backend.clone(); diff --git a/core/src/workspace/services.rs b/core/src/workspace/services.rs new file mode 100644 index 00000000..eeebe06c --- /dev/null +++ b/core/src/workspace/services.rs @@ -0,0 +1,554 @@ +//! Workspace service aggregation and builder. + +#[allow(unused_imports)] +use super::{CommandRequest, WorkspaceError, WorkspaceVersionConflict}; +use super::{ + LocalWorkspaceBackend, ManifestWorkspaceBackend, VirtualPathResolver, WorkspaceCapabilities, + WorkspaceCommandRunner, WorkspaceFileSystem, WorkspaceFileSystemExt, WorkspaceGit, + WorkspaceGitStashProvider, WorkspaceGitWorktreeProvider, WorkspacePath, WorkspacePathResolver, + WorkspaceRef, WorkspaceResult, WorkspaceSearch, WorkspaceTextReader, WorkspaceWriteOutcome, +}; +use crate::code_intelligence::{LocalCodeIntelligence, WorkspaceCodeIntelligence}; +use anyhow::{anyhow, Result}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// The host-provided workspace capability bundle used by tool execution. +pub struct WorkspaceServices { + workspace_ref: WorkspaceRef, + capabilities: WorkspaceCapabilities, + path_resolver: Arc, + file_system: Arc, + file_system_ext: Option>, + text_reader: Option>, + command_runner: Option>, + search: Option>, + code_intelligence: Option>, + git: Option>, + git_stash: Option>, + git_worktree: Option>, + /// Default timeout applied to non-bash workspace operations (file system, + /// search, git). Bash uses its own per-call timeout in [`CommandRequest`]. + /// `None` means no enforced timeout — appropriate for the local backend. + operation_timeout: Option, + local_root: Option, +} + +impl std::fmt::Debug for WorkspaceServices { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WorkspaceServices") + .field("workspace_ref", &self.workspace_ref) + .field("capabilities", &self.capabilities) + .field("file_system_ext", &self.file_system_ext.is_some()) + .field("text_reader", &self.text_reader.is_some()) + .field("command_runner", &self.command_runner.is_some()) + .field("search", &self.search.is_some()) + .field("code_intelligence", &self.code_intelligence.is_some()) + .field("git", &self.git.is_some()) + .field("git_stash", &self.git_stash.is_some()) + .field("git_worktree", &self.git_worktree.is_some()) + .field("local_root", &self.local_root) + .finish() + } +} + +impl WorkspaceServices { + pub(crate) fn new_with_git( + workspace_ref: WorkspaceRef, + mut capabilities: WorkspaceCapabilities, + path_resolver: Arc, + file_system: Arc, + command_runner: Option>, + search: Option>, + git: Option>, + ) -> Self { + if command_runner.is_none() { + capabilities.exec = false; + } + if search.is_none() { + capabilities.search = false; + } + if git.is_none() { + capabilities.git = false; + } + Self { + workspace_ref, + capabilities, + path_resolver, + file_system, + file_system_ext: None, + text_reader: None, + command_runner, + search, + code_intelligence: None, + git, + git_stash: None, + git_worktree: None, + operation_timeout: None, + local_root: None, + } + } + + pub fn builder( + workspace_ref: WorkspaceRef, + file_system: Arc, + ) -> WorkspaceServicesBuilder { + WorkspaceServicesBuilder::new(workspace_ref, file_system) + } + + pub fn local(root: impl Into) -> Arc { + let backend = Arc::new(LocalWorkspaceBackend::new(root.into())); + let workspace_ref = WorkspaceRef::new( + backend.root.display().to_string(), + backend.root.display().to_string(), + ); + let path_resolver: Arc = backend.clone(); + let file_system: Arc = backend.clone(); + let text_reader: Arc = backend.clone(); + let command_runner: Arc = backend.clone(); + let search: Arc = backend.clone(); + let git: Arc = backend.clone(); + let git_stash: Arc = backend.clone(); + let git_worktree: Arc = backend.clone(); + Arc::new(Self { + workspace_ref, + capabilities: WorkspaceCapabilities::local_default(), + path_resolver, + file_system, + file_system_ext: None, + text_reader: Some(text_reader), + command_runner: Some(command_runner), + search: Some(search), + code_intelligence: None, + git: Some(git), + git_stash: Some(git_stash), + git_worktree: Some(git_worktree), + operation_timeout: None, + local_root: Some(backend.root.clone()), + }) + } + + /// Local workspace services backed by an in-memory file manifest for + /// search. `read`/`write`/`ls`/`bash`/`git` preserve local backend + /// behavior; `glob` and `grep` use the manifest once the initial scan has + /// completed and fall back to filesystem search before that. + pub fn local_with_manifest(root: impl Into) -> Arc { + let backend = ManifestWorkspaceBackend::new(root); + Self::local_with_manifest_backend(backend) + } + + /// Build local manifest-backed services with native Code Intelligence. + /// + /// The provider subscribes to the manifest's existing change stream and + /// therefore does not create a second filesystem watcher or file index. + pub async fn local_with_code_intelligence( + root: impl Into, + isolation_scope: impl Into, + ) -> Result> { + let backend = ManifestWorkspaceBackend::new(root); + Self::local_with_code_intelligence_backend(backend, isolation_scope).await + } + + /// Attach native Code Intelligence to one shared manifest backend. + pub async fn local_with_code_intelligence_backend( + backend: Arc, + isolation_scope: impl Into, + ) -> Result> { + let manifest = backend.manifest(); + let file_system: Arc = backend.clone(); + let services = Self::local_with_manifest_backend(backend); + let provider = LocalCodeIntelligence::start(isolation_scope, manifest, file_system) + .await + .map_err(|error| anyhow!("failed to start Code Intelligence: {error}"))?; + Ok(services.with_code_intelligence(provider)) + } + + /// Build local workspace services from a shared manifest backend. Hosts + /// can keep the same manifest for UI file pickers and agent tools. + pub fn local_with_manifest_backend(backend: Arc) -> Arc { + let workspace_ref = WorkspaceRef::new( + backend.local_root().display().to_string(), + backend.local_root().display().to_string(), + ); + let path_resolver: Arc = backend.clone(); + let file_system: Arc = backend.clone(); + let text_reader: Arc = backend.clone(); + let command_runner: Arc = backend.clone(); + let search: Arc = backend.clone(); + let git: Arc = backend.clone(); + let git_stash: Arc = backend.clone(); + let git_worktree: Arc = backend.clone(); + Arc::new(Self { + workspace_ref, + capabilities: WorkspaceCapabilities::local_default(), + path_resolver, + file_system, + file_system_ext: None, + text_reader: Some(text_reader), + command_runner: Some(command_runner), + search: Some(search), + code_intelligence: None, + git: Some(git), + git_stash: Some(git_stash), + git_worktree: Some(git_worktree), + operation_timeout: None, + local_root: Some(backend.local_root().to_path_buf()), + }) + } + + pub fn workspace_ref(&self) -> &WorkspaceRef { + &self.workspace_ref + } + + pub fn capabilities(&self) -> WorkspaceCapabilities { + self.capabilities + } + + pub fn normalize_path(&self, input: &str) -> Result { + self.path_resolver.normalize(input) + } + + pub fn fs(&self) -> Arc { + Arc::clone(&self.file_system) + } + + /// Optional compare-and-swap file system extensions. + /// + /// Returns `Some` when the backend supports version-aware writes (e.g. + /// S3 via ETag). Tools that perform read-modify-write cycles should + /// route through [`Self::read_for_edit`] and [`Self::write_for_edit`] + /// rather than touching this directly. + pub fn fs_ext(&self) -> Option> { + self.file_system_ext.clone() + } + + pub fn text_reader(&self) -> Option> { + self.text_reader.clone() + } + + pub fn command_runner(&self) -> Option> { + self.command_runner.clone() + } + + pub fn search(&self) -> Option> { + self.search.clone() + } + + /// Optional workspace-scoped semantic code query provider. + pub fn code_intelligence(&self) -> Option> { + self.code_intelligence.clone() + } + + /// Attach a semantic code query provider while preserving every existing + /// workspace capability and backend. + pub fn with_code_intelligence( + &self, + provider: Arc, + ) -> Arc { + Arc::new(Self { + workspace_ref: self.workspace_ref.clone(), + capabilities: self.capabilities, + path_resolver: Arc::clone(&self.path_resolver), + file_system: Arc::clone(&self.file_system), + file_system_ext: self.file_system_ext.clone(), + text_reader: self.text_reader.clone(), + command_runner: self.command_runner.clone(), + search: self.search.clone(), + code_intelligence: Some(provider), + git: self.git.clone(), + git_stash: self.git_stash.clone(), + git_worktree: self.git_worktree.clone(), + operation_timeout: self.operation_timeout, + local_root: self.local_root.clone(), + }) + } + + pub fn git(&self) -> Option> { + self.git.clone() + } + + pub fn git_stash(&self) -> Option> { + self.git_stash.clone() + } + + pub fn git_worktree(&self) -> Option> { + self.git_worktree.clone() + } + + /// Internal helper used by decorators (`with_remote_git` and any + /// future git-provider override) to swap the git layer of an existing + /// `WorkspaceServices` without losing unrelated fields. + /// + /// Every field is **explicitly listed** in the returned struct + /// literal. This is the point of the helper — adding a new field to + /// `WorkspaceServices` will trip a compile error here, and the author + /// of that new field has to decide whether a git-provider swap + /// preserves it. Previously the decorator went through + /// `WorkspaceServicesBuilder`, which silently dropped any field the + /// builder did not know about (notably `local_root`). + /// + /// `git_worktree` is reset to `None` because worktree operations are + /// part of the same domain as the git provider — keeping the local + /// worktree provider while routing `status`/`log`/`diff` to a remote + /// server would surface inconsistent state to the model. + pub(crate) fn with_git_provider( + &self, + git: Arc, + git_stash: Option>, + ) -> Arc { + let mut capabilities = self.capabilities; + capabilities.git = true; + Arc::new(Self { + workspace_ref: self.workspace_ref.clone(), + capabilities, + path_resolver: Arc::clone(&self.path_resolver), + file_system: Arc::clone(&self.file_system), + file_system_ext: self.file_system_ext.clone(), + text_reader: self.text_reader.clone(), + command_runner: self.command_runner.clone(), + search: self.search.clone(), + code_intelligence: self.code_intelligence.clone(), + git: Some(git), + git_stash, + git_worktree: None, + operation_timeout: self.operation_timeout, + local_root: self.local_root.clone(), + }) + } + + /// Default timeout applied to non-bash workspace operations. + /// + /// `None` means no enforced timeout. Backends that may stall (remote, + /// browser, DFS) should set this so tools using [`Self::run_with_timeout`] + /// surface a timeout error instead of letting the agent loop hang. + pub fn operation_timeout(&self) -> Option { + self.operation_timeout + } + + /// Run a workspace future under the configured operation timeout. + /// + /// Tools that route through file system / search / git providers should + /// wrap their calls with this helper so non-local backends never stall + /// the agent loop indefinitely. + /// + /// Polymorphic in the error type so the helper works equally well for + /// futures returning `anyhow::Result` (the legacy callers — search, + /// git, etc.) and for futures returning [`WorkspaceResult`] (the + /// migrated `WorkspaceFileSystem` callers). The `E: From` + /// bound is satisfied by both `anyhow::Error` (trivially) and + /// [`WorkspaceError`] (via its `#[from]` `Backend` variant); a timeout + /// surfaces as that From conversion of an `anyhow!(...)` message. + pub async fn run_with_timeout( + &self, + op: &'static str, + fut: F, + ) -> std::result::Result + where + F: std::future::Future>, + E: From, + { + match self.operation_timeout { + Some(d) => tokio::time::timeout(d, fut).await.map_err(|_| { + E::from(anyhow!( + "workspace operation '{}' timed out after {:?}", + op, + d + )) + })?, + None => fut.await, + } + } + + /// Read a file for a subsequent modify-write cycle, requesting a version + /// token when the backend supports compare-and-swap writes. + /// + /// Returns `(content, Some(version))` when [`Self::fs_ext`] is available + /// (e.g. on S3, where the version is the object ETag); `(content, None)` + /// otherwise. Pair with [`Self::write_for_edit`]. + pub async fn read_for_edit( + &self, + path: &WorkspacePath, + ) -> WorkspaceResult<(String, Option)> { + if let Some(ext) = self.fs_ext() { + let path = path.clone(); + return self + .run_with_timeout("read_text_with_version", async move { + let (content, version) = ext.read_text_with_version(&path).await?; + Ok((content, Some(version))) + }) + .await; + } + let fs = self.fs(); + let path_owned = path.clone(); + let content = self + .run_with_timeout("read_text", async move { fs.read_text(&path_owned).await }) + .await?; + Ok((content, None)) + } + + /// Companion to [`Self::read_for_edit`]. Performs a compare-and-swap + /// write when both [`Self::fs_ext`] is available *and* a version token + /// was returned by the prior read; falls back to a plain write + /// otherwise. On version mismatch the returned error is the typed + /// [`WorkspaceError::VersionConflict`] variant; callers can also still + /// downcast `anyhow::Error::downcast_ref::()` + /// when the value has been lifted into an `anyhow::Result`. + pub async fn write_for_edit( + &self, + path: &WorkspacePath, + content: &str, + expected_version: Option<&str>, + ) -> WorkspaceResult { + if let (Some(ext), Some(version)) = (self.fs_ext(), expected_version) { + let path = path.clone(); + let content = content.to_string(); + let expected = version.to_string(); + return self + .run_with_timeout("write_text_if_version", async move { + ext.write_text_if_version(&path, &content, &expected).await + }) + .await; + } + let fs = self.fs(); + let path = path.clone(); + let content = content.to_string(); + self.run_with_timeout( + "write_text", + async move { fs.write_text(&path, &content).await }, + ) + .await + } + + pub fn local_root(&self) -> Option<&Path> { + self.local_root.as_deref() + } + + pub fn display_path(&self, path: &WorkspacePath) -> String { + if path.is_root() { + return self.workspace_ref.display_root.clone(); + } + + let root = self.workspace_ref.display_root.trim_end_matches('/'); + if root.is_empty() { + path.as_str().to_string() + } else { + format!("{root}/{}", path.as_str()) + } + } +} + +/// Builder for assembling workspace services without constructor arity churn. +pub struct WorkspaceServicesBuilder { + workspace_ref: WorkspaceRef, + capabilities: WorkspaceCapabilities, + path_resolver: Arc, + file_system: Arc, + file_system_ext: Option>, + text_reader: Option>, + command_runner: Option>, + search: Option>, + code_intelligence: Option>, + git: Option>, + git_stash: Option>, + git_worktree: Option>, + operation_timeout: Option, +} + +impl WorkspaceServicesBuilder { + pub fn new(workspace_ref: WorkspaceRef, file_system: Arc) -> Self { + Self { + workspace_ref, + capabilities: WorkspaceCapabilities::read_write(), + path_resolver: Arc::new(VirtualPathResolver), + file_system, + file_system_ext: None, + text_reader: None, + command_runner: None, + search: None, + code_intelligence: None, + git: None, + git_stash: None, + git_worktree: None, + operation_timeout: None, + } + } + + pub fn capabilities(mut self, capabilities: WorkspaceCapabilities) -> Self { + self.capabilities = capabilities; + self + } + + pub fn command_runner(mut self, command_runner: Arc) -> Self { + self.capabilities.exec = true; + self.command_runner = Some(command_runner); + self + } + + pub fn search(mut self, search: Arc) -> Self { + self.capabilities.search = true; + self.search = Some(search); + self + } + + pub fn code_intelligence(mut self, provider: Arc) -> Self { + self.code_intelligence = Some(provider); + self + } + + pub fn git(mut self, git: Arc) -> Self { + self.capabilities.git = true; + self.git = Some(git); + self + } + + pub fn git_stash(mut self, git_stash: Arc) -> Self { + self.git_stash = Some(git_stash); + self + } + + pub fn git_worktree(mut self, git_worktree: Arc) -> Self { + self.git_worktree = Some(git_worktree); + self + } + + /// Attach optional compare-and-swap file system extensions + /// ([`WorkspaceFileSystemExt`]). Tools that perform read-modify-write + /// cycles will pick this up via [`WorkspaceServices::read_for_edit`] + /// and [`WorkspaceServices::write_for_edit`]. + pub fn file_system_ext(mut self, ext: Arc) -> Self { + self.file_system_ext = Some(ext); + self + } + + pub fn text_reader(mut self, reader: Arc) -> Self { + self.text_reader = Some(reader); + self + } + + /// Apply a default timeout to non-bash workspace operations (file system, + /// search, git). Backends that may stall — remote, browser, DFS — should + /// set this so tools surface a timeout error rather than hanging. + pub fn operation_timeout(mut self, timeout: std::time::Duration) -> Self { + self.operation_timeout = Some(timeout); + self + } + + pub fn build(self) -> Arc { + let mut services = WorkspaceServices::new_with_git( + self.workspace_ref, + self.capabilities, + self.path_resolver, + self.file_system, + self.command_runner, + self.search, + self.git, + ); + services.file_system_ext = self.file_system_ext; + services.text_reader = self.text_reader; + services.code_intelligence = self.code_intelligence; + services.git_stash = self.git_stash; + services.git_worktree = self.git_worktree; + services.operation_timeout = self.operation_timeout; + Arc::new(services) + } +} diff --git a/core/src/workspace/tests.rs b/core/src/workspace/tests.rs index 6b05b2b3..a94084af 100644 --- a/core/src/workspace/tests.rs +++ b/core/src/workspace/tests.rs @@ -201,6 +201,128 @@ async fn write_for_edit_succeeds_on_matching_version() { assert_eq!(current, "beta"); } +struct TestCodeIntelligence { + status: tokio::sync::watch::Sender, +} + +impl TestCodeIntelligence { + fn ready() -> Self { + use crate::code_intelligence::{ + CodeIntelligenceCapabilities, CodeIntelligenceState, CodeIntelligenceStatus, + }; + + let (status, _) = tokio::sync::watch::channel(CodeIntelligenceStatus { + state: CodeIntelligenceState::Ready, + capabilities: CodeIntelligenceCapabilities { + document_symbols: true, + ..CodeIntelligenceCapabilities::default() + }, + languages: Vec::new(), + message: None, + }); + Self { status } + } + + fn unavailable() -> crate::code_intelligence::CodeIntelligenceResult { + Err( + crate::code_intelligence::CodeIntelligenceError::Unavailable { + message: "test provider has no runtime".to_string(), + }, + ) + } +} + +#[async_trait] +impl crate::code_intelligence::WorkspaceCodeIntelligence for TestCodeIntelligence { + fn subscribe_status( + &self, + ) -> tokio::sync::watch::Receiver { + self.status.subscribe() + } + + async fn document_symbols( + &self, + _path: &WorkspacePath, + _cancellation: tokio_util::sync::CancellationToken, + ) -> crate::code_intelligence::CodeIntelligenceResult< + crate::code_intelligence::CodeQueryResult, + > { + Self::unavailable() + } + + async fn search_symbols( + &self, + _query: &str, + _limit: usize, + _cancellation: tokio_util::sync::CancellationToken, + ) -> crate::code_intelligence::CodeIntelligenceResult< + crate::code_intelligence::CodeQueryResult, + > { + Self::unavailable() + } + + async fn navigate( + &self, + _kind: crate::code_intelligence::NavigationKind, + _path: &WorkspacePath, + _position: crate::code_intelligence::CodePosition, + _cancellation: tokio_util::sync::CancellationToken, + ) -> crate::code_intelligence::CodeIntelligenceResult< + crate::code_intelligence::CodeQueryResult, + > { + Self::unavailable() + } + + async fn diagnostics( + &self, + _path: Option<&WorkspacePath>, + _cancellation: tokio_util::sync::CancellationToken, + ) -> crate::code_intelligence::CodeIntelligenceResult< + crate::code_intelligence::CodeQueryResult, + > { + Self::unavailable() + } +} + +#[test] +fn workspace_services_builder_attaches_code_intelligence() { + use crate::code_intelligence::{CodeIntelligenceState, WorkspaceCodeIntelligence}; + + let fs = Arc::new(InMemoryFileSystem::new()); + let fs_backend: Arc = fs; + let provider: Arc = Arc::new(TestCodeIntelligence::ready()); + let services = + WorkspaceServices::builder(WorkspaceRef::new("semantic", "mem://semantic"), fs_backend) + .code_intelligence(provider) + .build(); + + assert!(services.code_intelligence().is_some()); + assert_eq!( + services.code_intelligence().unwrap().status().state, + CodeIntelligenceState::Ready + ); +} + +#[test] +fn code_intelligence_decorator_preserves_existing_services() { + use crate::code_intelligence::WorkspaceCodeIntelligence; + + let fs = Arc::new(InMemoryFileSystem::new()); + let fs_backend: Arc = fs; + let services = WorkspaceServices::builder( + WorkspaceRef::new("decorated", "mem://decorated"), + fs_backend, + ) + .build(); + let provider: Arc = Arc::new(TestCodeIntelligence::ready()); + let decorated = services.with_code_intelligence(provider); + + assert!(decorated.capabilities().read); + assert!(decorated.capabilities().write); + assert!(decorated.code_intelligence().is_some()); + assert_eq!(decorated.workspace_ref(), services.workspace_ref()); +} + #[tokio::test] async fn write_for_edit_surfaces_conflict_when_version_changed() { let fs = Arc::new(InMemoryFileSystem::new()); diff --git a/core/tests/fixtures/code_intelligence_fake_lsp.rs b/core/tests/fixtures/code_intelligence_fake_lsp.rs new file mode 100644 index 00000000..32ee550e --- /dev/null +++ b/core/tests/fixtures/code_intelligence_fake_lsp.rs @@ -0,0 +1,264 @@ +use std::{ + fs::OpenOptions, + io::{self, BufRead, BufReader, Write}, + path::PathBuf, +}; + +fn main() -> io::Result<()> { + let executable = std::env::current_exe()?; + let log_path = executable.with_extension("log"); + let executable_name = executable + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + let push_diagnostics = executable_name.contains("push-diagnostics"); + let cold_navigation = if executable_name.contains("cold-empty") { + ColdNavigation::Empty + } else if executable_name.contains("cold-partial") { + ColdNavigation::Partial + } else { + ColdNavigation::Disabled + }; + let stdin = io::stdin(); + let mut input = BufReader::new(stdin.lock()); + let stdout = io::stdout(); + let mut output = stdout.lock(); + let mut document_uri = None; + let mut navigation_requests = 0_usize; + + while let Some(body) = read_message(&mut input)? { + append_log(&log_path, &body)?; + let method = string_field(&body, "method").unwrap_or_default(); + if method == "workspace/symbol" && body.contains("\"query\":\"terminate-process\"") { + eprintln!("fixture language server terminated unexpectedly"); + std::process::exit(12); + } + if method == "textDocument/didOpen" { + document_uri = string_field(&body, "uri"); + if push_diagnostics { + if let Some(uri) = document_uri.as_deref() { + std::thread::sleep(std::time::Duration::from_millis(50)); + write_message(&mut output, &publish_diagnostics_notification(uri))?; + } + } + } + + let Some(id) = request_id(&body) else { + if method == "exit" { + break; + } + continue; + }; + if is_navigation_method(&method) { + navigation_requests += 1; + } + let result = response_for( + &method, + document_uri.as_deref(), + push_diagnostics, + cold_navigation, + navigation_requests, + ); + write_message( + &mut output, + &format!("{{\"jsonrpc\":\"2.0\",\"id\":{id},\"result\":{result}}}"), + )?; + } + Ok(()) +} + +#[derive(Clone, Copy)] +enum ColdNavigation { + Disabled, + Empty, + Partial, +} + +fn read_message(reader: &mut impl BufRead) -> io::Result> { + let mut content_length = None; + loop { + let mut line = String::new(); + if reader.read_line(&mut line)? == 0 { + return Ok(None); + } + let trimmed = line.trim_end_matches(['\r', '\n']); + if trimmed.is_empty() { + break; + } + if let Some(value) = trimmed + .strip_prefix("Content-Length:") + .or_else(|| trimmed.strip_prefix("content-length:")) + { + content_length = Some( + value + .trim() + .parse::() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?, + ); + } + } + let length = content_length + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing Content-Length"))?; + let mut body = vec![0_u8; length]; + reader.read_exact(&mut body)?; + String::from_utf8(body) + .map(Some) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +fn write_message(writer: &mut impl Write, body: &str) -> io::Result<()> { + write!(writer, "Content-Length: {}\r\n\r\n{body}", body.len())?; + writer.flush() +} + +fn append_log(path: &PathBuf, body: &str) -> io::Result<()> { + let mut file = OpenOptions::new().create(true).append(true).open(path)?; + writeln!(file, "{body}") +} + +fn request_id(body: &str) -> Option<&str> { + let rest = body.split_once("\"id\":")?.1; + let end = rest.find([',', '}']).unwrap_or(rest.len()); + Some(rest[..end].trim()) +} + +fn string_field(body: &str, field: &str) -> Option { + let marker = format!("\"{field}\":\""); + let rest = body.split_once(&marker)?.1; + let mut escaped = false; + let end = rest.char_indices().find_map(|(index, character)| { + if character == '"' && !escaped { + return Some(index); + } + escaped = character == '\\' && !escaped; + if character != '\\' { + escaped = false; + } + None + })?; + Some(rest[..end].to_owned()) +} + +fn response_for( + method: &str, + uri: Option<&str>, + push_diagnostics: bool, + cold_navigation: ColdNavigation, + navigation_requests: usize, +) -> String { + let uri = uri.unwrap_or("file:///missing.rs"); + match method { + "initialize" => initialize_response(push_diagnostics), + "textDocument/documentSymbol" => concat!( + "[{\"name\":\"answer\",\"kind\":12,", + "\"range\":{\"start\":{\"line\":0,\"character\":0},", + "\"end\":{\"line\":0,\"character\":30}},", + "\"selectionRange\":{\"start\":{\"line\":0,\"character\":7},", + "\"end\":{\"line\":0,\"character\":13}}}]" + ) + .to_owned(), + "workspace/symbol" => workspace_symbol_response(uri), + "textDocument/definition" + | "textDocument/declaration" + | "textDocument/references" + | "textDocument/implementation" => match (cold_navigation, navigation_requests) { + (ColdNavigation::Empty, 1) => "[]".to_owned(), + (ColdNavigation::Partial, 1) => locations_response(uri), + (ColdNavigation::Empty | ColdNavigation::Partial, _) => settled_locations_response(uri), + (ColdNavigation::Disabled, _) => locations_response(uri), + }, + "textDocument/diagnostic" => concat!( + "{\"kind\":\"full\",\"resultId\":\"fixture-1\",\"items\":[{", + "\"range\":{\"start\":{\"line\":0,\"character\":7},", + "\"end\":{\"line\":0,\"character\":13}},", + "\"severity\":2,\"source\":\"fixture\",\"message\":\"fixture warning\"}]}" + ) + .to_owned(), + "shutdown" => "null".to_owned(), + _ => "null".to_owned(), + } +} + +fn is_navigation_method(method: &str) -> bool { + matches!( + method, + "textDocument/definition" + | "textDocument/declaration" + | "textDocument/references" + | "textDocument/implementation" + ) +} + +fn initialize_response(push_diagnostics: bool) -> String { + let mut response = concat!( + "{\"capabilities\":{", + "\"positionEncoding\":\"utf-16\",", + "\"textDocumentSync\":{\"openClose\":true,\"change\":1,\"save\":true},", + "\"documentSymbolProvider\":true,\"workspaceSymbolProvider\":true,", + "\"definitionProvider\":true,\"declarationProvider\":true,", + "\"referencesProvider\":true,\"implementationProvider\":true" + ) + .to_owned(); + if !push_diagnostics { + response.push_str(concat!( + ",\"diagnosticProvider\":{\"interFileDependencies\":false,", + "\"workspaceDiagnostics\":false}" + )); + } + response.push_str("}}"); + response +} + +fn publish_diagnostics_notification(uri: &str) -> String { + let mut notification = concat!( + "{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/publishDiagnostics\",", + "\"params\":{\"uri\":\"" + ) + .to_owned(); + notification.push_str(uri); + notification.push_str(concat!( + "\",\"version\":1,\"diagnostics\":[{", + "\"range\":{\"start\":{\"line\":0,\"character\":7},", + "\"end\":{\"line\":0,\"character\":13}},", + "\"severity\":2,\"source\":\"fixture\",", + "\"message\":\"fixture push warning\"}]}}" + )); + notification +} + +fn workspace_symbol_response(uri: &str) -> String { + let mut response = "[{\"name\":\"answer\",\"kind\":12,\"location\":{\"uri\":\"".to_owned(); + response.push_str(uri); + response.push_str(concat!( + "\",\"range\":{\"start\":{\"line\":0,\"character\":7},", + "\"end\":{\"line\":0,\"character\":13}}}}]" + )); + response +} + +fn locations_response(uri: &str) -> String { + let mut response = "[{\"uri\":\"".to_owned(); + response.push_str(uri); + response.push_str(concat!( + "\",\"range\":{\"start\":{\"line\":0,\"character\":7},", + "\"end\":{\"line\":0,\"character\":13}}}]" + )); + response +} + +fn settled_locations_response(uri: &str) -> String { + let mut response = String::from("["); + for (index, character) in [0, 7, 14].into_iter().enumerate() { + if index > 0 { + response.push(','); + } + response.push_str("{\"uri\":\""); + response.push_str(uri); + response.push_str(&format!( + "\",\"range\":{{\"start\":{{\"line\":0,\"character\":{character}}},\"end\":{{\"line\":0,\"character\":{}}}}}}}", + character + 1 + )); + } + response.push(']'); + response +} diff --git a/manual/CODE_INTELLIGENCE_DESIGN.md b/manual/CODE_INTELLIGENCE_DESIGN.md new file mode 100644 index 00000000..468dedec --- /dev/null +++ b/manual/CODE_INTELLIGENCE_DESIGN.md @@ -0,0 +1,175 @@ +# Code Intelligence Design + +## Decision + +A3S Code provides Code Intelligence as a native, workspace-scoped Core +capability shared by agent tools, the terminal editor, and A3S Web. The Rust +runtime owns language-process discovery, framed stdio JSON-RPC, lifecycle, +capability negotiation, saved-document synchronization, result normalization, +and bounded caching. It does not use MCP or a separate helper service. + +The initial language profiles are Rust and TypeScript/JavaScript. The public +contracts use open-ended language identifiers so adding a profile does not +change callers. + +## Capability Boundary + +Code Intelligence owns only language-semantic operations: + +- hierarchical symbols for one document; +- bounded symbol search across a workspace; +- definitions, declarations, references, and implementations; +- document and bounded workspace diagnostics; +- runtime status and negotiated capabilities. + +Existing A3S Code capabilities remain the single source of truth for adjacent +work: + +| Concern | Existing owner | Code Intelligence behavior | +| --- | --- | --- | +| File discovery and change events | Workspace manifest | Subscribes to the existing manifest and watcher | +| Path validation and file reads | Workspace services | Uses `WorkspacePath` and `WorkspaceFileSystem` | +| Text and filename search | `grep` and `glob` | Returns semantic symbols only | +| Source retrieval | `read` | Returns locations and metadata, not source text | +| Mutations | `write`, `edit`, and `patch` | Exposes no second editing or refactoring tool | +| Context, memory, and sessions | Existing Core runtimes | Stores no parallel project memory or session state | +| Permissions and cancellation | Tool runtime and workspace policy | Uses the same read-only query lane and cancellation tokens | + +This boundary lets an agent locate a symbol semantically, inspect it with +`read`, and modify it with the existing compare-and-swap editing tools. It +avoids competing file indexes, mutation paths, or persistence formats. + +## Shared Architecture + +```text +agent tools TUI /ide Web + Monaco + \ | / + +------ WorkspaceServices ------+ + | + WorkspaceCodeIntelligence + | + LocalCodeIntelligence + / \ +shared manifest + FS runtime registry + | + per-language process runtime + | + framed stdio language protocol +``` + +`WorkspaceServices` is the host boundary. Local hosts attach one +`LocalCodeIntelligence` provider to the same `ManifestWorkspaceBackend` used by +file tools and workspace UI. Web caches that bundle by canonical workspace; +sessions for the same workspace reuse it. The TUI owns one bundle for its +active workspace. + +The registry key includes the canonical workspace, project layout, and host +isolation scope; the workspace runtime owns its per-language processes. +Concurrent initialization is single-flight. Leases protect active runtimes. +The generic registry supports bounded TTL/LRU reclamation, while the local +provider retires an idle superseded layout immediately. Shutdown is explicit +and idempotent; process exit also changes status immediately and permits a +clean restart on the next query. + +Protocol initialization can complete before a language server finishes its +first semantic index. Status therefore remains starting through a bounded, +cancellable initialization-settle window. The first navigation query for each +saved document revision performs one warmup request and returns a second, +authoritative response after a short cancellable interval. This rule does not +infer readiness from an empty result or an assumed reference count; legitimate +empty results remain valid and later queries for the same revision avoid the +warmup cost. + +The first workspace diagnostics query starts every language profile relevant +to supported source files in the current manifest, then fairly queries a +bounded number of saved documents with bounded concurrency. A missing runtime +does not suppress diagnostics from available languages: the result is marked +`truncated`, the aggregate status becomes degraded, and the unavailable +language remains visible in per-language status. + +## Public Contract + +- Paths are normalized, workspace-relative `WorkspacePath` values. +- Lines and characters are zero-based. +- Characters count UTF-16 code units. This matches the language protocol and + Monaco without lossy column conversion. +- Queries operate on saved files only. An editor with unsaved changes must say + that results are based on the saved version. +- A document-scoped result includes a monotonic revision, an opaque saved + content hash, and a `stale` flag if the file changed during the query. +- Every result is bounded and reports `truncated` plus the observed workspace + revision. +- Unsupported server capabilities are reported through typed status and errors + instead of being guessed from the language name. + +Saved-only synchronization prevents two UI sessions or editor tabs from +publishing conflicting in-memory document state. Save events flow through the +shared manifest; a later query resynchronizes the saved contents before asking +the language runtime. + +## Language Profiles and Project Layout + +The manifest identifies project roots from stable marker topology: + +| Profile | Source files | Project markers | Default executable | +| --- | --- | --- | --- | +| Rust | `.rs` | `Cargo.toml` | `rust-analyzer` | +| TypeScript/JavaScript | `.ts`, `.tsx`, `.js`, `.jsx` | `package.json`, `tsconfig*.json` | `typescript-language-server --stdio` | + +Only marker topology changes restart a workspace runtime. Ordinary source or +metadata changes keep the existing process and use saved-document events. A +mixed monorepo can run both profiles with deduplicated project folders. + +Language executables are discovered from the process environment when a query +first needs that profile. Missing or incompatible executables degrade only that +language profile and remain visible in status; they do not disable file tools +or another working language. + +## Product Surfaces + +Agent sessions expose three read-only tools when the workspace has an attached +Code Intelligence provider: + +- `code_symbols` for document outlines and workspace symbol search; +- `code_navigation` for definitions, declarations, references, and + implementations; +- `code_diagnostics` for document or bounded workspace diagnostics. + +The TUI maps the same service to `/ide` commands and a navigable result list. +A3S Web maps it to typed read-only endpoints and Monaco providers/actions. Both +surfaces reuse the existing file-selection flow when opening a returned +location. + +## Security and Failure Model + +- Canonical path checks reject absolute client paths, traversal, and symlink + escapes before a file is read or a result is returned. +- Language-process output is treated as untrusted protocol data. Malformed + frames, invalid URIs, and out-of-workspace locations become typed errors. +- The built-in Rust profile disables workspace build scripts, procedural + macros, and automatic check commands so a read-only semantic query cannot + execute workspace code or bypass the existing command-approval path. +- Request timeouts and cancellation remove pending protocol calls. Process + shutdown is bounded, followed by forced cleanup when necessary. +- Stderr is bounded diagnostic evidence and never mixed with protocol stdout. +- Runtime failure changes only Code Intelligence status; existing workspace + reads, searches, edits, and sessions remain available. + +## Delivery and Verification + +1. Core contracts and runtime: protocol codec, initialization, process + lifecycle, saved-document state, path containment, project layout, and + registry. +2. Agent integration: capability-gated semantic tools with bounded structured + output. +3. Product integration: asynchronous TUI commands plus Web endpoints and + Monaco diagnostics, symbols, and navigation. +4. Hardening: real child-process fixtures, crash/restart and shutdown tests, + mixed-workspace isolation, dirty-editor messaging, and end-to-end product + checks. + +Required regression coverage includes UTF-16 positions, stale evidence, +unsupported capabilities, process crashes, delayed status subscribers, +workspace isolation, cold empty and partial navigation results, cancellation +during stabilization, traversal and symlink escapes, bounded results, explicit +shutdown, and the absence of duplicate file/search/edit behavior. diff --git a/scripts/check_release_versions.sh b/scripts/check_release_versions.sh index 4ac8e183..60c5dfac 100755 --- a/scripts/check_release_versions.sh +++ b/scripts/check_release_versions.sh @@ -119,6 +119,12 @@ def check_bootstrap_runtime_version(path): check_equal(f"{path} __version__", match.group(1)) +def check_changelog(path): + heading = f"## [{expected}]" + if heading not in read(path): + fail(f"{path}: missing release heading {heading}") + + if not expected: expected = first_manifest_version("core/Cargo.toml") or "" @@ -134,6 +140,8 @@ check_package_json("sdk/node/package.json") check_pyproject("sdk/python/pyproject.toml") check_pyproject("sdk/python-bootstrap/pyproject.toml") check_bootstrap_runtime_version("sdk/python-bootstrap/src/a3s_code/_bootstrap.py") +check_changelog("CHANGELOG.md") +check_changelog("sdk/python/CHANGELOG.md") check_cargo_lock("Cargo.lock") check_cargo_lock_package("sdk/node/Cargo.lock", "a3s-code-node") check_cargo_lock_package("sdk/python/Cargo.lock", "a3s-code-py") diff --git a/scripts/check_semver.sh b/scripts/check_semver.sh new file mode 100644 index 00000000..fd549b84 --- /dev/null +++ b/scripts/check_semver.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Compare the current public API with the published 5.2.4 baseline. + +set -euo pipefail + +BASELINE_VERSION="${1:-5.2.4}" +PACKAGE="a3s-code-core" + +case "$BASELINE_VERSION" in + 5.2.4) + BASELINE_SHA256="0066046ead6d44acac8a01a8bd1bd78c37aae02c14121f28ea77c15d9d0133a4" + ;; + *) + echo "unsupported SemVer baseline: $BASELINE_VERSION" >&2 + exit 1 + ;; +esac + +TEMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/a3s-code-semver.XXXXXX")" +trap 'rm -rf "$TEMP_ROOT"' EXIT + +ARCHIVE="$TEMP_ROOT/${PACKAGE}-${BASELINE_VERSION}.crate" +SOURCE_ROOT="$TEMP_ROOT/${PACKAGE}-${BASELINE_VERSION}" + +curl \ + --fail \ + --location \ + --silent \ + --show-error \ + --retry 3 \ + --proto '=https' \ + --tlsv1.2 \ + --output "$ARCHIVE" \ + "https://static.crates.io/crates/${PACKAGE}/${PACKAGE}-${BASELINE_VERSION}.crate" + +python3 - "$ARCHIVE" "$BASELINE_SHA256" <<'PY' +import hashlib +import pathlib +import sys + +archive = pathlib.Path(sys.argv[1]) +expected = sys.argv[2] +actual = hashlib.sha256(archive.read_bytes()).hexdigest() +if actual != expected: + raise SystemExit( + f"baseline archive checksum mismatch: expected {expected}, got {actual}" + ) +PY + +tar -xzf "$ARCHIVE" -C "$TEMP_ROOT" + +# The published 5.2.4 manifest used the compatible range `1.4.1`, but a later +# incompatible a3s-search release now satisfies that range. Pin the dependency +# version used by 5.2.4 so its unchanged public API can be documented. +python3 - "$SOURCE_ROOT/Cargo.toml" <<'PY' +import pathlib +import sys + +manifest = pathlib.Path(sys.argv[1]) +contents = manifest.read_text() +original = '[dependencies.a3s-search]\nversion = "1.4.1"' +replacement = '[dependencies.a3s-search]\nversion = "=1.4.1"' +if contents.count(original) != 1: + raise SystemExit(f"unexpected baseline a3s-search declaration in {manifest}") +manifest.write_text(contents.replace(original, replacement)) +PY + +cargo semver-checks check-release \ + --package "$PACKAGE" \ + --baseline-root "$SOURCE_ROOT" diff --git a/sdk/node/Cargo.lock b/sdk/node/Cargo.lock index 4b72ef7e..c79b56c1 100644 --- a/sdk/node/Cargo.lock +++ b/sdk/node/Cargo.lock @@ -15,7 +15,7 @@ checksum = "35b83be97f61abdd33096446eae063f9d65d98e84c446560b59d3c744194287e" [[package]] name = "a3s-code-core" -version = "5.2.4" +version = "5.3.0" dependencies = [ "a3s-acl 0.2.0", "a3s-common", @@ -46,6 +46,7 @@ dependencies = [ "jsonschema", "libc", "lopdf", + "lsp-types", "notify", "pdf-extract", "pin-project-lite", @@ -68,13 +69,14 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", + "url", "uuid", "zip 0.6.6", ] [[package]] name = "a3s-code-node" -version = "5.2.4" +version = "5.3.0" dependencies = [ "a3s-code-core", "anyhow", @@ -165,7 +167,6 @@ dependencies = [ "clap", "dom_smoothie", "futures", - "quick-xml", "reqwest 0.12.28", "scraper", "serde", @@ -1777,6 +1778,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "fluent-uri" version = "0.4.1" @@ -2774,6 +2784,19 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri 0.1.4", + "serde", + "serde_json", + "serde_repr", +] + [[package]] name = "mac" version = "0.1.1" @@ -3449,16 +3472,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "quinn" version = "0.11.11" @@ -3665,7 +3678,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "348e860aeb0b7bd035778fd11dd9cd5290d32e4aed3b8f2274a00287a9fd362b" dependencies = [ "ahash", - "fluent-uri", + "fluent-uri 0.4.1", "getrandom 0.3.4", "hashbrown 0.17.1", "itoa", @@ -4143,6 +4156,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "serde_spanned" version = "0.6.9" diff --git a/sdk/node/Cargo.toml b/sdk/node/Cargo.toml index 9b066c96..96cbc75f 100644 --- a/sdk/node/Cargo.toml +++ b/sdk/node/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-code-node" -version = "5.2.4" +version = "5.3.0" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" @@ -11,7 +11,7 @@ description = "A3S Code Node.js bindings - Native addon via napi-rs" crate-type = ["cdylib"] [dependencies] -a3s-code-core = { version = "5.2.4", path = "../../core", features = ["s3", "serve"] } +a3s-code-core = { version = "5.3.0", path = "../../core", features = ["s3", "serve"] } napi = { version = "2", features = ["async", "napi6", "serde-json"] } napi-derive = "2" tokio = { version = "1.35", features = ["full"] } diff --git a/sdk/node/examples/package-lock.json b/sdk/node/examples/package-lock.json index 2935d098..2e2f5801 100644 --- a/sdk/node/examples/package-lock.json +++ b/sdk/node/examples/package-lock.json @@ -18,7 +18,7 @@ }, "..": { "name": "@a3s-lab/code", - "version": "5.2.4", + "version": "5.3.0", "license": "MIT", "devDependencies": { "@napi-rs/cli": "^2", @@ -27,12 +27,12 @@ "typescript": "^5.9.3" }, "optionalDependencies": { - "@a3s-lab/code-darwin-arm64": "5.2.4", - "@a3s-lab/code-linux-arm64-gnu": "5.2.4", - "@a3s-lab/code-linux-arm64-musl": "5.2.4", - "@a3s-lab/code-linux-x64-gnu": "5.2.4", - "@a3s-lab/code-linux-x64-musl": "5.2.4", - "@a3s-lab/code-win32-x64-msvc": "5.2.4" + "@a3s-lab/code-darwin-arm64": "5.3.0", + "@a3s-lab/code-linux-arm64-gnu": "5.3.0", + "@a3s-lab/code-linux-arm64-musl": "5.3.0", + "@a3s-lab/code-linux-x64-gnu": "5.3.0", + "@a3s-lab/code-linux-x64-musl": "5.3.0", + "@a3s-lab/code-win32-x64-msvc": "5.3.0" } }, "node_modules/@a3s-lab/code": { diff --git a/sdk/node/generated.d.ts b/sdk/node/generated.d.ts index eb2dcd2e..9af0ffee 100644 --- a/sdk/node/generated.d.ts +++ b/sdk/node/generated.d.ts @@ -1326,6 +1326,14 @@ export declare class Session { * JavaScript event loop. */ cancelAsync(): Promise + /** + * Cancel the active operation and wait until the session is safe to reuse. + * + * A streaming worker that does not settle during `graceMs` is aborted and + * receives a second `abortGraceMs` window for cleanup. Defaults are 2000 + * and 1000 milliseconds respectively. + */ + cancelAndSettle(graceMs?: number | undefined | null, abortGraceMs?: number | undefined | null): Promise /** * Close the session and cancel any active operation. * diff --git a/sdk/node/package-lock.json b/sdk/node/package-lock.json index 2434af6d..70ea4cf2 100644 --- a/sdk/node/package-lock.json +++ b/sdk/node/package-lock.json @@ -1,12 +1,12 @@ { "name": "@a3s-lab/code", - "version": "5.2.4", + "version": "5.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@a3s-lab/code", - "version": "5.2.4", + "version": "5.3.0", "license": "MIT", "devDependencies": { "@napi-rs/cli": "^2", @@ -15,12 +15,12 @@ "typescript": "^5.9.3" }, "optionalDependencies": { - "@a3s-lab/code-darwin-arm64": "5.2.4", - "@a3s-lab/code-linux-arm64-gnu": "5.2.4", - "@a3s-lab/code-linux-arm64-musl": "5.2.4", - "@a3s-lab/code-linux-x64-gnu": "5.2.4", - "@a3s-lab/code-linux-x64-musl": "5.2.4", - "@a3s-lab/code-win32-x64-msvc": "5.2.4" + "@a3s-lab/code-darwin-arm64": "5.3.0", + "@a3s-lab/code-linux-arm64-gnu": "5.3.0", + "@a3s-lab/code-linux-arm64-musl": "5.3.0", + "@a3s-lab/code-linux-x64-gnu": "5.3.0", + "@a3s-lab/code-linux-x64-musl": "5.3.0", + "@a3s-lab/code-win32-x64-msvc": "5.3.0" } }, "node_modules/@a3s-lab/code-darwin-arm64": { diff --git a/sdk/node/package.json b/sdk/node/package.json index d5a1b3a3..11367020 100644 --- a/sdk/node/package.json +++ b/sdk/node/package.json @@ -1,6 +1,6 @@ { "name": "@a3s-lab/code", - "version": "5.2.4", + "version": "5.3.0", "description": "A3S Code - Native Node.js bindings for the coding-agent runtime", "main": "index.js", "types": "index.d.ts", @@ -44,11 +44,11 @@ "test:helpers": "node test-helpers.mjs" }, "optionalDependencies": { - "@a3s-lab/code-darwin-arm64": "5.2.4", - "@a3s-lab/code-linux-x64-gnu": "5.2.4", - "@a3s-lab/code-linux-x64-musl": "5.2.4", - "@a3s-lab/code-linux-arm64-gnu": "5.2.4", - "@a3s-lab/code-linux-arm64-musl": "5.2.4", - "@a3s-lab/code-win32-x64-msvc": "5.2.4" + "@a3s-lab/code-darwin-arm64": "5.3.0", + "@a3s-lab/code-linux-x64-gnu": "5.3.0", + "@a3s-lab/code-linux-x64-musl": "5.3.0", + "@a3s-lab/code-linux-arm64-gnu": "5.3.0", + "@a3s-lab/code-linux-arm64-musl": "5.3.0", + "@a3s-lab/code-win32-x64-msvc": "5.3.0" } } diff --git a/sdk/node/src/session.rs b/sdk/node/src/session.rs index 25604536..ca7efb22 100644 --- a/sdk/node/src/session.rs +++ b/sdk/node/src/session.rs @@ -808,6 +808,27 @@ impl Session { .map_err(|e| napi::Error::from_reason(format!("Task join error: {e}"))) } + /// Cancel the active operation and wait until the session is safe to reuse. + /// + /// A streaming worker that does not settle during `graceMs` is aborted and + /// receives a second `abortGraceMs` window for cleanup. Defaults are 2000 + /// and 1000 milliseconds respectively. + #[napi] + pub async fn cancel_and_settle( + &self, + grace_ms: Option, + abort_grace_ms: Option, + ) -> napi::Result { + let session = self.inner.clone(); + let grace = std::time::Duration::from_millis(u64::from(grace_ms.unwrap_or(2_000))); + let abort_grace = + std::time::Duration::from_millis(u64::from(abort_grace_ms.unwrap_or(1_000))); + get_runtime() + .spawn(async move { session.cancel_and_settle(grace, abort_grace).await }) + .await + .map_err(|e| napi::Error::from_reason(format!("Task join error: {e}"))) + } + /// Close the session and cancel any active operation. /// /// Call this when the session will no longer be used so Node.js can exit diff --git a/sdk/python-bootstrap/pyproject.toml b/sdk/python-bootstrap/pyproject.toml index a58db3cc..5c15fac1 100644 --- a/sdk/python-bootstrap/pyproject.toml +++ b/sdk/python-bootstrap/pyproject.toml @@ -7,7 +7,7 @@ name = "a3s-code" # Keep in sync with crates/code core release. The bootstrap loader fetches # the matching native wheel from `https://github.com/AI45Lab/Code/releases/tag/v` # at import time. -version = "5.2.4" +version = "5.3.0" description = "A3S Code Python SDK — pure-Python bootstrap that fetches the native wheel from GitHub Releases" readme = "README.md" license = {text = "MIT"} diff --git a/sdk/python-bootstrap/src/a3s_code/_bootstrap.py b/sdk/python-bootstrap/src/a3s_code/_bootstrap.py index 018e89a9..a5e1075b 100644 --- a/sdk/python-bootstrap/src/a3s_code/_bootstrap.py +++ b/sdk/python-bootstrap/src/a3s_code/_bootstrap.py @@ -31,7 +31,7 @@ # Version is the bootstrap's own version, which equals the matching native # wheel version on GH Releases. Bumped by the release workflow. -__version__ = "5.2.4" +__version__ = "5.3.0" _DEFAULT_BASE_URL = "https://github.com/A3S-Lab/Code/releases/download" _REQUEST_TIMEOUT_S = 120 diff --git a/sdk/python/CHANGELOG.md b/sdk/python/CHANGELOG.md index 59fdec1f..36ba4ff2 100644 --- a/sdk/python/CHANGELOG.md +++ b/sdk/python/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to the A3S Code Python SDK will be documented in this file. ## [Unreleased] +## [5.3.0] - 2026-07-15 + +### Added + +- Added `Session.cancel_and_settle(...)` parity for bounded cooperative + cancellation and streaming-worker cleanup before session reuse. + ## [5.2.4] - 2026-07-14 ### Fixed diff --git a/sdk/python/Cargo.lock b/sdk/python/Cargo.lock index d866a329..e47a7df2 100644 --- a/sdk/python/Cargo.lock +++ b/sdk/python/Cargo.lock @@ -15,7 +15,7 @@ checksum = "35b83be97f61abdd33096446eae063f9d65d98e84c446560b59d3c744194287e" [[package]] name = "a3s-code-core" -version = "5.2.4" +version = "5.3.0" dependencies = [ "a3s-acl 0.2.0", "a3s-common", @@ -46,6 +46,7 @@ dependencies = [ "jsonschema", "libc", "lopdf", + "lsp-types", "notify", "pdf-extract", "pin-project-lite", @@ -68,13 +69,14 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", + "url", "uuid", "zip 0.6.6", ] [[package]] name = "a3s-code-py" -version = "5.2.4" +version = "5.3.0" dependencies = [ "a3s-code-core", "anyhow", @@ -164,7 +166,6 @@ dependencies = [ "clap", "dom_smoothie", "futures", - "quick-xml", "reqwest 0.12.28", "scraper", "serde", @@ -1749,6 +1750,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "fluent-uri" version = "0.4.1" @@ -2785,6 +2795,19 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri 0.1.4", + "serde", + "serde_json", + "serde_repr", +] + [[package]] name = "mac" version = "0.1.1" @@ -3489,16 +3512,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "quinn" version = "0.11.9" @@ -3707,7 +3720,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "348e860aeb0b7bd035778fd11dd9cd5290d32e4aed3b8f2274a00287a9fd362b" dependencies = [ "ahash", - "fluent-uri", + "fluent-uri 0.4.1", "getrandom 0.3.4", "hashbrown 0.17.1", "itoa", @@ -4185,6 +4198,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_spanned" version = "0.6.9" diff --git a/sdk/python/Cargo.toml b/sdk/python/Cargo.toml index 5b13bf56..c91117a7 100644 --- a/sdk/python/Cargo.toml +++ b/sdk/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-code-py" -version = "5.2.4" +version = "5.3.0" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" @@ -12,7 +12,7 @@ name = "a3s_code" crate-type = ["cdylib"] [dependencies] -a3s-code-core = { version = "5.2.4", path = "../../core", features = ["s3", "serve"] } +a3s-code-core = { version = "5.3.0", path = "../../core", features = ["s3", "serve"] } pyo3 = { version = "0.23", features = ["multiple-pymethods"] } tokio = { version = "1.35", features = ["full"] } serde_json = "1.0" diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index beb9340f..8e578b5f 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "a3s-code" -version = "5.2.4" +version = "5.3.0" description = "A3S Code - Native Python bindings for the coding-agent runtime" readme = "README.md" license = {text = "MIT"} diff --git a/sdk/python/src/session.rs b/sdk/python/src/session.rs index c65f8278..7015bb30 100644 --- a/sdk/python/src/session.rs +++ b/sdk/python/src/session.rs @@ -889,6 +889,21 @@ impl PySession { run_in_asyncio_executor(py, callable.into_any()) } + /// Cancel the active operation and wait until the session is safe to reuse. + /// + /// A streaming worker that does not settle during ``grace_ms`` is aborted + /// and receives ``abort_grace_ms`` for cleanup. + #[pyo3(signature = (grace_ms=2000, abort_grace_ms=1000))] + fn cancel_and_settle(&self, py: Python<'_>, grace_ms: u64, abort_grace_ms: u64) -> bool { + let session = self.inner.clone(); + py.allow_threads(move || { + get_runtime().block_on(session.cancel_and_settle( + std::time::Duration::from_millis(grace_ms), + std::time::Duration::from_millis(abort_grace_ms), + )) + }) + } + /// Close the session and cancel any active operation. fn close(&self, py: Python<'_>) -> PyResult<()> { let session = self.inner.clone(); From f84ad66b1f69d925b26a758238f103ac76d7f33b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 01:16:43 +0800 Subject: [PATCH 2/3] release: validate 5.3.0 against published 5.2.7 --- .github/workflows/release.yml | 4 ++-- core/src/workspace/mod.rs | 3 +++ core/src/workspace/services.rs | 7 ++++++- core/src/workspace/tests.rs | 2 ++ scripts/check_semver.sh | 13 +++++++++---- 5 files changed, 22 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a1aa7eec..3084fbeb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,10 +52,10 @@ jobs: test -n "$VERSION" bash check-version.sh "$VERSION" - - name: Check public API compatibility with v5.2.4 + - name: Check public API compatibility with v5.2.7 run: | cargo install cargo-semver-checks --version 0.48.0 --locked - bash scripts/check_semver.sh 5.2.4 + bash scripts/check_semver.sh 5.2.7 - name: Check SDK protocol and API alignment run: | diff --git a/core/src/workspace/mod.rs b/core/src/workspace/mod.rs index bdf205f1..2995c384 100644 --- a/core/src/workspace/mod.rs +++ b/core/src/workspace/mod.rs @@ -105,6 +105,7 @@ pub struct WorkspaceCapabilities { pub exec: bool, pub search: bool, pub git: bool, + pub code_intelligence: bool, } impl WorkspaceCapabilities { @@ -115,6 +116,7 @@ impl WorkspaceCapabilities { exec: true, search: true, git: true, + code_intelligence: false, } } @@ -125,6 +127,7 @@ impl WorkspaceCapabilities { exec: false, search: false, git: false, + code_intelligence: false, } } } diff --git a/core/src/workspace/services.rs b/core/src/workspace/services.rs index eeebe06c..1a3cf235 100644 --- a/core/src/workspace/services.rs +++ b/core/src/workspace/services.rs @@ -71,6 +71,7 @@ impl WorkspaceServices { if git.is_none() { capabilities.git = false; } + capabilities.code_intelligence = false; Self { workspace_ref, capabilities, @@ -245,9 +246,11 @@ impl WorkspaceServices { &self, provider: Arc, ) -> Arc { + let mut capabilities = self.capabilities; + capabilities.code_intelligence = true; Arc::new(Self { workspace_ref: self.workspace_ref.clone(), - capabilities: self.capabilities, + capabilities, path_resolver: Arc::clone(&self.path_resolver), file_system: Arc::clone(&self.file_system), file_system_ext: self.file_system_ext.clone(), @@ -491,6 +494,7 @@ impl WorkspaceServicesBuilder { } pub fn code_intelligence(mut self, provider: Arc) -> Self { + self.capabilities.code_intelligence = true; self.code_intelligence = Some(provider); self } @@ -545,6 +549,7 @@ impl WorkspaceServicesBuilder { ); services.file_system_ext = self.file_system_ext; services.text_reader = self.text_reader; + services.capabilities.code_intelligence = self.code_intelligence.is_some(); services.code_intelligence = self.code_intelligence; services.git_stash = self.git_stash; services.git_worktree = self.git_worktree; diff --git a/core/src/workspace/tests.rs b/core/src/workspace/tests.rs index a94084af..6bf5f26f 100644 --- a/core/src/workspace/tests.rs +++ b/core/src/workspace/tests.rs @@ -296,6 +296,7 @@ fn workspace_services_builder_attaches_code_intelligence() { .code_intelligence(provider) .build(); + assert!(services.capabilities().code_intelligence); assert!(services.code_intelligence().is_some()); assert_eq!( services.code_intelligence().unwrap().status().state, @@ -319,6 +320,7 @@ fn code_intelligence_decorator_preserves_existing_services() { assert!(decorated.capabilities().read); assert!(decorated.capabilities().write); + assert!(decorated.capabilities().code_intelligence); assert!(decorated.code_intelligence().is_some()); assert_eq!(decorated.workspace_ref(), services.workspace_ref()); } diff --git a/scripts/check_semver.sh b/scripts/check_semver.sh index fd549b84..368867c4 100644 --- a/scripts/check_semver.sh +++ b/scripts/check_semver.sh @@ -1,12 +1,15 @@ #!/usr/bin/env bash -# Compare the current public API with the published 5.2.4 baseline. +# Compare the current public API with a verified published baseline. set -euo pipefail -BASELINE_VERSION="${1:-5.2.4}" +BASELINE_VERSION="${1:-5.2.7}" PACKAGE="a3s-code-core" case "$BASELINE_VERSION" in + 5.2.7) + BASELINE_SHA256="59993ad1e362628c7665d817318271faff5b3f775dfad0d648b1cc4a17099784" + ;; 5.2.4) BASELINE_SHA256="0066046ead6d44acac8a01a8bd1bd78c37aae02c14121f28ea77c15d9d0133a4" ;; @@ -51,8 +54,9 @@ tar -xzf "$ARCHIVE" -C "$TEMP_ROOT" # The published 5.2.4 manifest used the compatible range `1.4.1`, but a later # incompatible a3s-search release now satisfies that range. Pin the dependency -# version used by 5.2.4 so its unchanged public API can be documented. -python3 - "$SOURCE_ROOT/Cargo.toml" <<'PY' +# version used by 5.2.4 so its unchanged public API can still be documented. +if [[ "$BASELINE_VERSION" == "5.2.4" ]]; then + python3 - "$SOURCE_ROOT/Cargo.toml" <<'PY' import pathlib import sys @@ -64,6 +68,7 @@ if contents.count(original) != 1: raise SystemExit(f"unexpected baseline a3s-search declaration in {manifest}") manifest.write_text(contents.replace(original, replacement)) PY +fi cargo semver-checks check-release \ --package "$PACKAGE" \ From a7dbb995f269539aae2daf61c550f9d5e7310485 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 01:28:52 +0800 Subject: [PATCH 3/3] release: advance SemVer baseline to 5.2.8 --- .github/workflows/release.yml | 4 ++-- scripts/check_semver.sh | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3084fbeb..ea495ead 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,10 +52,10 @@ jobs: test -n "$VERSION" bash check-version.sh "$VERSION" - - name: Check public API compatibility with v5.2.7 + - name: Check public API compatibility with v5.2.8 run: | cargo install cargo-semver-checks --version 0.48.0 --locked - bash scripts/check_semver.sh 5.2.7 + bash scripts/check_semver.sh 5.2.8 - name: Check SDK protocol and API alignment run: | diff --git a/scripts/check_semver.sh b/scripts/check_semver.sh index 368867c4..2511c56d 100644 --- a/scripts/check_semver.sh +++ b/scripts/check_semver.sh @@ -3,10 +3,13 @@ set -euo pipefail -BASELINE_VERSION="${1:-5.2.7}" +BASELINE_VERSION="${1:-5.2.8}" PACKAGE="a3s-code-core" case "$BASELINE_VERSION" in + 5.2.8) + BASELINE_SHA256="059e9eefe6f2d0b816b9ec9f906878413a2f30fd1bc90a751c53b77972ff84a7" + ;; 5.2.7) BASELINE_SHA256="59993ad1e362628c7665d817318271faff5b3f775dfad0d648b1cc4a17099784" ;;