diff --git a/.github/workflows/language-images-release.yml b/.github/workflows/language-images-release.yml index 52ead5bb..3354ba7d 100644 --- a/.github/workflows/language-images-release.yml +++ b/.github/workflows/language-images-release.yml @@ -16,11 +16,13 @@ jobs: build-and-publish: runs-on: ubuntu-latest-large steps: - - uses: actions/checkout@v6 + - name: Checkout + uses: actions/checkout@v6 - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 + - name: Setup Docker buildx + uses: docker/setup-buildx-action@v3 with: driver-opts: network=host diff --git a/.github/workflows/service-images-release.yml b/.github/workflows/service-images-release.yml index d21ce662..3f747804 100644 --- a/.github/workflows/service-images-release.yml +++ b/.github/workflows/service-images-release.yml @@ -16,13 +16,16 @@ jobs: build-and-publish: runs-on: ubuntu-latest-large steps: - - uses: actions/checkout@v6 + - name: Checkout + uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 + - name: Setup Docker buildx + uses: docker/setup-buildx-action@v3 with: driver-opts: network=host diff --git a/.github/workflows/service-tests.yml b/.github/workflows/service-tests.yml deleted file mode 100644 index 01eb4161..00000000 --- a/.github/workflows/service-tests.yml +++ /dev/null @@ -1,189 +0,0 @@ -name: Service Tests - -on: - pull_request: - paths-ignore: - - '.github/workflows/*-client-*' - - '*.md' - - 'clients/**' - - 'docs/**' - push: - branches: - - main - paths-ignore: - - '.github/workflows/*-client-*' - - '*.md' - - 'clients/**' - - 'docs/**' - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -env: - LANGUAGE_TAG: "1.0.0" - SERVICE_TAG: actions-run-${{ github.run_id }} - -jobs: - check-rust: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - - name: Show Rust toolchain - run: rustc --version && cargo --version - - - uses: Swatinem/rust-cache@v2 - with: - cache-on-failure: true - - - name: Run Rust check - run: cargo build --locked --workspace --all-targets --all-features - env: - SERVICE_IMAGE_VERSION: ${{ env.SERVICE_TAG }} - LANGUAGE_IMAGE_VERSION: ${{ env.LANGUAGE_TAG }} - - - name: Run Rust fmt - run: cargo fmt --check - - build-images: - runs-on: ubuntu-latest - needs: check-rust - steps: - - uses: actions/checkout@v6 - - - uses: docker/setup-buildx-action@v3 - - # Build without Docker cache to ensure fresh Docker layers, but use GitHub Actions - # cache backend for BuildKit cache mounts (Cargo registry and build artifacts). - # This gives us reproducible builds while still caching Rust compilation. - - name: Build service images - run: | - ./scripts/build-images.sh \ - --all-services --service-tag=${{ env.SERVICE_TAG }} \ - --language-tag=${{ env.LANGUAGE_TAG }} \ - --cache=gha \ - --sequential - - - name: Export images - run: | - mkdir -p build-artifacts - docker save \ - nuanced-lsp-proxy:${{ env.SERVICE_TAG }} \ - nuanced-lsp-wrapper:${{ env.SERVICE_TAG }} \ - nuanced-lsp-watchdog:${{ env.SERVICE_TAG }} \ - -o build-artifacts/images.tar - - - uses: actions/upload-artifact@v6 - with: - name: built-images - path: build-artifacts/images.tar - - test-rust: - runs-on: ubuntu-latest - needs: build-images - steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - - name: Show Rust toolchain - run: rustc --version && cargo --version - - - uses: Swatinem/rust-cache@v2 - with: - cache-on-failure: true - - - uses: actions/download-artifact@v6 - with: - name: built-images - path: build-artifacts - - - name: Load service images - run: docker load -i build-artifacts/images.tar - - - name: Pre-pull Python language image from GHCR - run: docker pull ghcr.io/nuanced-dev/nuanced-lsp-python:${{ env.LANGUAGE_TAG }} - - - name: Run Rust tests - run: cargo test --locked --workspace --all-targets --all-features - env: - SERVICE_IMAGE_VERSION: ${{ env.SERVICE_TAG }} - LANGUAGE_IMAGE_VERSION: ${{ env.LANGUAGE_TAG }} - - test-container-lifecycle: - runs-on: ubuntu-latest - needs: build-images - steps: - - uses: actions/checkout@v6 - - - uses: actions/download-artifact@v6 - with: - name: built-images - path: build-artifacts - - - name: Load images - run: docker load -i build-artifacts/images.tar - - - name: Pre-pull Python language image from GHCR - run: docker pull ghcr.io/nuanced-dev/nuanced-lsp-python:${{ env.LANGUAGE_TAG }} - - - name: Run container lifecycle tests - run: ./scripts/test-container-lifecycle.sh --service-tag=${{ env.SERVICE_TAG }} --language-tag=${{ env.LANGUAGE_TAG }} - - test-watchdog: - runs-on: ubuntu-latest - needs: build-images - steps: - - uses: actions/checkout@v6 - - - uses: actions/download-artifact@v6 - with: - name: built-images - path: build-artifacts - - - name: Load images - run: docker load -i build-artifacts/images.tar - - - name: Pre-pull Python language image from GHCR - run: docker pull ghcr.io/nuanced-dev/nuanced-lsp-python:${{ env.LANGUAGE_TAG }} - - - name: Run watchdog tests - run: ./scripts/test-watchdog.sh --service-tag=${{ env.SERVICE_TAG }} --language-tag=${{ env.LANGUAGE_TAG }} - - test-all-endpoints: - runs-on: ubuntu-latest - needs: build-images - steps: - - uses: actions/checkout@v6 - - - uses: actions/download-artifact@v6 - with: - name: built-images - path: build-artifacts - - - name: Load images - run: docker load -i build-artifacts/images.tar - - - name: Pre-pull all language images from GHCR - run: | - # Pull all language images in parallel for faster startup - echo "Pulling language images from GHCR..." - docker pull ghcr.io/nuanced-dev/nuanced-lsp-python:${{ env.LANGUAGE_TAG }} & - docker pull ghcr.io/nuanced-dev/nuanced-lsp-typescript:${{ env.LANGUAGE_TAG }} & - docker pull ghcr.io/nuanced-dev/nuanced-lsp-rust:${{ env.LANGUAGE_TAG }} & - docker pull ghcr.io/nuanced-dev/nuanced-lsp-golang:${{ env.LANGUAGE_TAG }} & - docker pull ghcr.io/nuanced-dev/nuanced-lsp-java:${{ env.LANGUAGE_TAG }} & - docker pull ghcr.io/nuanced-dev/nuanced-lsp-clangd:${{ env.LANGUAGE_TAG }} & - docker pull ghcr.io/nuanced-dev/nuanced-lsp-csharp:${{ env.LANGUAGE_TAG }} & - docker pull ghcr.io/nuanced-dev/nuanced-lsp-php:${{ env.LANGUAGE_TAG }} & - docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-3.4.4:${{ env.LANGUAGE_TAG }} & - docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-sorbet-3.4.4:${{ env.LANGUAGE_TAG }} & - wait - echo "All language images pulled successfully" - - - name: Run all-endpoints tests - run: ./scripts/test-all-endpoints.sh --service-tag=${{ env.SERVICE_TAG }} --language-tag=${{ env.LANGUAGE_TAG }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..a6a1d958 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,332 @@ +name: Test Service and Client + +on: + pull_request: + paths-ignore: + - '*.md' + - 'docs/**' + push: + branches: + - main + paths-ignore: + - '*.md' + - 'docs/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + LANGUAGE_TAG: "1" + SERVICE_TAG: actions-run-${{ github.run_id }} + +jobs: + service-check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Show Rust toolchain + run: rustc --version && cargo --version + + - name: Setup Rust build cache + uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + + - name: Build + run: cargo build --locked --workspace --all-targets --all-features + env: + SERVICE_IMAGE_VERSION: ${{ env.SERVICE_TAG }} + LANGUAGE_IMAGE_VERSION: ${{ env.LANGUAGE_TAG }} + + - name: Check formatting + run: cargo fmt --check + + build-service-images: + runs-on: ubuntu-latest + needs: service-check + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Docker buildx + uses: docker/setup-buildx-action@v3 + + # Build without Docker cache to ensure fresh Docker layers, but use GitHub Actions + # cache backend for BuildKit cache mounts (Cargo registry and build artifacts). + # This gives us reproducible builds while still caching Rust compilation. + - name: Build service images + run: | + ./scripts/build-images.sh \ + --all-services --service-tag=${{ env.SERVICE_TAG }} \ + --language-tag=${{ env.LANGUAGE_TAG }} \ + --cache=gha \ + --sequential + + - name: Export images + run: | + mkdir -p build-artifacts + docker save \ + nuanced-lsp-proxy:${{ env.SERVICE_TAG }} \ + nuanced-lsp-wrapper:${{ env.SERVICE_TAG }} \ + nuanced-lsp-watchdog:${{ env.SERVICE_TAG }} \ + -o service-images.tar + + - name: Upload service images + uses: actions/upload-artifact@v6 + with: + name: service-images + path: service-images.tar + retention-days: 1 + + service-test-rust: + runs-on: ubuntu-latest + needs: build-service-images + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Show Rust toolchain + run: rustc --version && cargo --version + + - name: Setup Rust build cache + uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + + - name: Download service images + id: download-service-images + uses: actions/download-artifact@v6 + with: + name: service-images + + - name: Load service images + run: docker load -i ${{ steps.download-service-images.outputs.download-path }}/service-images.tar + + - name: Pre-pull Python language image from GHCR + run: docker pull ghcr.io/nuanced-dev/nuanced-lsp-python:${{ env.LANGUAGE_TAG }} + + - name: Run Rust tests + run: cargo test --locked --workspace --all-targets --all-features + env: + SERVICE_IMAGE_VERSION: ${{ env.SERVICE_TAG }} + LANGUAGE_IMAGE_VERSION: ${{ env.LANGUAGE_TAG }} + + service-test-container-lifecycle: + runs-on: ubuntu-latest + needs: build-service-images + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download service images + id: download-service-images + uses: actions/download-artifact@v6 + with: + name: service-images + + - name: Load service images + run: docker load -i ${{ steps.download-service-images.outputs.download-path }}/service-images.tar + + - name: Pre-pull Python language image from GHCR + run: docker pull ghcr.io/nuanced-dev/nuanced-lsp-python:${{ env.LANGUAGE_TAG }} + + - name: Run container lifecycle tests + run: ./scripts/test-container-lifecycle.sh --service-tag=${{ env.SERVICE_TAG }} --language-tag=${{ env.LANGUAGE_TAG }} + + service-test-watchdog: + runs-on: ubuntu-latest + needs: build-service-images + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download service images + id: download-service-images + uses: actions/download-artifact@v6 + with: + name: service-images + + - name: Load service images + run: docker load -i ${{ steps.download-service-images.outputs.download-path }}/service-images.tar + + - name: Pre-pull Python language image from GHCR + run: docker pull ghcr.io/nuanced-dev/nuanced-lsp-python:${{ env.LANGUAGE_TAG }} + + - name: Run watchdog tests + run: ./scripts/test-watchdog.sh --service-tag=${{ env.SERVICE_TAG }} --language-tag=${{ env.LANGUAGE_TAG }} + + service-test-all-endpoints: + runs-on: ubuntu-latest + needs: build-service-images + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Show Rust toolchain + run: rustc --version && cargo --version + + - name: Install websocat + run: cargo install websocat + + - name: Download service images + id: download-service-images + uses: actions/download-artifact@v6 + with: + name: service-images + + - name: Load service images + run: docker load -i ${{ steps.download-service-images.outputs.download-path }}/service-images.tar + + - name: Pre-pull all language images from GHCR + run: | + echo "Pulling language images from GHCR..." + docker pull ghcr.io/nuanced-dev/nuanced-lsp-clangd:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-csharp:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-golang:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-java:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-php:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-python:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-3.4.4:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-sorbet-3.4.4:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-rust:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-typescript:${{ env.LANGUAGE_TAG }} & + wait + echo "All language images pulled successfully" + + - name: Run all-endpoints tests + run: ./scripts/test-all-endpoints.sh --service-tag=${{ env.SERVICE_TAG }} --language-tag=${{ env.LANGUAGE_TAG }} + + typescript-client-check: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [18.x, 20.x, 22.x, 24.x] + defaults: + run: + working-directory: clients/typescript + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm install + + - name: Build + run: npm run build + + - name: Lint + run: npm run lint + + typescript-client-test: + runs-on: ubuntu-latest + needs: + - typescript-client-check + - build-service-images + defaults: + run: + working-directory: clients/typescript + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js 24.x + uses: actions/setup-node@v6 + with: + node-version: 24.x + + - name: Install dependencies + run: npm install + + - name: Build + run: npm run build + + - name: Download service images + id: download-service-images + uses: actions/download-artifact@v6 + with: + name: service-images + + - name: Load service images + run: docker load -i ${{ steps.download-service-images.outputs.download-path }}/service-images.tar + + - name: Pre-pull all language images from GHCR + run: | + echo "Pulling language images from GHCR..." + docker pull ghcr.io/nuanced-dev/nuanced-lsp-clangd:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-csharp:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-golang:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-java:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-php:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-python:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-3.2.2:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-3.2.6:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-3.3.5:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-3.3.6:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-3.4.1:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-3.4.2:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-3.4.4:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-sorbet-3.2.2:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-sorbet-3.2.6:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-sorbet-3.3.5:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-sorbet-3.3.6:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-sorbet-3.4.1:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-sorbet-3.4.2:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-sorbet-3.4.4:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-rust:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-typescript:${{ env.LANGUAGE_TAG }} & + wait + echo "All language images pulled successfully" + + - name: Test + run: npm run test + env: + NUANCED_LSP_TIMEOUT: 240 + FAIL_FAST: 1 + + vscode-client-check: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [18.x, 20.x, 22.x, 24.x] + defaults: + run: + working-directory: clients/vscode + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm install + + - name: Build + run: npm run build + + - name: Lint + run: npm run lint diff --git a/.github/workflows/typescript-client-release.yml b/.github/workflows/typescript-client-release.yml index 9e7525db..311e9c32 100644 --- a/.github/workflows/typescript-client-release.yml +++ b/.github/workflows/typescript-client-release.yml @@ -50,6 +50,12 @@ jobs: working-directory: clients/typescript run: npm install + - name: Build + run: npm run build + + - name: Pull images to ensure they are published + run: dist/cli.cjs pull --all-services --all-languages + - name: Publish to npm run: npm publish diff --git a/.github/workflows/typescript-client-tests.yml b/.github/workflows/typescript-client-tests.yml deleted file mode 100644 index 69443aa2..00000000 --- a/.github/workflows/typescript-client-tests.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: TypeScript Client Tests - -on: - pull_request: - paths: - - '.github/workflows/typescript-*' - - 'clients/typescript/**' - - '!clients/typescript/*.md' - - '!clients/typescript/docs/**' - push: - branches: - - main - paths: - - '.github/workflows/typescript-*' - - 'clients/typescript/**' - - '!clients/typescript/*.md' - - '!clients/typescript/docs/**' - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -defaults: - run: - working-directory: clients/typescript - -jobs: - check: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node-version: [18.x, 20.x, 22.x, 24.x] - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v6 - with: - node-version: ${{ matrix.node-version }} - - - name: Install dependencies - run: npm install - - - name: Build - run: npm run build - - - name: Lint - run: npm run lint - - test: - runs-on: ubuntu-latest - needs: check - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Node.js 24.x - uses: actions/setup-node@v6 - with: - node-version: 24.x - - - name: Install dependencies - run: npm install - - - name: Build - run: npm run build - - - name: Test - run: npm run test - env: - NUANCED_LSP_TIMEOUT: 240 - FAIL_FAST: 1 diff --git a/CHANGELOG.services.md b/CHANGELOG.services.md index a7cad829..0318b3a4 100644 --- a/CHANGELOG.services.md +++ b/CHANGELOG.services.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2025-01-20 + +### Added + +- A new `/lsp/ws` WebSocket endpoints exposes the proxy as a proper LSP server. + ## [0.4.9] - 2025-01-15 ### Changes @@ -20,6 +26,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 with the `CONTAINER_REGISTRY` environment variable at build time or run time. The default is GHCR. -- Proxy initialization changed to allow failing fast if other containers fail to start. The proxy shuts down immediately if other service containers fail to start. The language status reported by the health endpoint now reports healthy / unhealthy. Previously a false status could also mean the language was still initializing. +- Proxy initialization changed to allow failing fast if other containers + fail to start. The proxy shuts down immediately if other service + containers fail to start. The language status reported by the health + endpoint now reports healthy / unhealthy. Previously a false status + could also mean the language was still initializing. -- The proxy will now always try to use local images when starting containers, before falling back to pulling and using images from the registry. +- The proxy will now always try to use local images when starting + containers, before falling back to pulling and using images from + the registry. diff --git a/Cargo.lock b/Cargo.lock index e5e58df2..43804ff8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -223,6 +223,20 @@ dependencies = [ "syn", ] +[[package]] +name = "actix-ws" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a1fb4f9f2794b0aadaf2ba5f14a6f034c7e86957b458c506a8cb75953f2d99" +dependencies = [ + "actix-codec", + "actix-http", + "actix-web", + "bytestring", + "futures-core", + "tokio", +] + [[package]] name = "adler2" version = "2.0.1" @@ -627,6 +641,12 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.10.1" @@ -746,7 +766,7 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "common" -version = "0.4.9" +version = "0.5.0" dependencies = [ "ast-grep", "async-trait", @@ -968,6 +988,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + [[package]] name = "deranged" version = "0.5.5" @@ -2413,11 +2439,12 @@ dependencies = [ [[package]] name = "proxy" -version = "0.4.9" +version = "0.5.0" dependencies = [ "actix-cors", "actix-files", "actix-web", + "actix-ws", "async-trait", "bollard", "clap", @@ -2447,6 +2474,7 @@ dependencies = [ "tempfile", "thiserror 1.0.69", "tokio", + "tokio-tungstenite", "tower 0.4.13", "tracing", "tracing-subscriber", @@ -3369,6 +3397,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.17" @@ -3777,6 +3817,25 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.3.1", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + [[package]] name = "typenum" version = "1.19.0" @@ -3849,6 +3908,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4370,14 +4435,16 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "wrapper" -version = "0.4.9" +version = "0.5.0" dependencies = [ "actix-web", + "actix-ws", "ast-grep", "async-trait", "clap", "common", "env_logger", + "futures", "glob", "ignore", "log", diff --git a/Cargo.toml b/Cargo.toml index ec16cb9c..7a6105f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.4.9" +version = "0.5.0" edition = "2021" authors = ["Nuanced Dev"] repository = "https://github.com/nuanced-dev/lsp" @@ -17,6 +17,7 @@ publish = false [workspace.dependencies] actix-web = "4.0" +actix-ws = "0.3" ast-grep = { version = "0.39", default-features = false } async-trait = "0.1" clap = { version = "4.3", features = ["derive"] } @@ -24,7 +25,7 @@ env_logger = "0.10" glob = "0.3" ignore = "0.4.23" # Pin to avoid edition2024 requirement in 0.4.25+ log = "0.4" -lsp-types = "0.95" +lsp-types = "0.95" # Upgrade needs resolution or workaround for https://github.com/gluon-lang/lsp-types/issues/284 serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" strum = "0.26" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 04497615..eaadad7e 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -217,6 +217,14 @@ Follow these steps to add a new language: ## Debugging +**Run endpoint tests without cleaning up containers:** + +```bash +scripts/test-all-endpoints.sh --no-cleanup +``` + +This leaves all containers running after the tests and allows inspecting the logs for troubleshooting. + **View service logs:** ```bash diff --git a/clients/typescript/CHANGELOG.md b/clients/typescript/CHANGELOG.md index dd041d22..98607e41 100644 --- a/clients/typescript/CHANGELOG.md +++ b/clients/typescript/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2025-01-20 + +### Added + +- A new `server` command runs Nuanced LSP as an LSP server. + +### Changed + +- Updated service dependency to version to 0.5.0. + +- The `pull` method now follows the command, and supports pulling service + and/or language images without having to construct the iamge names. + ## [0.4.0] - 2025-01-14 ### Changed diff --git a/clients/typescript/DEVELOPMENT.md b/clients/typescript/DEVELOPMENT.md index 12d22ba2..55fcb16e 100644 --- a/clients/typescript/DEVELOPMENT.md +++ b/clients/typescript/DEVELOPMENT.md @@ -112,11 +112,11 @@ For `0.x.y` versions, the minor version is treated like the major version. **Image versions:** -The TypeScript client depends on **published** versions of the service and language images. The versions used by the client are defined in `src/defaults.ts`. +The TypeScript client depend on the image versions defined in `src/defaults.ts`. -It is intentional that the client does not automatically depend the image versions in the repository. This allows developing the service before moving the client to this new version. +A local test run will use locally built images if available. The testing workflow will have service images built from the repo available. -_Note that if images and the client are updated in the same pull request, the images need to be released before the client tests can succeed in CI._ +The release workflow will **not** build any images and verify that all images can be pulled from the registry before publishign the release. ## Release diff --git a/clients/typescript/eslint.config.js b/clients/typescript/eslint.config.js index 07c47d2c..412c6cba 100644 --- a/clients/typescript/eslint.config.js +++ b/clients/typescript/eslint.config.js @@ -106,10 +106,11 @@ export default [ }, { ignores: [ + "*.config.js", "coverage/", "dist/", - "*.config.js", "node_modules/", + "src/__generated/", "tests/workspaces/*/", ], }, diff --git a/clients/typescript/package.json b/clients/typescript/package.json index 0f1be67d..9eb11574 100644 --- a/clients/typescript/package.json +++ b/clients/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@nuanced-dev/lsp", - "version": "0.4.0", + "version": "0.5.0", "description": "TypeScript library and CLI for Nuanced LSP", "license": "MIT", "author": "Nuanced", @@ -43,12 +43,14 @@ }, "dependencies": { "commander": "^14.0.2", + "ws": "^8.18.0", "zod": "^3.23.8" }, "devDependencies": { "@eslint/js": "^9.37.0", "@stylistic/eslint-plugin": "^5.4.0", "@types/node": "^20.19.25", + "@types/ws": "^8.5.13", "esbuild": "^0.25.9", "eslint": "^9.33.0", "eslint-config-prettier": "^10.1.5", diff --git a/clients/typescript/scripts/generate-version.mjs b/clients/typescript/scripts/generate-version.mjs index 1a4e4684..2a48a6af 100755 --- a/clients/typescript/scripts/generate-version.mjs +++ b/clients/typescript/scripts/generate-version.mjs @@ -23,8 +23,7 @@ const pkg = JSON.parse(fs.readFileSync(pkgJson, "utf8")); fs.mkdirSync(outDir, { recursive: true }); // Emit TypeScript module (formatted to pass Prettier) -const contents = `/* eslint-disable */ -// AUTO-GENERATED FILE. Do not edit. +const contents = `// AUTO-GENERATED FILE. Do not edit. // Generated by scripts/generate-version.mjs export const VERSION = ${JSON.stringify(pkg.version)}; `; diff --git a/clients/typescript/src/__generated/version.ts b/clients/typescript/src/__generated/version.ts index c2b999bb..ae8863ac 100644 --- a/clients/typescript/src/__generated/version.ts +++ b/clients/typescript/src/__generated/version.ts @@ -1,4 +1,3 @@ -/* eslint-disable */ // AUTO-GENERATED FILE. Do not edit. // Generated by scripts/generate-version.mjs -export const VERSION = "0.4.0"; +export const VERSION = "0.5.0"; diff --git a/clients/typescript/src/cli.ts b/clients/typescript/src/cli.ts index 77aa647f..48b4f418 100644 --- a/clients/typescript/src/cli.ts +++ b/clients/typescript/src/cli.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { Command } from "commander"; +import { Command, Option } from "commander"; import { isErr } from "./types.js"; import type { BaseCommandOptions, @@ -31,6 +31,7 @@ import type { Result, RunCommandOptions, RunResult, + ServerCommandOptions, StatusCommandOptions, StatusResult, UpCommandOptions, @@ -49,6 +50,21 @@ import { } from "./defaults.js"; import type { NuancedLspClient } from "./client.js"; +async function generateRandomContainerName(): Promise { + const { randomUUID } = await import("node:crypto"); + const id = randomUUID().slice(0, 8); + return `nuanced-lsp-${id}`; +} + +async function generateSharedContainerName(workspace: string): Promise { + const { createHash } = await import("node:crypto"); + const { resolve } = await import("node:path"); + const absolutePath = resolve(workspace); + const hash = createHash("sha256").update(absolutePath).digest("hex"); + const shortHash = hash.slice(0, 12); + return `nuanced-lsp-${shortHash}`; +} + // Lazy-load client (avoids startup cost if user only runs --help, etc.) async function lspClient(opts: { containerName?: string; @@ -251,6 +267,38 @@ async function upCommand( }); } +async function serverCommand( + workspace: string, + opts: ServerCommandOptions, +): Promise { + let containerName: string; + if (opts.shared) { + containerName = await generateSharedContainerName(workspace); + } else { + containerName = await generateRandomContainerName(); + } + + const client = await lspClient({ + containerName, + ...opts, + lspPort: opts.hostPort, + }); + + if (opts.shared && opts.sharedMode === "down") { + await client.down(); + return; + } + + try { + // Run the LSP server stdio loop + const { runLspServer } = await import("./server.js"); + await runLspServer(client, workspace, opts, process.stdin, process.stdout); + } catch { + // Fatal errors are already logged via window/logMessage + process.exitCode = 1; + } +} + async function downCommand(opts: DownCommandOptions): Promise { const client = await lspClient({ ...opts, @@ -360,14 +408,10 @@ async function pullCommand(opts: PullCommandOptions): Promise { sudo: getSudoFlag(opts), }); - // Import constants - const { ALL_SERVICES, ALL_LANGUAGES, selectLanguages } = - await import("./constants.js"); - // Parse services - let services: string[] = []; + let services: "all" | string[] | undefined; if (opts.allServices) { - services = [...ALL_SERVICES]; + services = "all"; } else if (opts.services) { services = opts.services .split(",") @@ -375,76 +419,43 @@ async function pullCommand(opts: PullCommandOptions): Promise { .filter(Boolean); } - // Parse languages and expand "ruby" and "ruby-sorbet" into all supported versions - let languages: string[] = []; + // Parse languages + let languages: "all" | string[] | undefined; if (opts.allLanguages) { - languages = [...ALL_LANGUAGES]; + languages = "all"; } else if (opts.languages) { - const names = opts.languages + languages = opts.languages .split(",") .map((s) => s.trim()) .filter(Boolean); - languages = selectLanguages(names, ALL_LANGUAGES); } // Validate at least one of services or languages is specified - if (services.length === 0 && languages.length === 0) { + if (services == undefined && languages == undefined) { log.err( "At least one of, --all-languages --all-services, --languages=, or --services= must be specified", ); process.exit(1); } - // Get registry and image versions - const containerRegistry = - opts.containerRegistry ?? - process.env.CONTAINER_REGISTRTY ?? - DEFAULT_CONTAINER_REGISTRY; - const languageVersion = - opts.languageImageVersion ?? - process.env.LANGUAGE_IMAGE_VERSION ?? - DEFAULT_LANGUAGE_IMAGE_VERSION; - const serviceVersion = - opts.serviceImageVersion ?? - process.env.SERVICE_IMAGE_VERSION ?? - DEFAULT_SERVICE_IMAGE_VERSION; - - // Build list of images to pull - const images: string[] = []; - for (const service of services) { - images.push( - `${containerRegistry}/nuanced-lsp-${service}:${serviceVersion}`, - ); - } - for (const language of languages) { - images.push( - `${containerRegistry}/nuanced-lsp-${language}:${languageVersion}`, - ); - } - - // Pull each image - let failed = false; - for (const image of images) { - if (!opts.json) log.info(`Pulling image '${image}'...`); - - const res = await client.pull(image, opts.stream); - - handleResult(res, { - json: !!opts.json, - fallbackErrMsg: `Failed to pull image '${image}`, - onSuccess: (data: PullResult) => { - log.info(`Successfully pulled image: ${data.image}`); - }, - onError: (err) => { - log.err(`Failed to pull image '${image}: ${err.message}`); - failed = true; - }, - }); - } + const res = await client.pull({ + services, + languages, + containerRegistry: opts.containerRegistry, + languageImageVersion: opts.languageImageVersion, + serviceImageVersion: opts.serviceImageVersion, + stream: opts.stream, + }); - if (failed) { - process.exit(1); - } + handleResult(res, { + json: !!opts.json, + fallbackErrMsg: "Failed to pull images", + onSuccess: (data: PullResult[]) => { + for (const result of data) { + log.info(`Successfully pulled image: ${result.image}`); + } + }, + }); } async function healthCommand(opts: HealthCommandOptions): Promise { @@ -719,6 +730,60 @@ program .option("--env-file ", "Path to an env file") .action(upCommand); +program + .command("server") + .description( + ansi.pink( + "Start a stdio LSP server that forwards requests to the Nuanced LSP container.", + ), + ) + .argument("", "Host workspace directory to mount") + .option( + "--host-port ", + `Host port to map to ${DEFAULT_HOST_PORT}. Note: Use port 0 for a dynamically assigned host port from Docker.`, + (v: string) => parseInt(v, 10), + ) + .option( + "--bind-host ", + "Host/IP to bind (e.g. 127.0.0.1, 0.0.0.0, 192.168.1.10)", + DEFAULT_BIND_HOST, + ) + .option( + "--container-registry ", + `Container registry (default: ${DEFAULT_CONTAINER_REGISTRY})`, + ) + .option( + "--language-image-version ", + "Language image version (default: from service)", + ) + .option( + "--service-image-version ", + `Nuanced LSP service image version (default: ${DEFAULT_SERVICE_IMAGE_VERSION})`, + ) + .option( + "--timeout ", + `Health check poll loop timeout seconds (<=0 to skip) (default: ${DEFAULT_TIMEOUT_SECS})`, + (v: string) => parseInt(v, 10), + ) + .option("--sudo", "Run Docker commands with sudo") + .option("--ro", "Mount workspace as read-only (default is read-write)") + .option("--debug", "Run the container with debug logging enabled") + .option( + "--env ", + "Set environment variable (repeatable)", + (v: string, prev: string[] | undefined) => (prev ? prev.concat(v) : [v]), + ) + .option("--env-file ", "Path to an env file") + .option("--shared", "Share container across multiple server instances") + .addOption( + new Option("--shared-mode ", "Mode for shared container").choices([ + "up", + "down", + "use", + ]), + ) + .action(serverCommand); + program .command("down") .description(ansi.pink("Stop the Nuanced LSP container.")) diff --git a/clients/typescript/src/client.ts b/clients/typescript/src/client.ts index c5f78dd0..d99c260c 100644 --- a/clients/typescript/src/client.ts +++ b/clients/typescript/src/client.ts @@ -1,54 +1,53 @@ +import type WebSocket from "ws"; import type { + ClientPullOptions, DefinitionsInFileResult, DockerResult, DownResult, FilePosition, FindDefinitionResult, FindIdentifierResult, - FindReferencesResult, FindReferencedSymbolsResult, + FindReferencesResult, HealthResult, HttpResult, IdentifierPosition, - UpResult, + JsonRpcMessage, ListFilesResult, + LogsResult, LspPosition, LspRange, + PullResult, ReadSourceResult, RunResult, StatusResult, - LogsResult, - PullResult, + UpResult, } from "./types.js"; import { DEFAULT_CONTAINER_NAME, DEFAULT_CONTAINER_PORT, + DEFAULT_CONTAINER_REGISTRY, DEFAULT_HOST_PORT, DEFAULT_HOST_URL, - DEFAULT_TIMEOUT_SECS, + DEFAULT_LANGUAGE_IMAGE_VERSION, DEFAULT_RETRIES, + DEFAULT_SERVICE_IMAGE_VERSION, + DEFAULT_TIMEOUT_SECS, } from "./defaults.js"; +import { err, ok } from "./types.js"; // Precompiled tiny helpers const TRAILING_SLASH_RE = /\/$/; type HttpModule = typeof import("./http.js"); -let _http: HttpModule | null = null; async function http(): Promise { - // Lazy-load only when a data-plane method is called - if (_http) return _http; - _http = await import("./http.js"); - return _http!; + return import("./http.js"); } type ProxyModule = typeof import("./proxy.js"); -let _proxy: ProxyModule | null = null; async function proxy(): Promise { - // Lazy-load only when a docker lifecycle method is called - if (_proxy) return _proxy; - _proxy = await import("./proxy.js"); - return _proxy!; + return await import("./proxy.js"); } export class NuancedLspClient { @@ -60,6 +59,7 @@ export class NuancedLspClient { private timeoutSecs: number; private fullProxyUrl: string | undefined; + private nextId = 1; // Cache for list-files results to avoid redundant calls // TODO: Add proper cache invalidation when files are modified @@ -129,6 +129,16 @@ export class NuancedLspClient { return this.fullProxyUrl; } + /** + * Create a WebSocket connection for bidirectional LSP communication + */ + async lsp_ws(): Promise { + const httpUrl = await this.proxyUrl(); + const wsUrl = httpUrl.replace(/^http/, "ws"); + const WS = (await import("ws")).default; + return new WS(`${wsUrl}/lsp/ws`); + } + // ---- Lifecycle (docker) --------------------------------------------------- async up( workspace: string, @@ -211,12 +221,67 @@ export class NuancedLspClient { return status(this.containerName, this.sudo); } - async pull( - image?: string, - stream?: boolean, - ): Promise> { + async pull(opts: ClientPullOptions): Promise> { const { pull } = await proxy(); - return pull(image, this.sudo, stream); + + // Import constants + const { ALL_SERVICES, ALL_LANGUAGES, selectLanguages } = + await import("./constants.js"); + + // Parse services + let services: string[] = []; + if (opts.services === "all") { + services = [...ALL_SERVICES]; + } else if (Array.isArray(opts.services)) { + services = opts.services; + } + + // Parse languages and expand "ruby" and "ruby-sorbet" into all supported versions + let languages: string[] = []; + if (opts.languages === "all") { + languages = [...ALL_LANGUAGES]; + } else if (Array.isArray(opts.languages)) { + languages = selectLanguages(opts.languages, ALL_LANGUAGES); + } + + // Get registry and image versions + const containerRegistry = + opts.containerRegistry ?? + process.env.CONTAINER_REGISTRTY ?? + DEFAULT_CONTAINER_REGISTRY; + const languageVersion = + opts.languageImageVersion ?? + process.env.LANGUAGE_IMAGE_VERSION ?? + DEFAULT_LANGUAGE_IMAGE_VERSION; + const serviceVersion = + opts.serviceImageVersion ?? + process.env.SERVICE_IMAGE_VERSION ?? + DEFAULT_SERVICE_IMAGE_VERSION; + + // Build list of images to pull + const images: string[] = []; + for (const service of services) { + images.push( + `${containerRegistry}/nuanced-lsp-${service}:${serviceVersion}`, + ); + } + for (const language of languages) { + images.push( + `${containerRegistry}/nuanced-lsp-${language}:${languageVersion}`, + ); + } + + // Pull each image + const results: PullResult[] = []; + for (const image of images) { + const res = await pull(image, this.sudo, opts.stream); + if (!res.ok) { + return res as DockerResult; + } + results.push(res.data); + } + + return { ok: true, data: results }; } // ---- Health (data-plane) -------------------------------------------------- @@ -393,4 +458,69 @@ export class NuancedLspClient { this.resolveTimeout(timeoutSecs), ); } + + // ---- LSP JSON-RPC forwarding ----------------------------------------------- + + async request( + method: string, + params: any | undefined, + timeoutSecs?: number, + ): Promise> { + const url = await this.proxyUrl(); + const { httpRequestWithRetries } = await http(); + const id = this.nextId++; + const request: JsonRpcMessage = { + jsonrpc: "2.0", + id, + method, + params, + }; + const result = await httpRequestWithRetries( + "POST", + "/lsp", + url, + request, + undefined, + this.retries, + this.resolveTimeout(timeoutSecs), + ); + + if (!result.ok) { + return result; + } + + // If JSON-RPC response contains an error, translate to 500 HTTP error + if (result.data.error) { + return err({ + status_code: 500, + error: result.data.error, + }); + } + + // Return just the result data + return ok(result.data.result); + } + + async notify( + method: string, + params: any | undefined, + timeoutSecs?: number, + ): Promise> { + const url = await this.proxyUrl(); + const { httpRequestWithRetries } = await http(); + const request: JsonRpcMessage = { + jsonrpc: "2.0", + method, + params, + }; + return httpRequestWithRetries( + "POST", + "/lsp", + url, + request, + undefined, + this.retries, + this.resolveTimeout(timeoutSecs), + ); + } } diff --git a/clients/typescript/src/defaults.ts b/clients/typescript/src/defaults.ts index fc8471f0..11eb996c 100644 --- a/clients/typescript/src/defaults.ts +++ b/clients/typescript/src/defaults.ts @@ -10,7 +10,7 @@ export const DEFAULT_CONTAINER_REGISTRY = "ghcr.io/nuanced-dev"; export const DEFAULT_LANGUAGE_IMAGE_VERSION = "1"; // Service image version -export const DEFAULT_SERVICE_IMAGE_VERSION = "0.4.9"; +export const DEFAULT_SERVICE_IMAGE_VERSION = "0.5.0"; // DEFAULT_BIND_HOST is the default host address to which the LSProxy container binds. // The default is the local loopback, implying the container only accepts connections from the same machine. diff --git a/clients/typescript/src/proxy.ts b/clients/typescript/src/proxy.ts index d3828ec3..1aeb350e 100644 --- a/clients/typescript/src/proxy.ts +++ b/clients/typescript/src/proxy.ts @@ -55,17 +55,13 @@ async function findImage( sudo?: boolean, stream?: boolean, ): Promise> { - const registryImage = image.includes("/") - ? image - : (() => { - // Check if local image exists - if (imageExists(image, sudo)) { - return image; - } + // Check if local image exists + if (imageExists(image, sudo)) { + return ok(image); + } - // Build the registry-prefixed image name - return `${registry}/${image}`; - })(); + // Build the registry-prefixed image name + const registryImage = image.includes("/") ? image : `${registry}/${image}`; // Try to pull from registry const pullRes = await pull(registryImage, sudo, stream); @@ -413,7 +409,7 @@ export async function up( const httpErr = h.data; return err({ error_code: httpErr.status_code ?? 1, - message: `Health check polling loop timed out before successful response: ${httpErr.error}`, + message: `Health check polling loop timed out before successful response: ${JSON.stringify(httpErr.error)}`, stdout: "", stderr: "", }); @@ -677,14 +673,11 @@ export async function status( /** Pull image. Supports optional streaming. Never throws. */ export async function pull( - image?: string, + image: string, sudo?: boolean, stream?: boolean, ): Promise> { - const img = - image ?? - `${DEFAULT_CONTAINER_REGISTRY}/${PROXY_IMAGE_BASE}:${DEFAULT_SERVICE_IMAGE_VERSION}`; - const args = ["pull", img]; + const args = ["pull", image]; const r = runDockerCmd(args, { sudo, stream }); if (!r.ok) { return err({ @@ -694,5 +687,5 @@ export async function pull( stderr: r.stderr.trim(), }); } - return ok({ image: img, stdout: r.stdout.trim() }); + return ok({ image, stdout: r.stdout.trim() }); } diff --git a/clients/typescript/src/server.ts b/clients/typescript/src/server.ts new file mode 100644 index 00000000..8789d40d --- /dev/null +++ b/clients/typescript/src/server.ts @@ -0,0 +1,464 @@ +import type { Readable, Writable } from "node:stream"; +import WebSocket from "ws"; +import type { NuancedLspClient } from "./client.js"; +import type { JsonRpcMessage, ServerCommandOptions } from "./types.js"; +import { isErr, JsonRpcErrorCode } from "./types.js"; + +// ---- Types ------------------------------------------------------------------ + +/** + * LSP MessageType for window/logMessage notifications. + */ +enum MessageType { + Error = 1, + Warning = 2, + Info = 3, + Log = 4, + Debug = 5, +} + +// ---- High-level server function --------------------------------------------- + +class LspServer { + private readonly client: NuancedLspClient; + private readonly workspace: string; + private readonly opts: ServerCommandOptions; + private readonly input: Readable; + private readonly output: Writable; + private readonly logLevel: MessageType; + private shutdownReceived = false; + private isShuttingDown = false; + private ws: WebSocket | null = null; + + constructor( + client: NuancedLspClient, + workspace: string, + opts: ServerCommandOptions, + input: Readable, + output: Writable, + ) { + this.client = client; + this.workspace = workspace; + this.opts = opts; + this.input = input; + this.output = output; + this.logLevel = opts.debug ? MessageType.Debug : MessageType.Info; + } + + async run(): Promise { + try { + await this.startServer(); + if (this.opts.shared && this.opts.sharedMode === "up") { + await this.sendLogMessage(MessageType.Info, "Nuanced LSP started"); + return; + } + this.setupSignalHandlers(); + await this.sendLogMessage(MessageType.Info, "Nuanced LSP started"); + await this.processMessages(); + } catch (err) { + await this.sendLogMessage( + MessageType.Error, + `Fatal Nuanced LSP error: ${err}`, + ); + throw err; + } finally { + await this.stopServer(); + if (!this.opts.shared) { + await this.sendLogMessage(MessageType.Info, "Nuanced LSP stopped"); + } else { + await this.sendLogMessage( + MessageType.Info, + "Nuanced LSP disconnected (shared container left running)", + ); + } + process.exit(0); + } + } + + private async startServer(): Promise { + let mustStartServer = true; + + if (this.opts.shared) { + const statusRes = await this.client.status(); + if (statusRes.ok) { + await this.sendLogMessage( + MessageType.Info, + `Using existing shared container '${this.client.containerName}'`, + ); + mustStartServer = false; + } + } + + if (mustStartServer) { + const res = await this.client.up(this.workspace, { + containerRegistry: this.opts.containerRegistry, + languageImageVersion: this.opts.languageImageVersion, + serviceImageVersion: this.opts.serviceImageVersion, + timeout: this.opts.timeout, + ro: this.opts.ro, + bindHost: this.opts.bindHost, + debug: this.opts.debug, + env: this.opts.env, + envFile: this.opts.envFile, + }); + + if (isErr(res)) { + await this.sendLogMessage( + MessageType.Error, + `Failed to start LSP server container: ${JSON.stringify(res.data)}`, + ); + throw new Error("Failed to start container"); + } + } + + await this.waitUntilServerIsHealthy(); + + this.ws = await this.client.lsp_ws(); + await this.connectWebSocket(); + } + + private async waitUntilServerIsHealthy(): Promise { + const timeoutMs = 60000; + const initialDelayMs = 100; + const maxDelayMs = 5000; + const startTime = Date.now(); + + let attempt = 0; + while (Date.now() - startTime < timeoutMs) { + const result = await this.client.health(); + + if (result.ok) { + if (result.data.status === "ok") { + return; + } else if (result.data.status === "not ok") { + throw new Error("Server is unhealthy"); + } + } + + const delayMs = Math.min( + initialDelayMs * Math.pow(2, attempt), + maxDelayMs, + ); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + attempt++; + } + + throw new Error("Server health check timed out after 60s"); + } + + private async connectWebSocket(): Promise { + return new Promise((resolve, reject) => { + if (!this.ws) { + reject(new Error("WebSocket not initialized")); + return; + } + + this.ws.on("open", () => { + this.sendLogMessage(MessageType.Debug, "WebSocket connected"); + resolve(); + }); + + this.ws.on("error", (err: Error) => { + this.sendLogMessage( + MessageType.Error, + `WebSocket error: ${err.name}: ${err.message}`, + ); + }); + + this.ws.on("close", (ev: { reason: string }) => { + this.sendLogMessage( + MessageType.Debug, + `WebSocket closed: ${ev.reason}`, + ); + }); + + this.ws.on("message", (data: Buffer | string) => { + try { + const message = JSON.parse(data.toString()) as JsonRpcMessage; + // Forward server notifications to stdout + writeMessage(this.output, message); + } catch (rawErr) { + const err = rawErr as Error; + this.sendLogMessage( + MessageType.Error, + `Failed to parse WebSocket message: ${err.name}: ${err.message}`, + ); + } + }); + }); + } + + private setupSignalHandlers(): void { + process.on("SIGINT", () => { + this.stopServer().then(() => process.exit(0)); + }); + process.on("SIGTERM", () => { + this.stopServer().then(() => process.exit(0)); + }); + } + + private async stopServer(): Promise { + if (this.isShuttingDown) return; + this.isShuttingDown = true; + + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + if (!this.opts.shared) { + await this.client.down(); + } + } + + private async processMessages(): Promise { + while (true) { + const message = await readMessage(this.input); + + if (message === null) { + break; + } + + if (message.method === "shutdown") { + await this.handleShutdownRequest(message); + } else if (message.method === "exit") { + this.handleExitNotification(); + } else { + await this.forwardMessage(message); + } + } + } + + private async handleShutdownRequest(message: JsonRpcMessage): Promise { + await this.sendLogMessage( + MessageType.Debug, + "Nuanced LSP shutdown request received", + ); + + this.shutdownReceived = true; + await this.stopServer(); + + const shutdownResponse: JsonRpcMessage = { + jsonrpc: "2.0", + id: message.id ?? null, + result: null, + }; + await writeMessage(this.output, shutdownResponse); + } + + private handleExitNotification(): void { + this.sendLogMessage( + MessageType.Debug, + "Nuanced LSP exit notification received", + ); + + const exitCode = this.shutdownReceived ? 0 : 1; + process.exit(exitCode); + } + + private async forwardMessage(message: JsonRpcMessage): Promise { + await this.sendLogMessage( + MessageType.Debug, + `Nuanced LSP request: ${JSON.stringify(message)}`, + ); + + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + await this.sendLogMessage(MessageType.Error, "WebSocket not connected"); + + if (message.id !== undefined && message.id !== null) { + const errorResponse: JsonRpcMessage = { + jsonrpc: "2.0", + id: message.id, + error: { + code: JsonRpcErrorCode.InternalError, + message: "WebSocket not connected", + }, + }; + await writeMessage(this.output, errorResponse); + } + return; + } + + try { + this.ws.send(JSON.stringify(message)); + } catch (err) { + await this.sendLogMessage( + MessageType.Error, + `Nuanced LSP forwarding error: ${err}`, + ); + + // Only send error response for requests + if (message.id !== undefined && message.id !== null) { + const errorResponse: JsonRpcMessage = { + jsonrpc: "2.0", + id: message.id, + error: { + code: JsonRpcErrorCode.InternalError, + message: "Failed to send message over WebSocket", + data: String(err), + }, + }; + await writeMessage(this.output, errorResponse); + } + } + } + + private async sendLogMessage( + type: MessageType, + message: string, + ): Promise { + if (type > this.logLevel) return; + + const notification: JsonRpcMessage = { + jsonrpc: "2.0", + method: "window/logMessage", + params: { + type, + message, + }, + }; + + await writeMessage(this.output, notification); + } +} + +/** + * Start an LSP server that manages a container and forwards JSON-RPC requests. + */ +export async function runLspServer( + client: NuancedLspClient, + workspace: string, + opts: ServerCommandOptions, + input: Readable = process.stdin, + output: Writable = process.stdout, +): Promise { + const server = new LspServer(client, workspace, opts, input, output); + await server.run(); +} + +// ---- Helper functions ------------------------------------------------------- + +/** + * Read a single JSON-RPC message from stdin with Content-Length header parsing. + * Returns null when stdin is closed. + */ +async function readMessage(input: Readable): Promise { + return new Promise((resolve, reject) => { + let contentLength: number | null = null; + let buffer = Buffer.alloc(0); + let headersDone = false; + + const dataHandler = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + + if (!headersDone) { + // Try CRLF separator first (LSP standard), then LF + let separator = "\r\n\r\n"; + let lineSeparator = "\r\n"; + let separatorIndex = buffer.indexOf(separator); + + if (separatorIndex === -1) { + separator = "\n\n"; + lineSeparator = "\n"; + separatorIndex = buffer.indexOf(separator); + } + + if (separatorIndex === -1) { + // Haven't received all headers yet + return; + } + + // Extract headers + const headerSection = buffer + .subarray(0, separatorIndex) + .toString("utf8"); + const headers = headerSection.split(lineSeparator); + + for (const header of headers) { + const len = parseContentLength(header); + if (len !== null) { + contentLength = len; + break; + } + } + + if (contentLength === null) { + // Invalid: no Content-Length header + cleanup(); + resolve(null); + return; + } + + // Move past headers + buffer = buffer.subarray(separatorIndex + separator.length); + headersDone = true; + } + + // Check if we have all the content + if (headersDone && buffer.length >= contentLength!) { + // Extract exactly contentLength bytes + const messageContent = buffer + .subarray(0, contentLength!) + .toString("utf8"); + + try { + const message = JSON.parse(messageContent) as JsonRpcMessage; + cleanup(); + resolve(message); + } catch (err) { + // Invalid JSON + cleanup(); + reject(new Error(`Failed to parse JSON-RPC message: ${err}`)); + } + } + }; + + const endHandler = () => { + cleanup(); + resolve(null); + }; + + const errorHandler = (err: Error) => { + cleanup(); + reject(err); + }; + + const cleanup = () => { + input.off("data", dataHandler); + input.off("end", endHandler); + input.off("error", errorHandler); + }; + + input.on("data", dataHandler); + input.on("end", endHandler); + input.on("error", errorHandler); + }); +} + +/** + * Parse Content-Length header from LSP message. + */ +function parseContentLength(header: string): number | null { + const match = /^Content-Length:\s*(\d+)\s*$/i.exec(header.trim()); + return match ? parseInt(match[1], 10) : null; +} + +/** + * Write a JSON-RPC message to stdout with Content-Length header. + */ +function writeMessage( + output: Writable, + message: JsonRpcMessage, +): Promise { + return new Promise((resolve, reject) => { + const content = JSON.stringify(message); + const contentLength = Buffer.byteLength(content, "utf8"); + const header = `Content-Length: ${contentLength}\r\n\r\n`; + const full = header + content; + + output.write(full, (err) => { + if (err) reject(err); + else resolve(); + }); + }); +} diff --git a/clients/typescript/src/types.ts b/clients/typescript/src/types.ts index 0f8968af..9f3e1685 100644 --- a/clients/typescript/src/types.ts +++ b/clients/typescript/src/types.ts @@ -71,6 +71,19 @@ export const PullResultSchema = z.object({ }); export type PullResult = Named; +export const ClientPullOptionsSchema = z.object({ + services: z.union([z.literal("all"), z.array(z.string())]).optional(), + languages: z.union([z.literal("all"), z.array(z.string())]).optional(), + containerRegistry: z.string().optional(), + languageImageVersion: z.string().optional(), + serviceImageVersion: z.string().optional(), + stream: z.boolean().optional(), +}); +export type ClientPullOptions = Named< + typeof ClientPullOptionsSchema, + "ClientPullOptions" +>; + // ---- General HttpResult types ----------------------------------------------- export const HttpErrSchema = z.object({ status_code: z.number().nullable(), @@ -298,6 +311,31 @@ export type FindReferencesResult = Named< "FindReferencesResult" >; +// ---- JSON-RPC types --------------------------------------------------------- + +export interface JsonRpcMessage { + jsonrpc: "2.0"; + id?: string | number | null; + method?: string; + params?: any; + result?: any; + error?: JsonRpcError; +} + +export interface JsonRpcError { + code: JsonRpcErrorCode; + message: string; + data?: any; +} + +export enum JsonRpcErrorCode { + ParseError = -32700, + InvalidRequest = -32600, + MethodNotFound = -32601, + InvalidParams = -32602, + InternalError = -32603, +} + // ---- CLI Command Options --------------------------------------------------- export const BaseCommandOptionsSchema = z.object({ json: z.boolean().optional(), @@ -447,3 +485,25 @@ export type FindReferencesOptions = Named< typeof FindReferencesOptionsSchema, "FindReferencesOptions" >; + +export const ServerCommandOptionsSchema = BaseCommandOptionsSchema.extend({ + hostPort: z.number().int().min(0).optional(), + containerRegistry: z.string().optional(), + languageImageVersion: z.string().optional(), + serviceImageVersion: z.string().optional(), + timeout: z.number().optional(), + sudo: z.boolean().optional(), + ro: z.boolean().optional(), + bindHost: z.string().optional(), + debug: z.boolean().optional(), + env: z.array(z.string()).optional(), + envFile: z.string().optional(), + shared: z.boolean().optional(), + sharedMode: z + .union([z.literal("up"), z.literal("down"), z.literal("use")]) + .optional(), +}); +export type ServerCommandOptions = Named< + typeof ServerCommandOptionsSchema, + "ServerCommandOptions" +>; diff --git a/clients/vscode/.gitignore b/clients/vscode/.gitignore new file mode 100644 index 00000000..3d2bc626 --- /dev/null +++ b/clients/vscode/.gitignore @@ -0,0 +1,2 @@ +/dist/ +/node_modules/ diff --git a/clients/vscode/README.md b/clients/vscode/README.md new file mode 100644 index 00000000..c44f9a33 --- /dev/null +++ b/clients/vscode/README.md @@ -0,0 +1,42 @@ +# Nuanced LSP VSCode Extension + +VSCode extension for Nuanced LSP server providing advanced language features. + +_This is not meant to be a full-featured language plugin! Merely a way to showcase the LSP capabilities and do interactive debugging._ + +## Requirements + +- VSCode 1.75.0 or higher +- `nuanced-lsp` command available in your PATH + +## Development + +### Building + +```bash +npm install +npm run build +``` + +## Running from Command Line + +To start VSCode with the extension enabled for development: + +```bash +# From the vscode directory +scripts/code-with-nuanced-lsp +``` + +## Packaging + +To create a VSIX package for distribution: + +```bash +npm run package +``` + +This will create a `.vsix` file that can be installed in VSCode via: + +```bash +code --install-extension nuanced-lsp-vscode-0.1.0.vsix +``` diff --git a/clients/vscode/eslint.config.mjs b/clients/vscode/eslint.config.mjs new file mode 100644 index 00000000..5593c85d --- /dev/null +++ b/clients/vscode/eslint.config.mjs @@ -0,0 +1,77 @@ +import { fileURLToPath } from "url"; +import { dirname } from "path"; +import js from "@eslint/js"; +import typescript from "typescript-eslint"; +import prettierConfigRecommended from "eslint-plugin-prettier/recommended"; +import importPlugin from "eslint-plugin-import"; +import stylistic from "@stylistic/eslint-plugin"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default [ + js.configs.recommended, + ...typescript.configs.recommended, + prettierConfigRecommended, + importPlugin.flatConfigs.recommended, + importPlugin.flatConfigs.typescript, + { + files: ["**/*.ts", "**/*.tsx"], + settings: { + "import/resolver": { + typescript: true, + node: true, + }, + }, + languageOptions: { + parserOptions: { + ecmaVersion: "latest", + sourceType: "commonjs", + tsconfigRootDir: __dirname, + }, + globals: { + clearTimeout: "readonly", + fetch: "readonly", + global: "readonly", + setTimeout: "readonly", + Response: "readonly", + __dirname: "readonly", + __filename: "readonly", + AbortController: "readonly", + Buffer: "readonly", + BufferEncoding: "readonly", + NodeJS: "readonly", + }, + }, + plugins: { + "@stylistic": stylistic, + }, + rules: { + "no-console": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + }, + ], + "prettier/prettier": "error", + "@stylistic/comma-dangle": ["error", "always-multiline"], + "@stylistic/eol-last": ["error", "always"], + "@stylistic/no-multiple-empty-lines": ["error", { max: 1, maxEOF: 0 }], + "@stylistic/no-trailing-spaces": "error", + "@stylistic/quotes": ["error", "double"], + "@stylistic/semi": ["error", "always"], + "import/first": "error", + "import/no-extraneous-dependencies": "error", + "import/no-unassigned-import": "error", + "import/no-unresolved": "error", + "import/order": "error", + }, + }, + { + ignores: ["*.config.cjs", "*.config.mjs", "dist/", "node_modules/"], + }, +]; diff --git a/clients/vscode/package.json b/clients/vscode/package.json new file mode 100644 index 00000000..eca2bec1 --- /dev/null +++ b/clients/vscode/package.json @@ -0,0 +1,77 @@ +{ + "name": "@nuanced-dev/lsp-vscode", + "displayName": "Nuanced LSP", + "description": "VSCode extension for Nuanced LSP server", + "version": "0.1.0", + "publisher": "nuanced-dev", + "engines": { + "vscode": "^1.75.0", + "node": ">=18" + }, + "categories": [ + "Programming Languages" + ], + "main": "./dist/extension.js", + "activationEvents": [ + "onLanguage:cpp", + "onLanguage:csharp", + "onLanguage:go", + "onLanguage:java", + "onLanguage:javascript", + "onLanguage:php", + "onLanguage:python", + "onLanguage:ruby", + "onLanguage:rust", + "onLanguage:typescript" + ], + "contributes": { + "commands": [ + { + "command": "nuancedLsp.restart", + "title": "Nuanced LSP: Restart Server" + } + ], + "configuration": { + "title": "Nuanced LSP", + "properties": { + "nuancedLsp.command": { + "anyOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ], + "description": "Command to run the Nuanced LSP server. Can be a string (command only) or an array where the first element is the command and the rest are arguments. Default: [\"nuanced-lsp\", \"server\"]" + }, + "nuancedLsp.env": { + "type": "object", + "default": {}, + "description": "Environment variables to pass to the LSP server command" + } + } + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "clean": "rm -rf dist nuanced-lsp-vscode-*.vsix", + "lint": "eslint", + "lint:fix": "eslint --fix", + "package": "vsce package", + "vscode:prepublish": "bun run build" + }, + "dependencies": { + "vscode-languageclient": "^9.0.1" + }, + "devDependencies": { + "@eslint/js": "^9.37.0", + "@stylistic/eslint-plugin": "^5.4.0", + "@types/node": "^20.11.30", + "@types/vscode": "^1.75.0", + "@vscode/vsce": "^3.2.1", + "eslint": "^9.33.0", + "eslint-config-prettier": "^10.1.5", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-prettier": "^5.4.1", + "typescript": "^5.5.4", + "typescript-eslint": "^8.45.0" + } +} diff --git a/clients/vscode/scripts/code-with-nuanced-lsp b/clients/vscode/scripts/code-with-nuanced-lsp new file mode 100755 index 00000000..68f3acd7 --- /dev/null +++ b/clients/vscode/scripts/code-with-nuanced-lsp @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")" && pwd)" + +VSCODE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +exec code --extensionDevelopmentPath="$VSCODE_DIR" "$@" diff --git a/clients/vscode/src/extension.ts b/clients/vscode/src/extension.ts new file mode 100644 index 00000000..1f88e1d0 --- /dev/null +++ b/clients/vscode/src/extension.ts @@ -0,0 +1,105 @@ +import * as vscode from "vscode"; +import { + LanguageClient, + LanguageClientOptions, + ServerOptions, +} from "vscode-languageclient/node"; + +const DEFAULT_COMMAND_CONFIG = ["nuanced-lsp", "server", "--host-port", "0"]; + +let client: LanguageClient | undefined; + +function startClient() { + const config = vscode.workspace.getConfiguration("nuancedLsp"); + const commandConfig = config.get("command"); + const envConfig = config.get>("env", {}); + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + vscode.window.showErrorMessage( + "Nuanced LSP: No workspace folder found. Please open a folder to use this extension.", + ); + return; + } + + const workspacePath = workspaceFolder.uri.fsPath; + + let command: string; + let args: string[]; + + if (Array.isArray(commandConfig)) { + [command, ...args] = commandConfig; + } else if (typeof commandConfig === "string") { + [command, ...args] = [commandConfig]; + } else { + [command, ...args] = DEFAULT_COMMAND_CONFIG; + } + + args.push(workspacePath); + + const serverOptions: ServerOptions = { + command, + args, + options: { + env: { ...process.env, ...envConfig }, + }, + }; + + const clientOptions: LanguageClientOptions = { + documentSelector: [ + { scheme: "file", language: "python" }, + { scheme: "file", language: "typescript" }, + { scheme: "file", language: "javascript" }, + { scheme: "file", language: "rust" }, + { scheme: "file", language: "cpp" }, + { scheme: "file", language: "csharp" }, + { scheme: "file", language: "java" }, + { scheme: "file", language: "go" }, + { scheme: "file", language: "php" }, + { scheme: "file", language: "ruby" }, + ], + synchronize: { + fileEvents: vscode.workspace.createFileSystemWatcher("**/*"), + }, + }; + + client = new LanguageClient( + "nuancedLsp", + "Nuanced LSP", + serverOptions, + clientOptions, + ); + + client.start(); +} + +async function restartClient() { + if (client) { + await client.stop(); + } + startClient(); + vscode.window.showInformationMessage("Nuanced LSP: Server restarted"); +} + +export function activate(context: vscode.ExtensionContext) { + startClient(); + + context.subscriptions.push( + vscode.commands.registerCommand("nuancedLsp.restart", restartClient), + ); + + context.subscriptions.push({ + dispose: () => { + if (client) { + client.stop(); + } + }, + }); +} + +export function deactivate(): Thenable | undefined { + if (!client) { + return undefined; + } + return client.stop(); +} diff --git a/clients/vscode/tsconfig.json b/clients/vscode/tsconfig.json new file mode 100644 index 00000000..ac51ef92 --- /dev/null +++ b/clients/vscode/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "types": ["vscode", "node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.config.cjs"] +} diff --git a/crates/common/src/api_types.rs b/crates/common/src/api_types.rs index d8c209cb..6aacacc3 100644 --- a/crates/common/src/api_types.rs +++ b/crates/common/src/api_types.rs @@ -627,7 +627,7 @@ pub struct Identifier { } #[derive(Serialize, Deserialize, ToSchema, IntoParams)] -pub struct GetDefinitionRequest { +pub struct FindDefinitionRequest { pub position: FilePosition, /// Whether to include the source code around the symbol's identifier in the response. @@ -645,7 +645,7 @@ pub struct GetDefinitionRequest { } #[derive(Serialize, Deserialize, ToSchema, IntoParams)] -pub struct GetReferencesRequest { +pub struct FindReferencesRequest { pub identifier_position: FilePosition, /// Whether to include the source code of the symbol in the response. @@ -669,7 +669,7 @@ pub struct GetReferencesRequest { /// For example, if the position points to a function name, the response will include /// all symbols referenced within that function's implementation. #[derive(Serialize, Deserialize, ToSchema, IntoParams)] -pub struct GetReferencedSymbolsRequest { +pub struct FindReferencedSymbolsRequest { /// Whether to use the more permissive rules to find referenced symbols. This will be not just /// code that is executed but also things like type hints and chained indirection. /// Defaults to false. @@ -683,7 +683,7 @@ pub struct GetReferencedSymbolsRequest { /// Request to get the symbols in a file. #[derive(Serialize, Deserialize, ToSchema, IntoParams)] -pub struct FileSymbolsRequest { +pub struct DefinitionsInFileRequest { /// The path to the file to get the symbols for, relative to the root of the workspace. #[schema(example = "src/main.py")] pub file_path: String, @@ -722,7 +722,7 @@ pub struct WorkspaceSymbolsRequest { /// ``` /// The definition(s) will be `[{"path": "src/main.py", "line": 0, "character": 6}]`. #[derive(Debug, PartialEq, Clone, Serialize, Deserialize, ToSchema)] -pub struct DefinitionResponse { +pub struct FindDefinitionResponse { #[serde(skip_serializing_if = "Option::is_none")] /// The raw response from the langserver. /// @@ -754,7 +754,7 @@ pub struct DefinitionResponse { /// ``` /// The references will be `[{"path": "src/main.py", "line": 5, "character": 7}]`. #[derive(Debug, PartialEq, Clone, Serialize, Deserialize, ToSchema)] -pub struct ReferencesResponse { +pub struct FindReferencesResponse { #[serde(skip_serializing_if = "Option::is_none")] /// The raw response from the langserver. /// @@ -777,14 +777,12 @@ pub struct ReferencesResponse { /// - external_symbols: References to symbols from outside the workspace (built-in functions, external libraries) /// - not_found: References where the symbol definition could not be found #[derive(Debug, PartialEq, Clone, Serialize, Deserialize, ToSchema)] -pub struct ReferencedSymbolsResponse { +pub struct FindReferencedSymbolsResponse { pub workspace_symbols: Vec, pub external_symbols: Vec, pub not_found: Vec, } -pub type SymbolResponse = Vec; - impl From for FilePosition { fn from(location: Location) -> Self { FilePosition { @@ -824,7 +822,7 @@ pub struct FindIdentifierRequest { #[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct IdentifierResponse { +pub struct FindIdentifierResponse { pub identifiers: Vec, } @@ -845,6 +843,165 @@ pub struct ReadSourceCodeRequest { pub range: Option, } +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ReadSourceCodeResponse { + pub source_code: String, +} + +/// Unified JSON-RPC message +/// +/// Because multiple message types flow in both directions (requests & notifications +/// from the client, responses & notifications fro the server), it is easier to work +/// with a unified message type. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoParams)] +pub struct JsonRpcMessage { + /// The JSON-RPC version (always "2.0") + #[schema(example = "2.0")] + pub jsonrpc: String, + + /// Request ID (required for requests and responses) + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_option_value" + )] + pub id: Option, + + /// Method name (required for requests and notifications) + #[schema(example = "textDocument/hover")] + #[serde(skip_serializing_if = "Option::is_none")] + pub method: Option, + + /// Parameters (optional for requests and notifications) + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_option_value" + )] + pub params: Option, + + /// Result (required for responses, unless error is present) + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_option_value" + )] + pub result: Option, + + /// Error (required for responses, unless result is present) + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl JsonRpcMessage { + pub fn new_notification( + method: String, + params: Option, + ) -> Self { + Self { + jsonrpc: "2.0".to_string(), + id: None, + method: Some(method), + params: params.map(|params| serde_json::to_value(params).unwrap()), + result: None, + error: None, + } + } + + pub fn new_request( + id: Id, + method: String, + params: Option, + ) -> Self { + Self { + jsonrpc: "2.0".to_string(), + id: Some(serde_json::to_value(id).unwrap()), + method: Some(method), + params: params.map(|params| serde_json::to_value(params).unwrap()), + result: None, + error: None, + } + } + + pub fn new_result_response(id: Option, result: R) -> Self { + Self { + jsonrpc: "2.0".to_string(), + id: Some(id.map_or(Value::Null, |id| serde_json::to_value(id).unwrap())), + method: None, + params: None, + result: Some(serde_json::to_value(result).unwrap()), + error: None, + } + } + + pub fn new_error_response( + id: Option, + code: i32, + message: impl ToString, + ) -> Self { + Self { + jsonrpc: "2.0".to_string(), + id: Some(id.map_or(Value::Null, |id| serde_json::to_value(id).unwrap())), + method: None, + params: None, + result: None, + error: Some(JsonRpcError { + code, + message: message.to_string(), + data: None, + }), + } + } +} + +/// JSON-RPC error +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct JsonRpcError { + /// Code + pub code: i32, + + /// Message + pub message: String, + + /// Optional additional error data + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl fmt::Display for JsonRpcError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Error {}: {}", self.code, self.message) + } +} + +impl std::error::Error for JsonRpcError {} + +#[allow(non_snake_case, non_upper_case_globals)] +pub mod JsonRpcErrorCode { + pub const ParseError: i32 = -32700; + pub const InvalidRequest: i32 = -32600; + pub const MethodNotFound: i32 = -32601; + pub const InvalidParams: i32 = -32602; + pub const InternalError: i32 = -32603; +} + +/// Custom deserialize function to ensure `Some(Null)` is not reduced to `None`. +/// Fields need to be annotated as follows: +/// ```skip +/// #[serde( +/// default, +/// skip_serializing_if = "Option::is_none", +/// deserialize_with = "deserialize_option_value" +/// )] +/// ``` +/// Solution from: +fn deserialize_option_value<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Deserialize::deserialize(deserializer).map(Some) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/common/src/utils/language_utils.rs b/crates/common/src/utils/language_utils.rs index 0de46a99..030ae004 100644 --- a/crates/common/src/utils/language_utils.rs +++ b/crates/common/src/utils/language_utils.rs @@ -10,6 +10,50 @@ use super::workspace_documents::{ TYPESCRIPT_AND_JAVASCRIPT_EXTENSIONS, TYPESCRIPT_EXTENSIONS, }; +/// Detect Ruby workspace and version configuration from a file path. +/// +/// Walks up the directory tree from the file path to find workspace markers +/// (.ruby-version or Gemfile), then detects the Ruby version and Sorbet configuration. +/// Returns the appropriate SupportedLanguages variant with version and config info. +fn detect_ruby_language(file_path: &Path) -> SupportedLanguages { + let mut workspace_path = file_path; + while let Some(parent) = workspace_path.parent() { + let has_ruby_version = parent.join(".ruby-version").exists(); + let has_gemfile = parent.join("Gemfile").exists(); + + if has_ruby_version || has_gemfile { + let version = detect_ruby_version(parent); + + // Only use Sorbet if BOTH conditions are met: + // 1. File has type annotations (# typed: comment) + // 2. Workspace has sorbet/config file + // This prevents spawning broken Sorbet containers that spin at 100% CPU + let sorbet_config_dir = if has_sorbet_type_annotation(file_path) { + find_sorbet_config_dir(file_path) + } else { + None + }; + let is_sorbet = sorbet_config_dir.is_some(); + + return SupportedLanguages::from_ruby_version_with_config( + &version, + is_sorbet, + sorbet_config_dir, + ); + } + + workspace_path = parent; + + // Stop at root directory + if parent.parent().is_none() { + break; + } + } + + // Fallback to default version if no workspace markers found + SupportedLanguages::ruby_default() +} + /// Detect the programming language from a file path. /// /// Returns a `SupportedLanguages` enum variant based on the file extension. @@ -33,47 +77,7 @@ pub fn detect_language(file_path: &str) -> Result ext if JAVA_EXTENSIONS.contains(&ext) => Ok(SupportedLanguages::Java), ext if GOLANG_EXTENSIONS.contains(&ext) => Ok(SupportedLanguages::Golang), ext if PHP_EXTENSIONS.contains(&ext) => Ok(SupportedLanguages::PHP), - ext if RUBY_EXTENSIONS.contains(&ext) => { - let path = Path::new(file_path); - - // Detect Ruby version by walking up to find .ruby-version or Gemfile - let mut workspace_path = path; - while let Some(parent) = workspace_path.parent() { - let has_ruby_version = parent.join(".ruby-version").exists(); - let has_gemfile = parent.join("Gemfile").exists(); - - if has_ruby_version || has_gemfile { - let version = detect_ruby_version(parent); - - // Only use Sorbet if BOTH conditions are met: - // 1. File has type annotations (# typed: comment) - // 2. Workspace has sorbet/config file - // This prevents spawning broken Sorbet containers that spin at 100% CPU - let sorbet_config_dir = if has_sorbet_type_annotation(path) { - find_sorbet_config_dir(path) - } else { - None - }; - let is_sorbet = sorbet_config_dir.is_some(); - - return Ok(SupportedLanguages::from_ruby_version_with_config( - &version, - is_sorbet, - sorbet_config_dir, - )); - } - - workspace_path = parent; - - // Stop at root directory - if parent.parent().is_none() { - break; - } - } - - // Fallback to default version if no workspace markers found - Ok(SupportedLanguages::ruby_default()) - } + ext if RUBY_EXTENSIONS.contains(&ext) => Ok(detect_ruby_language(Path::new(file_path))), _ => Err(LspError::UnsupportedFileType(file_path.to_string())), } } diff --git a/crates/proxy/Cargo.toml b/crates/proxy/Cargo.toml index 99219bc6..8e57261c 100644 --- a/crates/proxy/Cargo.toml +++ b/crates/proxy/Cargo.toml @@ -17,6 +17,7 @@ docker-tests = [] [dependencies] common.workspace = true actix-web.workspace = true +actix-ws.workspace = true actix-files = "0.6" actix-cors = "0.6" tokio.workspace = true @@ -55,6 +56,7 @@ regex = { version = "1.8", features = ["std"] } futures = "0.3" futures-util = "0.3" rand = "0.8" +tokio-tungstenite = "0.21" [dev-dependencies] tempfile.workspace = true diff --git a/crates/proxy/src/container/http_client.rs b/crates/proxy/src/container/api_client.rs similarity index 71% rename from crates/proxy/src/container/http_client.rs rename to crates/proxy/src/container/api_client.rs index 343f2be7..33432843 100644 --- a/crates/proxy/src/container/http_client.rs +++ b/crates/proxy/src/container/api_client.rs @@ -1,16 +1,16 @@ -/// HTTP client for communicating with LSP wrapper containers -/// -/// This client provides a simple interface to make HTTP requests to language -/// server containers, replacing the direct LSP process management. use common::api_types::*; use std::error::Error; -pub struct ContainerHttpClient { +/// HTTP client for calling API methods on LSP wrapper containers +/// +/// This client provides a simple interface to make HTTP requests to language +/// server containers, replacing the direct LSP process management. +pub struct ContainerApiClient { base_url: String, client: reqwest::Client, } -impl ContainerHttpClient { +impl ContainerApiClient { pub fn new(endpoint: &str) -> Self { Self { base_url: endpoint.to_string(), @@ -18,11 +18,15 @@ impl ContainerHttpClient { } } + pub fn get_base_url(&self) -> &str { + &self.base_url + } + /// Find definition for a symbol pub async fn find_definition( &self, - request: &GetDefinitionRequest, - ) -> Result> { + request: &FindDefinitionRequest, + ) -> Result> { let url = format!("{}/symbol/find-definition", self.base_url); let response = self.client.post(&url).json(request).send().await?; @@ -37,8 +41,8 @@ impl ContainerHttpClient { /// Find references for a symbol pub async fn find_references( &self, - request: &GetReferencesRequest, - ) -> Result> { + request: &FindReferencesRequest, + ) -> Result> { let url = format!("{}/symbol/find-references", self.base_url); let response = self.client.post(&url).json(request).send().await?; @@ -54,7 +58,7 @@ impl ContainerHttpClient { pub async fn find_identifier( &self, request: &FindIdentifierRequest, - ) -> Result> { + ) -> Result> { let url = format!("{}/symbol/find-identifier", self.base_url); let response = self.client.post(&url).json(request).send().await?; @@ -69,8 +73,8 @@ impl ContainerHttpClient { /// Find referenced symbols within a function pub async fn find_referenced_symbols( &self, - request: &GetReferencedSymbolsRequest, - ) -> Result> { + request: &FindReferencedSymbolsRequest, + ) -> Result> { let url = format!("{}/symbol/find-referenced-symbols", self.base_url); let response = self.client.post(&url).json(request).send().await?; @@ -85,7 +89,7 @@ impl ContainerHttpClient { /// Get all definitions in a file pub async fn definitions_in_file( &self, - request: &FileSymbolsRequest, + request: &DefinitionsInFileRequest, ) -> Result, Box> { let url = format!("{}/symbol/definitions-in-file", self.base_url); let response = self.client.get(&url).query(request).send().await?; @@ -98,4 +102,20 @@ impl ContainerHttpClient { // Response is directly Vec Ok(response.json().await?) } + + /// Forward a raw LSP JSON-RPC request to the container + pub async fn lsp( + &self, + request: &JsonRpcMessage, + ) -> Result> { + let url = format!("{}/lsp", self.base_url); + let response = self.client.post(&url).json(request).send().await?; + + if !response.status().is_success() { + let error_text = response.text().await?; + return Err(format!("LSP request failed: {}", error_text).into()); + } + + Ok(response.json().await?) + } } diff --git a/crates/proxy/src/container/mod.rs b/crates/proxy/src/container/mod.rs index 126d32f9..1229bf27 100644 --- a/crates/proxy/src/container/mod.rs +++ b/crates/proxy/src/container/mod.rs @@ -8,7 +8,7 @@ use tokio::sync::Mutex; use common::api_types::{LanguageVariant, SupportedLanguages}; -pub mod http_client; +pub mod api_client; pub mod language_manager; pub mod orchestrator; @@ -184,7 +184,7 @@ pub async fn find_image(docker: &Docker, image: String) -> Result Result<(), OrchestratorError> { - let health_url = format!("{}/health", info.endpoint); + let health_url = format!("{}/system/health", info.endpoint); let client = reqwest::Client::new(); log::info!("Checking health of {} at {}", info.image_name, health_url); diff --git a/crates/proxy/src/handlers/container_proxy.rs b/crates/proxy/src/handlers/container_proxy.rs index f76b7df7..eb7409a0 100644 --- a/crates/proxy/src/handlers/container_proxy.rs +++ b/crates/proxy/src/handlers/container_proxy.rs @@ -1,4 +1,4 @@ -use crate::container::{ContainerHttpClient, ContainerOrchestrator}; +use crate::container::{ContainerApiClient, ContainerOrchestrator}; /// Helper module for routing requests to containerized LSP servers /// /// This module handles: @@ -12,42 +12,42 @@ use log::{error, info}; use std::sync::Arc; /// Get or spawn a container for the given language and return an HTTP client -pub async fn get_container_client( +pub async fn get_container_api_client( orchestrator: &Arc, language: SupportedLanguages, -) -> Result { +) -> Result { // Check if container already exists if let Some(container_info) = orchestrator.get_container(&language).await { - return Ok(ContainerHttpClient::new(&container_info.endpoint)); + return Ok(ContainerApiClient::new(&container_info.endpoint)); } // Spawn new container - info!("Spawning container for {:?}", language); + info!("Spawning container for {}", language); match orchestrator .spawn_language_container(language.clone()) .await { Ok(container_info) => { info!( - "Container spawned for {:?}: {}", + "Container spawned for {}: {}", language, container_info.endpoint ); - Ok(ContainerHttpClient::new(&container_info.endpoint)) + Ok(ContainerApiClient::new(&container_info.endpoint)) } Err(e) => { - error!("Failed to spawn container for {:?}: {}", language, e); + error!("Failed to spawn container for {}: {}", language, e); Err(format!("Failed to spawn container: {}", e)) } } } /// Detect language from file path and get/spawn appropriate container client -pub async fn get_client_for_file( +pub async fn get_api_client_for_file( orchestrator: &Arc, file_path: &str, -) -> Result { +) -> Result { let language = detect_language(file_path) .map_err(|e| format!("Failed to detect language for {}: {}", file_path, e))?; - get_container_client(orchestrator, language).await + get_container_api_client(orchestrator, language).await } diff --git a/crates/proxy/src/handlers/definitions_in_file.rs b/crates/proxy/src/handlers/definitions_in_file.rs index ec2f4642..947115ea 100644 --- a/crates/proxy/src/handlers/definitions_in_file.rs +++ b/crates/proxy/src/handlers/definitions_in_file.rs @@ -2,7 +2,7 @@ use crate::handlers::container_proxy; use crate::AppState; use actix_web::web::{Data, Query}; use actix_web::HttpResponse; -use common::api_types::{ErrorResponse, FileSymbolsRequest, Symbol}; +use common::api_types::{DefinitionsInFileRequest, ErrorResponse, Symbol}; use log::{error, info}; /// Get all symbol definitions in a file @@ -10,7 +10,7 @@ use log::{error, info}; get, path = "/symbol/definitions-in-file", tag = "symbol", - params(FileSymbolsRequest), + params(DefinitionsInFileRequest), responses( (status = 200, description = "Symbols retrieved successfully", body = Vec), (status = 400, description = "Bad request"), @@ -19,7 +19,7 @@ use log::{error, info}; )] pub async fn definitions_in_file( data: Data, - info: Query, + info: Query, ) -> HttpResponse { info!( "Received definitions in file request for file: {}", @@ -28,7 +28,7 @@ pub async fn definitions_in_file( // Get container client for this file's language let client = - match container_proxy::get_client_for_file(&data.orchestrator, &info.file_path).await { + match container_proxy::get_api_client_for_file(&data.orchestrator, &info.file_path).await { Ok(client) => client, Err(e) => { error!("Failed to get container client: {}", e); diff --git a/crates/proxy/src/handlers/find_definition.rs b/crates/proxy/src/handlers/find_definition.rs index 7bd9dc8c..2729e15f 100644 --- a/crates/proxy/src/handlers/find_definition.rs +++ b/crates/proxy/src/handlers/find_definition.rs @@ -2,7 +2,7 @@ use crate::handlers::container_proxy; use crate::AppState; use actix_web::web::{Data, Json}; use actix_web::HttpResponse; -use common::api_types::{DefinitionResponse, ErrorResponse, GetDefinitionRequest}; +use common::api_types::{ErrorResponse, FindDefinitionRequest, FindDefinitionResponse}; use log::{error, info}; /// Get the definition of a symbol at a specific position in a file @@ -10,16 +10,16 @@ use log::{error, info}; post, path = "/symbol/find-definition", tag = "symbol", - request_body = GetDefinitionRequest, + request_body = FindDefinitionRequest, responses( - (status = 200, description = "Definition retrieved successfully", body = DefinitionResponse), + (status = 200, description = "Definition retrieved successfully", body = FindDefinitionResponse), (status = 400, description = "Bad request"), (status = 500, description = "Internal server error") ) )] pub async fn find_definition( data: Data, - info: Json, + info: Json, ) -> HttpResponse { info!( "Received definition request for file: {}, line: {}, character: {}", @@ -28,7 +28,9 @@ pub async fn find_definition( // Get container client for this file's language let client = - match container_proxy::get_client_for_file(&data.orchestrator, &info.position.path).await { + match container_proxy::get_api_client_for_file(&data.orchestrator, &info.position.path) + .await + { Ok(client) => client, Err(e) => { error!("Failed to get container client: {}", e); diff --git a/crates/proxy/src/handlers/find_identifier.rs b/crates/proxy/src/handlers/find_identifier.rs index 30703d03..80228941 100644 --- a/crates/proxy/src/handlers/find_identifier.rs +++ b/crates/proxy/src/handlers/find_identifier.rs @@ -2,7 +2,7 @@ use crate::handlers::container_proxy; use crate::AppState; use actix_web::web::{Data, Json}; use actix_web::HttpResponse; -use common::api_types::{ErrorResponse, FindIdentifierRequest, IdentifierResponse}; +use common::api_types::{ErrorResponse, FindIdentifierRequest, FindIdentifierResponse}; use log::{error, info}; /// Find an identifier by name @@ -12,7 +12,7 @@ use log::{error, info}; tag = "symbol", request_body = FindIdentifierRequest, responses( - (status = 200, description = "Identifier found successfully", body = IdentifierResponse), + (status = 200, description = "Identifier found successfully", body = FindIdentifierResponse), (status = 400, description = "Bad request"), (status = 500, description = "Internal server error") ) @@ -27,15 +27,16 @@ pub async fn find_identifier( ); // Get container client for this file's language - let client = match container_proxy::get_client_for_file(&data.orchestrator, &info.path).await { - Ok(client) => client, - Err(e) => { - error!("Failed to get container client: {}", e); - return HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("Failed to get container client: {}", e), - }); - } - }; + let client = + match container_proxy::get_api_client_for_file(&data.orchestrator, &info.path).await { + Ok(client) => client, + Err(e) => { + error!("Failed to get container client: {}", e); + return HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Failed to get container client: {}", e), + }); + } + }; // Forward request to container match client.find_identifier(&info.into_inner()).await { diff --git a/crates/proxy/src/handlers/find_referenced_symbols.rs b/crates/proxy/src/handlers/find_referenced_symbols.rs index dbf01cab..2485d9e3 100644 --- a/crates/proxy/src/handlers/find_referenced_symbols.rs +++ b/crates/proxy/src/handlers/find_referenced_symbols.rs @@ -2,7 +2,9 @@ use crate::handlers::container_proxy; use crate::AppState; use actix_web::web::{Data, Json}; use actix_web::HttpResponse; -use common::api_types::{ErrorResponse, GetReferencedSymbolsRequest, ReferencedSymbolsResponse}; +use common::api_types::{ + ErrorResponse, FindReferencedSymbolsRequest, FindReferencedSymbolsResponse, +}; use log::{error, info}; /// Find all symbols referenced within a given symbol @@ -10,16 +12,16 @@ use log::{error, info}; post, path = "/symbol/find-referenced-symbols", tag = "symbol", - request_body = GetReferencedSymbolsRequest, + request_body = FindReferencedSymbolsRequest, responses( - (status = 200, description = "Referenced symbols retrieved successfully", body = ReferencedSymbolsResponse), + (status = 200, description = "Referenced symbols retrieved successfully", body = FindReferencedSymbolsResponse), (status = 400, description = "Bad request"), (status = 500, description = "Internal server error") ) )] pub async fn find_referenced_symbols( data: Data, - info: Json, + info: Json, ) -> HttpResponse { info!( "Received find referenced symbols request for file: {}, line: {}, character: {}", @@ -29,7 +31,7 @@ pub async fn find_referenced_symbols( ); // Get container client for this file's language - let client = match container_proxy::get_client_for_file( + let client = match container_proxy::get_api_client_for_file( &data.orchestrator, &info.identifier_position.path, ) diff --git a/crates/proxy/src/handlers/find_references.rs b/crates/proxy/src/handlers/find_references.rs index 8010f009..873d40fd 100644 --- a/crates/proxy/src/handlers/find_references.rs +++ b/crates/proxy/src/handlers/find_references.rs @@ -2,7 +2,7 @@ use crate::handlers::container_proxy; use crate::AppState; use actix_web::web::{Data, Json}; use actix_web::HttpResponse; -use common::api_types::{ErrorResponse, GetReferencesRequest, ReferencesResponse}; +use common::api_types::{ErrorResponse, FindReferencesRequest, FindReferencesResponse}; use log::{error, info}; /// Get all references to a symbol @@ -10,16 +10,16 @@ use log::{error, info}; post, path = "/symbol/find-references", tag = "symbol", - request_body = GetReferencesRequest, + request_body = FindReferencesRequest, responses( - (status = 200, description = "References retrieved successfully", body = ReferencesResponse), + (status = 200, description = "References retrieved successfully", body = FindReferencesResponse), (status = 400, description = "Bad request"), (status = 500, description = "Internal server error") ) )] pub async fn find_references( data: Data, - info: Json, + info: Json, ) -> HttpResponse { info!( "Received references request for file: {}, line: {}, character: {}", @@ -29,7 +29,7 @@ pub async fn find_references( ); // Get container client for this file's language - let client = match container_proxy::get_client_for_file( + let client = match container_proxy::get_api_client_for_file( &data.orchestrator, &info.identifier_position.path, ) diff --git a/crates/proxy/src/handlers/list_files.rs b/crates/proxy/src/handlers/list_files.rs index f5000335..ea86db8a 100644 --- a/crates/proxy/src/handlers/list_files.rs +++ b/crates/proxy/src/handlers/list_files.rs @@ -12,7 +12,7 @@ use std::sync::{Arc, Mutex}; path = "/workspace/list-files", tag = "file", responses( - (status = 200, description = "Files listed successfully"), + (status = 200, description = "Files listed successfully", body = Vec), (status = 500, description = "Internal server error") ) )] diff --git a/crates/proxy/src/handlers/lsp_ws.rs b/crates/proxy/src/handlers/lsp_ws.rs new file mode 100644 index 00000000..d7c61081 --- /dev/null +++ b/crates/proxy/src/handlers/lsp_ws.rs @@ -0,0 +1,446 @@ +use crate::container::ContainerOrchestrator; +use crate::handlers::container_proxy; +use crate::AppState; +use actix_web::web::{Data, Payload}; +use actix_web::{HttpRequest, HttpResponse}; +use actix_ws::{Message as WsMessage, MessageStream, Session}; +use common::api_types::{JsonRpcErrorCode, JsonRpcMessage, SupportedLanguages}; +use common::utils::language_utils::detect_language; +use futures_util::stream::SplitSink; +use futures_util::{SinkExt, StreamExt}; +use log::{debug, error, info, warn}; +use lsp_types::{ + DeclarationCapability, FoldingRangeProviderCapability, HoverProviderCapability, + ImplementationProviderCapability, InitializeResult, OneOf, PositionEncodingKind, + ServerCapabilities, ServerInfo, TextDocumentSyncKind, TextDocumentSyncOptions, +}; +use serde_json::Value; +use std::collections::hash_map::Entry; +use std::collections::HashMap; +use std::sync::{Arc, LazyLock}; +use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; +use url::Url; + +type ContainerWsConnection = WebSocketStream>; +type ContainerWsSink = SplitSink; + +static SERVER_CAPABILITES: LazyLock = LazyLock::new(|| { + let mut capabilities = ServerCapabilities::default(); + capabilities.call_hierarchy_provider = Some(true.into()); + capabilities.declaration_provider = Some(DeclarationCapability::Simple(true)); + capabilities.definition_provider = Some(OneOf::Left(true)); + capabilities.document_symbol_provider = Some(OneOf::Left(true)); + capabilities.folding_range_provider = Some(FoldingRangeProviderCapability::Simple(true)); + capabilities.hover_provider = Some(HoverProviderCapability::Simple(false)); + capabilities.implementation_provider = Some(ImplementationProviderCapability::Simple(true)); + capabilities.inline_value_provider = Some(OneOf::Left(true)); + capabilities.position_encoding = Some(PositionEncodingKind::UTF16); + capabilities.references_provider = Some(OneOf::Left(true)); + capabilities.text_document_sync = Some( + TextDocumentSyncOptions { + open_close: Some(true), + change: Some(TextDocumentSyncKind::FULL), + ..Default::default() + } + .into(), + ); + capabilities.type_definition_provider = Some(true.into()); + capabilities +}); + +/// WebSocket endpoint for bidirectional LSP communication +pub async fn lsp_ws( + req: HttpRequest, + stream: Payload, + data: Data, +) -> Result { + debug!("WebSocket connection request received at proxy"); + + let (response, session, msg_stream) = actix_ws::handle(&req, stream)?; + + // Spawn task to handle the connection using actix_web::rt (supports !Send futures) + actix_web::rt::spawn(async move { + if let Err(e) = handle_ws_connection(session, msg_stream, data).await { + error!("WebSocket connection error: {}", e); + } + }); + + Ok(response) +} + +async fn handle_ws_connection( + mut client_session: Session, + mut client_stream: MessageStream, + data: Data, +) -> Result<(), Box> { + debug!("Handling WebSocket connection"); + + // Cache of container WebSocket sinks by language (for sending messages to containers) + let mut container_sinks: HashMap = HashMap::new(); + + // Process incoming messages from client + while let Some(result) = client_stream.next().await { + match result { + Ok(WsMessage::Text(text)) => { + if let Err(e) = handle_client_text_message( + text.to_string(), + &mut client_session, + &data, + &mut container_sinks, + ) + .await + { + error!("Error handling client text message: {}", e); + break; + } + } + Ok(WsMessage::Ping(bytes)) => { + if let Err(e) = client_session.pong(&bytes).await { + error!("Failed to send pong: {}", e); + break; + } + } + Ok(WsMessage::Pong(_)) => { + debug!("Received pong from client"); + } + Ok(WsMessage::Close(reason)) => { + debug!("Client closed connection: {:?}", reason); + break; + } + Err(e) => { + error!("Error receiving from client: {}", e); + break; + } + _ => {} + } + } + + debug!("WebSocket connection ended"); + Ok(()) +} + +/// Handle a text message from the client +async fn handle_client_text_message( + text: String, + client_session: &mut Session, + data: &Data, + container_sinks: &mut HashMap, +) -> Result<(), Box> { + debug!("Client -> Proxy: {}", text); + + // Parse JSON-RPC message + let json_rpc_msg: JsonRpcMessage = serde_json::from_str(&text)?; + + // Handle lifecycle requests locally + if let Ok(result) = handle_lifecycle_request(&json_rpc_msg) { + if let Some(response) = result { + let response_text = serde_json::to_string(&response)?; + client_session.text(response_text).await?; + } + return Ok(()); + } + + // Extract document URI to determine routing + let document_uri = + extract_document_uri(&json_rpc_msg).ok_or("Could not extract document URI from message")?; + + // Convert file:// URI to path + let file_path = uri_to_file_path(&document_uri)?; + + // Convert host path to container path for language detection + // The proxy only has access to the workspace at /mnt/workspace, not the host path + let container_path = host_path_to_container_path(&data.orchestrator, &file_path).await?; + let language = detect_language(&container_path) + .map_err(|e| format!("Failed to detect language for {}: {}", file_path, e))?; + + debug!( + "Routing message for file {} (language: {})", + file_path, language + ); + + // Convert paths and send message to container + send_to_container( + json_rpc_msg, + language, + data, + client_session, + container_sinks, + ) + .await +} + +/// Convert a file:// URI to a file path +fn uri_to_file_path(uri: &str) -> Result> { + let url = Url::parse(uri)?; + if url.scheme() != "file" { + return Err(format!("Expected file URI, got: {}", uri).into()); + } + let path = url + .to_file_path() + .map_err(|_| format!("Invalid file URI path: {}", uri))?; + Ok(path.to_string_lossy().to_string()) +} + +/// Convert a host file path to the container path +async fn host_path_to_container_path( + orchestrator: &Arc, + host_path: &str, +) -> Result> { + let host_workspace = orchestrator + .get_host_workspace_path() + .await + .ok_or_else(|| "Failed to get host workspace path".to_string())?; + + if let Some(relative_path) = host_path.strip_prefix(&host_workspace) { + Ok(format!("/mnt/workspace{}", relative_path)) + } else { + Err(format!( + "Path {} is not within workspace {}", + host_path, host_workspace + ) + .into()) + } +} + +/// Ensure a container connection exists for the given language and return the sink +async fn ensure_container_connection<'a>( + language: SupportedLanguages, + data: &Data, + client_session: &Session, + container_sinks: &'a mut HashMap, +) -> Result<&'a mut ContainerWsSink, Box> { + let sink = match container_sinks.entry(language) { + Entry::Occupied(o) => o.into_mut(), + Entry::Vacant(v) => { + let language = v.key(); + + // Get container client for this language + let container_client = + container_proxy::get_container_api_client(&data.orchestrator, language.clone()) + .await?; + + // Connect to container's WebSocket endpoint + let container_ws_url = format!("{}/lsp/ws", container_client.get_base_url()) + .replace("http://", "ws://") + .replace("https://", "wss://"); + + debug!("Connecting to container WebSocket: {}", container_ws_url); + + let (container_ws, _) = tokio_tungstenite::connect_async(&container_ws_url).await?; + + info!("Connected to container WebSocket for {}", language); + + // Split WebSocket into read and write halves + let (sink, stream) = container_ws.split(); + + // Spawn a task to read from this connection and forward to client + spawn_container_reader( + language.clone(), + stream, + client_session.clone(), + data.orchestrator.clone(), + ); + v.insert(sink) + } + }; + Ok(sink) +} + +/// Spawn a task to read from a container WebSocket and forward messages to the client +fn spawn_container_reader( + language: SupportedLanguages, + stream: futures_util::stream::SplitStream, + mut client_session: Session, + orchestrator: Arc, +) { + actix_web::rt::spawn(async move { + let mut stream = stream; + while let Some(msg_result) = stream.next().await { + match msg_result { + Ok(TungsteniteMessage::Text(text)) => { + debug!("Container -> Client from {}: {}", language, text); + + if let Err(e) = forward_container_message_to_client( + text, + &mut client_session, + &orchestrator, + ) + .await + { + error!("Failed to forward container message: {}", e); + break; + } + } + Ok(TungsteniteMessage::Close(_)) => { + debug!("Container WebSocket closed for {}", language); + break; + } + Err(e) => { + error!( + "Error reading from container WebSocket for {}: {}", + language, e + ); + break; + } + _ => {} + } + } + }); +} + +/// Convert paths in a container message and forward to client +async fn forward_container_message_to_client( + text: String, + client_session: &mut Session, + orchestrator: &Arc, +) -> Result<(), Box> { + // Parse and convert paths + match serde_json::from_str::(&text) { + Ok(mut msg) => { + // Convert result paths + if let Some(ref mut result) = msg.result { + convert_json_paths_container_to_host(orchestrator, result).await?; + } + + // Convert params paths (for notifications) + if let Some(ref mut params) = msg.params { + convert_json_paths_container_to_host(orchestrator, params).await?; + } + + let converted_text = serde_json::to_string(&msg)?; + client_session.text(converted_text).await?; + } + Err(e) => { + warn!("Failed to parse message from container: {}", e); + // Forward as-is if not valid JSON-RPC + client_session.text(text).await?; + } + } + Ok(()) +} + +/// Convert paths in a message and send to container +async fn send_to_container( + json_rpc_msg: JsonRpcMessage, + language: SupportedLanguages, + data: &Data, + client_session: &Session, + container_sinks: &mut HashMap, +) -> Result<(), Box> { + // Convert paths in message + let mut converted_msg = json_rpc_msg; + if let Some(ref mut params) = converted_msg.params { + convert_json_paths_host_to_container(&data.orchestrator, params).await?; + } + + // Send to container + let converted_text = serde_json::to_string(&converted_msg)?; + + // Get or create the sink for this language + let sink = ensure_container_connection(language.clone(), data, client_session, container_sinks) + .await?; + + // Send message to container + sink.send(TungsteniteMessage::Text(converted_text)) + .await + .map_err(|e| e.into()) +} + +/// Handle lifecycle requests locally, returning a JSON-RPC response if handled +fn handle_lifecycle_request(request: &JsonRpcMessage) -> Result, ()> { + let req_id = request.id.clone(); + let Some(method) = request.method.as_ref() else { + return Err(()); + }; + let response = match method.as_str() { + "initialize" => Some(JsonRpcMessage::new_result_response( + req_id, + InitializeResult { + capabilities: SERVER_CAPABILITES.clone(), + server_info: Some(ServerInfo { + name: "nuanced-lsp".to_string(), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + }), + }, + )), + "initialized" | "exit" => None, // No response for notifications + "shutdown" => Some(JsonRpcMessage::new_result_response(req_id, Value::Null)), + dollar_method if dollar_method.starts_with("$/") => { + if req_id.is_some() { + Some(JsonRpcMessage::new_error_response( + req_id, + JsonRpcErrorCode::MethodNotFound as i32, + "Method not found".to_string(), + )) + } else { + None + } + } + _ => return Err(()), + }; + Ok(response) +} + +/// Extract document URI from JSON-RPC request +fn extract_document_uri(request: &JsonRpcMessage) -> Option { + request + .params + .as_ref()? + .get("textDocument") + .and_then(|td| td.get("uri")) + .and_then(|u| u.as_str()) + .map(|uri| uri.to_string()) +} + +/// Recursively convert paths in JSON value from host to container +fn convert_json_paths_host_to_container<'a>( + orchestrator: &'a Arc, + value: &'a mut Value, +) -> std::pin::Pin> + 'a>> { + Box::pin(async move { + let host_workspace = orchestrator + .get_host_workspace_path() + .await + .ok_or_else(|| "Failed to get host workspace path".to_string())?; + + convert_json_paths_recursive(value, &host_workspace, "/mnt/workspace"); + Ok(()) + }) +} + +/// Recursively replace path strings in JSON values +fn convert_json_paths_recursive(value: &mut Value, from: &str, to: &str) { + match value { + Value::String(s) => { + *s = s.replace(from, to); + } + Value::Object(map) => { + for (_, v) in map.iter_mut() { + convert_json_paths_recursive(v, from, to); + } + } + Value::Array(arr) => { + for item in arr.iter_mut() { + convert_json_paths_recursive(item, from, to); + } + } + _ => {} + } +} + +/// Recursively convert paths in JSON value from container to host +fn convert_json_paths_container_to_host<'a>( + orchestrator: &'a Arc, + value: &'a mut Value, +) -> std::pin::Pin> + 'a>> { + Box::pin(async move { + let host_workspace = orchestrator + .get_host_workspace_path() + .await + .ok_or_else(|| "Failed to get host workspace path".to_string())?; + + convert_json_paths_recursive(value, "/mnt/workspace", &host_workspace); + Ok(()) + }) +} diff --git a/crates/proxy/src/handlers/mod.rs b/crates/proxy/src/handlers/mod.rs index 217c42ca..2481d430 100644 --- a/crates/proxy/src/handlers/mod.rs +++ b/crates/proxy/src/handlers/mod.rs @@ -5,10 +5,11 @@ mod find_referenced_symbols; mod find_references; mod health; mod list_files; +mod lsp_ws; mod read_source_code; pub(crate) mod container_proxy; pub use self::{ definitions_in_file::*, find_definition::*, find_identifier::*, find_referenced_symbols::*, - find_references::*, health::*, list_files::*, read_source_code::*, + find_references::*, health::*, list_files::*, lsp_ws::*, read_source_code::*, }; diff --git a/crates/proxy/src/handlers/read_source_code.rs b/crates/proxy/src/handlers/read_source_code.rs index db015d6f..9e027535 100644 --- a/crates/proxy/src/handlers/read_source_code.rs +++ b/crates/proxy/src/handlers/read_source_code.rs @@ -1,16 +1,10 @@ use crate::AppState; use actix_web::web::{Data, Json}; use actix_web::HttpResponse; -use common::api_types::{ErrorResponse, ReadSourceCodeRequest}; +use common::api_types::{ErrorResponse, ReadSourceCodeRequest, ReadSourceCodeResponse}; use log::{error, info}; -use serde::Serialize; use std::path::PathBuf; -#[derive(Serialize)] -struct ReadSourceResponse { - source_code: String, -} - /// Read source code from a file #[utoipa::path( post, @@ -18,7 +12,7 @@ struct ReadSourceResponse { tag = "file", request_body = ReadSourceCodeRequest, responses( - (status = 200, description = "Source code retrieved successfully"), + (status = 200, description = "Source code retrieved successfully", body = ReadSourceCodeResponse), (status = 400, description = "Bad request"), (status = 500, description = "Internal server error") ) @@ -79,9 +73,9 @@ pub async fn read_source_code( let selected_lines = &lines[start_line..end_line]; let source_code = selected_lines.join("\n"); - HttpResponse::Ok().json(ReadSourceResponse { source_code }) + HttpResponse::Ok().json(ReadSourceCodeResponse { source_code }) } else { - HttpResponse::Ok().json(ReadSourceResponse { + HttpResponse::Ok().json(ReadSourceCodeResponse { source_code: content, }) } diff --git a/crates/proxy/src/lib.rs b/crates/proxy/src/lib.rs index ab390c4f..8f8f3c58 100644 --- a/crates/proxy/src/lib.rs +++ b/crates/proxy/src/lib.rs @@ -1,13 +1,12 @@ use actix_cors::Cors; mod middleware; use actix_web::{ - web::{get, post, resource, scope, Data}, + web::{get, post, scope, Data}, App, HttpServer, }; -use common::api_types::{FindIdentifierRequest, IdentifierResponse}; -use handlers::{find_identifier, read_source_code}; use log::{error, info, warn}; use middleware::JwtMiddleware; +use std::env; use std::fs; use std::fs::File; use std::io::Write; @@ -22,16 +21,17 @@ pub mod container; mod handlers; use crate::handlers::{ - definitions_in_file, find_definition, find_referenced_symbols, find_references, health_check, - list_files, + definitions_in_file, find_definition, find_identifier, find_referenced_symbols, + find_references, health_check, list_files, lsp_ws, read_source_code, }; use common::api_types::{ - get_mount_dir, set_global_mount_dir, CodeContext, DefinitionResponse, ErrorResponse, - FilePosition, FileRange, FileSymbolsRequest, GetDefinitionRequest, GetReferencedSymbolsRequest, - GetReferencesRequest, HealthResponse, Position, ReferenceWithSymbolDefinitions, - ReferencedSymbolsResponse, ReferencesResponse, SupportedLanguages, Symbol, SymbolResponse, + get_mount_dir, set_global_mount_dir, CodeContext, DefinitionsInFileRequest, ErrorResponse, + FilePosition, FileRange, FindDefinitionRequest, FindDefinitionResponse, FindIdentifierRequest, + FindIdentifierResponse, FindReferencedSymbolsRequest, FindReferencedSymbolsResponse, + FindReferencesRequest, FindReferencesResponse, HealthResponse, Identifier, Position, Range, + ReadSourceCodeRequest, ReadSourceCodeResponse, ReferenceWithSymbolDefinitions, + SupportedLanguages, Symbol, }; -// use common::utils::doc_utils::make_code_sample; pub fn check_mount_dir() -> std::io::Result<()> { fs::read_dir(get_mount_dir())?; @@ -53,42 +53,45 @@ pub fn check_mount_dir() -> std::io::Result<()> { ), components( schemas( - FileSymbolsRequest, - GetDefinitionRequest, - GetReferencesRequest, - GetReferencedSymbolsRequest, - SupportedLanguages, - DefinitionResponse, - ReferencesResponse, - ReferencedSymbolsResponse, - SymbolResponse, - ReferenceWithSymbolDefinitions, - FilePosition, - Position, - Symbol, - ErrorResponse, CodeContext, + DefinitionsInFileRequest, + ErrorResponse, + FilePosition, FileRange, - HealthResponse, + FindDefinitionRequest, + FindDefinitionResponse, FindIdentifierRequest, - IdentifierResponse, + FindIdentifierResponse, + FindReferencedSymbolsRequest, + FindReferencedSymbolsResponse, + FindReferencesRequest, + FindReferencesResponse, + HealthResponse, + Identifier, + Position, + Range, + ReadSourceCodeRequest, + ReadSourceCodeResponse, + ReferenceWithSymbolDefinitions, + SupportedLanguages, + Symbol, ) ), paths( crate::handlers::definitions_in_file, crate::handlers::find_definition, + crate::handlers::find_identifier, + crate::handlers::find_referenced_symbols, crate::handlers::find_references, crate::handlers::health_check, crate::handlers::list_files, crate::handlers::read_source_code, - crate::handlers::find_referenced_symbols, - crate::handlers::find_identifier, ), tags( (name = "nuanced-lsp-api", description = "Nuanced LSP API") ), servers( - (url = "http://localhost:4444/v1", description = "API server v1") + (url = "http://localhost:4444/v1", description = "API server v1"), ) )] pub struct ApiDoc; @@ -177,13 +180,6 @@ pub async fn initialize_app_state_with_mount_dir( })) } -// Helper enum for cleaner matching -#[derive(Debug)] -enum Method { - Get, - Post, -} - pub async fn run_server(app_state: Data) -> std::io::Result<()> { run_server_with_host(app_state, "0.0.0.0").await } @@ -234,68 +230,37 @@ pub async fn run_server_with_port_and_host( // Initialize JWT middleware once before creating workers to fail fast // If this panics, it happens in the main thread before any workers start let jwt_middleware = if middleware::is_auth_enabled() { - match JwtMiddleware::from_env() { - Ok(middleware) => Some(middleware), - Err(e) => { - error!("Failed to initialize JWT middleware: {}", e); - std::process::exit(1); - } - } + JwtMiddleware::from_env().unwrap_or_else(|e| { + error!("Failed to initialize JWT middleware: {}", e); + std::process::exit(1); + }) } else { - None + JwtMiddleware::disabled() }; HttpServer::new(move || { - let mut api_scope = scope(format!("/{}", server_path).as_str()); - - // Add routes based on OpenAPI paths - for (path, path_item) in openapi.paths.paths.iter() { - let method = if path_item.get.is_some() { - Some(Method::Get) - } else if path_item.post.is_some() { - Some(Method::Post) - } else { - None - }; - - api_scope = match (path.as_str(), method) { - ("/symbol/find-definition", Some(Method::Post)) => - api_scope.service(resource(path).route(post().to(find_definition))), - ("/symbol/find-references", Some(Method::Post)) => - api_scope.service(resource(path).route(post().to(find_references))), - ("/symbol/find-referenced-symbols", Some(Method::Post)) => - api_scope.service(resource(path).route(post().to(find_referenced_symbols))), - ("/symbol/find-identifier", Some(Method::Post)) => - api_scope.service(resource(path).route(post().to(find_identifier))), - ("/symbol/definitions-in-file", Some(Method::Get)) => - api_scope.service(resource(path).route(get().to(definitions_in_file))), - ("/workspace/list-files", Some(Method::Get)) => - api_scope.service(resource(path).route(get().to(list_files))), - ("/workspace/read-source-code", Some(Method::Post)) => - api_scope.service(resource(path).route(post().to(read_source_code))), - ("/system/health", Some(Method::Get)) => - api_scope.service(resource(path).route(get().to(health_check))), - (p, m) => panic!( - "Invalid path configuration for {}: {:?}. Ensure the OpenAPI spec matches your handlers.", - p, - m - ) - }; - } - App::new() .wrap(Cors::permissive()) + .wrap(jwt_middleware.clone()) .app_data(app_state.clone()) .configure(|cfg| { - if let Some(ref middleware) = jwt_middleware { - cfg.service(api_scope.wrap(middleware.clone())); - } else { - cfg.service(api_scope); - } + cfg.route("/lsp/ws", get().to(lsp_ws)).service( + scope(format!("/{}", server_path).as_str()) + .route("/symbol/definitions-in-file", get().to(definitions_in_file)) + .route("/symbol/find-definition", post().to(find_definition)) + .route("/symbol/find-identifier", post().to(find_identifier)) + .route( + "/symbol/find-referenced-symbols", + post().to(find_referenced_symbols), + ) + .route("/symbol/find-references", post().to(find_references)) + .route("/system/health", get().to(health_check)) + .route("/workspace/list-files", get().to(list_files)) + .route("/workspace/read-source-code", post().to(read_source_code)), + ); }) .service( - SwaggerUi::new("/swagger-ui/{_:.*}") - .url("/api-docs/openapi.json", openapi.clone()) + SwaggerUi::new("/swagger-ui/{_:.*}").url("/api-docs/openapi.json", openapi.clone()), ) }) .bind(format!("{}:{}", host, port))? @@ -303,28 +268,10 @@ pub async fn run_server_with_port_and_host( .await } -// const PYTHON_SAMPLE: &str = r#" -// import requests - -// def get_pet(pet_id: int): -// response = requests.get(f'/pets/{pet_id}') -// return response.json() -// "#; - pub fn write_openapi_to_file(file_path: &PathBuf) -> std::io::Result<()> { // We use a clone since we're just adding the docs and writing it to the file. We don't need // this for runtime let mut openapi = ApiDoc::openapi().clone(); - // if let Some(path_item) = openapi.paths.paths.get_mut("/symbol/find-definition") { - // if let Some(post_op) = &mut path_item.post { - // let mut extensions = Extensions::default(); - // extensions.insert( - // String::from("x-codeSamples"), - // serde_json::json!(vec![make_code_sample("python", PYTHON_SAMPLE),]), - // ); - // post_op.extensions = Some(extensions); - // } - // } // Create components if none exist if openapi.components.is_none() { diff --git a/crates/proxy/src/middleware/jwt.rs b/crates/proxy/src/middleware/jwt.rs index c7f9d524..9ca71c82 100644 --- a/crates/proxy/src/middleware/jwt.rs +++ b/crates/proxy/src/middleware/jwt.rs @@ -21,6 +21,13 @@ pub struct JwtMiddleware { } impl JwtMiddleware { + /// Create middleware with auth disabled + pub fn disabled() -> Self { + Self { + secret: String::default(), + } + } + /// Create middleware from environment variable (for production use) pub fn from_env() -> Result { let secret = env::var("JWT_SECRET") @@ -73,6 +80,10 @@ where forward_ready!(service); fn call(&self, req: ServiceRequest) -> Self::Future { + if self.secret.is_empty() { + return Box::pin(self.service.call(req)); + } + let auth_header = req.headers().get("Authorization"); let secret = self.secret.clone(); diff --git a/crates/wrapper/Cargo.toml b/crates/wrapper/Cargo.toml index 3eb9f5c5..f5ba89dc 100644 --- a/crates/wrapper/Cargo.toml +++ b/crates/wrapper/Cargo.toml @@ -15,6 +15,7 @@ path = "src/main.rs" ast-grep.workspace = true common.workspace = true actix-web.workspace = true +actix-ws.workspace = true tokio.workspace = true serde.workspace = true serde_json.workspace = true @@ -30,6 +31,7 @@ strum.workspace = true strum_macros.workspace = true url.workspace = true utoipa.workspace = true +futures = "0.3" # Wrapper-specific notify-debouncer-mini = "0.4" diff --git a/crates/wrapper/src/handlers/definitions_in_file.rs b/crates/wrapper/src/handlers/definitions_in_file.rs index e9108b70..c1c65bde 100644 --- a/crates/wrapper/src/handlers/definitions_in_file.rs +++ b/crates/wrapper/src/handlers/definitions_in_file.rs @@ -3,7 +3,7 @@ use actix_web::HttpResponse; use log::info; use crate::AppState; -use common::api_types::{ErrorResponse, FileSymbolsRequest, Symbol}; +use common::api_types::{DefinitionsInFileRequest, ErrorResponse, Symbol}; /// Get symbols in a specific file (uses ast-grep) /// @@ -25,7 +25,7 @@ use common::api_types::{ErrorResponse, FileSymbolsRequest, Symbol}; get, path = "/symbol/definitions-in-file", tag = "symbol", - params(FileSymbolsRequest), + params(DefinitionsInFileRequest), responses( (status = 200, description = "Symbols retrieved successfully", body = Vec), (status = 400, description = "Bad request"), @@ -34,14 +34,18 @@ use common::api_types::{ErrorResponse, FileSymbolsRequest, Symbol}; )] pub async fn definitions_in_file( data: Data, - info: Query, + info: Query, ) -> HttpResponse { info!( "Received definitions in file request for file: {}", info.file_path ); - match data.manager.get_definitions_in_file(&info.file_path).await { + match data + .api_manager + .get_definitions_in_file(&info.file_path) + .await + { Ok(symbols) => { let symbol_response: Vec = symbols .into_iter() diff --git a/crates/wrapper/src/handlers/error.rs b/crates/wrapper/src/handlers/error.rs index 5912f094..0d17f9b5 100644 --- a/crates/wrapper/src/handlers/error.rs +++ b/crates/wrapper/src/handlers/error.rs @@ -1,4 +1,4 @@ -use crate::manager::LspManagerError; +use crate::managers::api::ApiManagerError; use actix_web::HttpResponse; use common::api_types::ErrorResponse; @@ -6,13 +6,16 @@ pub trait IntoHttpResponse { fn into_http_response(self) -> HttpResponse; } -impl IntoHttpResponse for LspManagerError { +impl IntoHttpResponse for ApiManagerError { fn into_http_response(self) -> HttpResponse { log::error!("LSP error: {}", self); match self { Self::FileNotFound(path) => HttpResponse::BadRequest().json(ErrorResponse { error: format!("File not found: {}", path), }), + Self::BadRequest(path) => HttpResponse::BadRequest().json(ErrorResponse { + error: format!("{}", path), + }), Self::LspClientNotFound(lang) => { HttpResponse::InternalServerError().json(ErrorResponse { error: format!("LSP client not found for {:?}", lang), diff --git a/crates/wrapper/src/handlers/find_definition.rs b/crates/wrapper/src/handlers/find_definition.rs index 08c27f4f..dd56a415 100644 --- a/crates/wrapper/src/handlers/find_definition.rs +++ b/crates/wrapper/src/handlers/find_definition.rs @@ -1,14 +1,15 @@ use crate::handlers::error::IntoHttpResponse; -use crate::manager::{LspManagerError, Manager}; +use crate::managers::api::{ApiManager, ApiManagerError}; use actix_web::web::{Data, Json}; use actix_web::HttpResponse; -use common::api_types::{CodeContext, FileRange, Position, Range}; +use common::api_types::{ + CodeContext, FileRange, FindDefinitionRequest, FindDefinitionResponse, Position, Range, +}; use common::utils::file_utils::uri_to_relative_path_string; use log::{error, info, warn}; use crate::handlers::utils; use crate::AppState; -use common::api_types::{DefinitionResponse, GetDefinitionRequest}; use common::api_types::{ErrorResponse, FilePosition}; use lsp_types::{GotoDefinitionResponse, Location, Position as LspPosition, Range as LspRange}; /// Get the definition of a symbol at a specific position in a file @@ -34,16 +35,16 @@ use lsp_types::{GotoDefinitionResponse, Location, Position as LspPosition, Range post, path = "/symbol/find-definition", tag = "symbol", - request_body = GetDefinitionRequest, + request_body = FindDefinitionRequest, responses( - (status = 200, description = "Definition retrieved successfully", body = DefinitionResponse), + (status = 200, description = "Definition retrieved successfully", body = FindDefinitionResponse), (status = 400, description = "Bad request"), (status = 500, description = "Internal server error") ) )] pub async fn find_definition( data: Data, - info: Json, + info: Json, ) -> HttpResponse { info!( "Received definition request for file: {}, line: {}, character: {}", @@ -56,7 +57,11 @@ pub async fn find_definition( position: info.position.position.clone(), }; - let file_identifiers = match data.manager.get_file_identifiers(&file_position.path).await { + let file_identifiers = match data + .api_manager + .get_file_identifiers(&file_position.path) + .await + { Ok(identifiers) => identifiers, Err(e) => { error!("Failed to get file identifiers: {:?}", e); @@ -77,7 +82,7 @@ pub async fn find_definition( // Call LSP directly (no ast-grep for identifier detection) let definitions = match data - .manager + .api_manager .find_definition( &info.position.path, LspPosition { @@ -94,7 +99,7 @@ pub async fn find_definition( }; let source_code_context = if info.include_source_code { - match fetch_definition_source_code(&data.manager, &definitions).await { + match fetch_definition_source_code(&data.api_manager, &definitions).await { Ok(context) => Some(context), Err(e) => { error!("Failed to fetch definition source code: {:?}", e); @@ -105,7 +110,7 @@ pub async fn find_definition( None }; - HttpResponse::Ok().json(DefinitionResponse { + HttpResponse::Ok().json(FindDefinitionResponse { raw_response: if info.include_raw_response { Some(serde_json::to_value(&definitions).unwrap()) } else { @@ -134,9 +139,9 @@ pub async fn find_definition( } async fn fetch_definition_source_code( - manager: &Manager, + manager: &ApiManager, definitions_response: &GotoDefinitionResponse, -) -> Result, LspManagerError> { +) -> Result, ApiManagerError> { let mut code_contexts = Vec::new(); let definitions: &Vec = match definitions_response { GotoDefinitionResponse::Scalar(definition) => &vec![definition.clone()], diff --git a/crates/wrapper/src/handlers/find_identifier.rs b/crates/wrapper/src/handlers/find_identifier.rs index 51a401f3..f2a72834 100644 --- a/crates/wrapper/src/handlers/find_identifier.rs +++ b/crates/wrapper/src/handlers/find_identifier.rs @@ -9,7 +9,7 @@ use crate::{ }; use common::api_types::{ - ErrorResponse, FilePosition, FindIdentifierRequest, Identifier, IdentifierResponse, + ErrorResponse, FilePosition, FindIdentifierRequest, FindIdentifierResponse, Identifier, }; use log::{error, info}; @@ -34,7 +34,7 @@ use log::{error, info}; tag = "symbol", request_body = FindIdentifierRequest, responses( - (status = 200, description = "Identifier retrieved successfully", body = IdentifierResponse), + (status = 200, description = "Identifier retrieved successfully", body = FindIdentifierResponse), (status = 400, description = "Bad request"), (status = 500, description = "Internal server error") ) @@ -47,7 +47,7 @@ pub async fn find_identifier( "Received identifier request for file: {}, name: {}, position: {:?}", info.path, info.name, info.position ); - let file_identifiers = match data.manager.get_file_identifiers(&info.path).await { + let file_identifiers = match data.api_manager.get_file_identifiers(&info.path).await { Ok(identifiers) => identifiers, Err(e) => { error!("Failed to get file identifiers: {:?}", e); @@ -64,7 +64,7 @@ pub async fn find_identifier( .collect(); if name_matched_identifiers.is_empty() { - return HttpResponse::Ok().json(IdentifierResponse { + return HttpResponse::Ok().json(FindIdentifierResponse { identifiers: vec![], }); } @@ -79,18 +79,18 @@ pub async fn find_identifier( ) .await { - Ok(identifier) => HttpResponse::Ok().json(IdentifierResponse { + Ok(identifier) => HttpResponse::Ok().json(FindIdentifierResponse { identifiers: vec![identifier], }), Err(PositionError::IdentifierNotFound { closest }) => { // Not an error case, just closest matches - HttpResponse::Ok().json(IdentifierResponse { + HttpResponse::Ok().json(FindIdentifierResponse { identifiers: closest, }) } } } else { - HttpResponse::Ok().json(IdentifierResponse { + HttpResponse::Ok().json(FindIdentifierResponse { identifiers: name_matched_identifiers, }) } diff --git a/crates/wrapper/src/handlers/find_referenced_symbols.rs b/crates/wrapper/src/handlers/find_referenced_symbols.rs index 5439c8a9..831601c2 100644 --- a/crates/wrapper/src/handlers/find_referenced_symbols.rs +++ b/crates/wrapper/src/handlers/find_referenced_symbols.rs @@ -2,8 +2,8 @@ use crate::AppState; use actix_web::web::{Data, Json}; use actix_web::HttpResponse; use common::api_types::{ - get_mount_dir, ErrorResponse, FilePosition, GetReferencedSymbolsRequest, Identifier, Position, - ReferenceWithSymbolDefinitions, ReferencedSymbolsResponse, + get_mount_dir, ErrorResponse, FilePosition, FindReferencedSymbolsRequest, + FindReferencedSymbolsResponse, Identifier, Position, ReferenceWithSymbolDefinitions, }; use common::utils::file_utils::uri_to_relative_path_string; use log::{error, info}; @@ -34,16 +34,16 @@ use lsp_types::{GotoDefinitionResponse, Position as LspPosition}; post, path = "/symbol/find-referenced-symbols", tag = "symbol", - request_body = GetReferencedSymbolsRequest, + request_body = FindReferencedSymbolsRequest, responses( - (status = 200, description = "Referenced symbols retrieved successfully", body = ReferencedSymbolsResponse), + (status = 200, description = "Referenced symbols retrieved successfully", body = FindReferencedSymbolsResponse), (status = 400, description = "Bad request"), (status = 500, description = "Internal server error") ) )] pub async fn find_referenced_symbols( data: Data, - info: Json, + info: Json, ) -> HttpResponse { info!( "Received referenced symbols request for file: {}, line: {}, character: {}", @@ -53,7 +53,7 @@ pub async fn find_referenced_symbols( ); let referenecd_ast_symbols = match data - .manager + .api_manager .find_referenced_symbols( &info.identifier_position.path, LspPosition { @@ -136,7 +136,7 @@ pub async fn find_referenced_symbols( }; match data - .manager + .api_manager .get_symbol_from_position(&def.path, &def_position) .await { @@ -163,7 +163,7 @@ pub async fn find_referenced_symbols( // Solution: Use ast-grep to get all identifiers in the file, find the one // matching by name and line number, then call get_symbol_from_position // using that identifier's position (which aligns with documentSymbol). - match data.manager.get_file_identifiers(&def.path).await { + match data.api_manager.get_file_identifiers(&def.path).await { Ok(identifiers) => { // Find the identifier on the same line as the definition with matching name if let Some(found_identifier) = identifiers.iter().find(|id| { @@ -181,7 +181,7 @@ pub async fn find_referenced_symbols( }; if let Ok(symbol) = data - .manager + .api_manager .get_symbol_from_position(&def.path, &id_position) .await { @@ -260,7 +260,7 @@ pub async fn find_referenced_symbols( }); // Return the sorted response - HttpResponse::Ok().json(ReferencedSymbolsResponse { + HttpResponse::Ok().json(FindReferencedSymbolsResponse { workspace_symbols, external_symbols, not_found, diff --git a/crates/wrapper/src/handlers/find_references.rs b/crates/wrapper/src/handlers/find_references.rs index 74dfb0e3..4a4d4071 100644 --- a/crates/wrapper/src/handlers/find_references.rs +++ b/crates/wrapper/src/handlers/find_references.rs @@ -5,11 +5,11 @@ use lsp_types::{Location, Position as LspPosition}; use crate::handlers::error::IntoHttpResponse; use crate::handlers::utils; -use crate::manager::{LspManagerError, Manager}; +use crate::managers::api::{ApiManager, ApiManagerError}; use crate::AppState; use common::api_types::{ - get_mount_dir, CodeContext, ErrorResponse, FilePosition, FileRange, GetReferencesRequest, - Position, Range, ReferencesResponse, + get_mount_dir, CodeContext, ErrorResponse, FilePosition, FileRange, FindReferencesRequest, + FindReferencesResponse, Position, Range, }; use common::utils::file_utils::uri_to_relative_path_string; @@ -36,16 +36,16 @@ use common::utils::file_utils::uri_to_relative_path_string; post, path = "/symbol/find-references", tag = "symbol", - request_body = GetReferencesRequest, + request_body = FindReferencesRequest, responses( - (status = 200, description = "References retrieved successfully", body = ReferencesResponse), + (status = 200, description = "References retrieved successfully", body = FindReferencesResponse), (status = 400, description = "Bad request"), (status = 500, description = "Internal server error") ) )] pub async fn find_references( data: Data, - info: Json, + info: Json, ) -> HttpResponse { info!( "Received references request for file: {}, line: {}, character: {}", @@ -55,7 +55,7 @@ pub async fn find_references( ); let file_identifiers = match data - .manager + .api_manager .get_file_identifiers(&info.identifier_position.path) .await { @@ -81,9 +81,9 @@ pub async fn find_references( }; let references_result = - find_and_filter_references(&data.manager, &info.identifier_position).await; + find_and_filter_references(&data.api_manager, &info.identifier_position).await; let code_contexts_result = get_code_contexts( - &data.manager, + &data.api_manager, &references_result, info.include_code_context_lines, ) @@ -103,7 +103,7 @@ pub async fn find_references( None }; - let response = ReferencesResponse { + let response = FindReferencesResponse { raw_response, references: references .into_iter() @@ -131,9 +131,9 @@ pub async fn find_references( } async fn find_and_filter_references( - manager: &Manager, + manager: &ApiManager, position: &FilePosition, -) -> Result, LspManagerError> { +) -> Result, ApiManagerError> { let references = manager .find_references( &position.path, @@ -166,10 +166,10 @@ async fn find_and_filter_references( } async fn get_code_contexts( - manager: &Manager, - references_result: &Result, LspManagerError>, + manager: &ApiManager, + references_result: &Result, ApiManagerError>, context_lines: Option, -) -> Result>, LspManagerError> { +) -> Result>, ApiManagerError> { match (references_result, context_lines) { (Ok(refs), Some(lines)) => fetch_code_context(manager, refs.clone(), lines) .await @@ -178,15 +178,15 @@ async fn get_code_contexts( } } -fn handle_lsp_error(e: LspManagerError) -> HttpResponse { +fn handle_lsp_error(e: ApiManagerError) -> HttpResponse { e.into_http_response() } async fn fetch_code_context( - manager: &Manager, + manager: &ApiManager, references: Vec, context_lines: u32, -) -> Result, LspManagerError> { +) -> Result, ApiManagerError> { let mut code_contexts = Vec::new(); for reference in references { let range = lsp_types::Range { diff --git a/crates/wrapper/src/handlers/lsp_ws.rs b/crates/wrapper/src/handlers/lsp_ws.rs new file mode 100644 index 00000000..3e46c689 --- /dev/null +++ b/crates/wrapper/src/handlers/lsp_ws.rs @@ -0,0 +1,29 @@ +use crate::managers::ws::WsManager; +use actix_web::{web, HttpRequest, HttpResponse}; +use log::{debug, error}; + +/// WebSocket endpoint for bidirectional LSP communication +pub async fn lsp_ws( + req: HttpRequest, + stream: web::Payload, + data: web::Data, +) -> Result { + debug!("WebSocket connection request received"); + + let (response, session, msg_stream) = actix_ws::handle(&req, stream)?; + + // Get the LSP client from the ApiManager + let client = data.api_manager.get_lsp_client(); + + // Create WsManager and handle the connection + let ws_manager = WsManager::new(client); + + // Spawn task to handle the connection using actix_web::rt (supports !Send futures) + actix_web::rt::spawn(async move { + if let Err(e) = ws_manager.handle_connection(session, msg_stream).await { + error!("WebSocket connection error: {}", e); + } + }); + + Ok(response) +} diff --git a/crates/wrapper/src/handlers/mod.rs b/crates/wrapper/src/handlers/mod.rs index 34508eb5..4866e673 100644 --- a/crates/wrapper/src/handlers/mod.rs +++ b/crates/wrapper/src/handlers/mod.rs @@ -4,5 +4,6 @@ pub mod find_definition; pub mod find_identifier; pub mod find_referenced_symbols; pub mod find_references; +pub mod lsp_ws; pub mod utils; diff --git a/crates/wrapper/src/lib.rs b/crates/wrapper/src/lib.rs index 78d70927..15f35bd7 100644 --- a/crates/wrapper/src/lib.rs +++ b/crates/wrapper/src/lib.rs @@ -4,10 +4,10 @@ // Local modules pub mod handlers; pub mod lsp; -pub mod manager; +pub mod managers; // Re-export commonly used types -pub use manager::Manager; +pub use managers::api::ApiManager; // Test utilities (available in test builds) #[cfg(test)] @@ -15,5 +15,5 @@ pub mod test_utils; // AppState struct (shared between lib and bin) pub struct AppState { - pub manager: Manager, + pub api_manager: ApiManager, } diff --git a/crates/wrapper/src/lsp/client.rs b/crates/wrapper/src/lsp/client.rs index 427fe6b7..d53e8df7 100644 --- a/crates/wrapper/src/lsp/client.rs +++ b/crates/wrapper/src/lsp/client.rs @@ -1,36 +1,142 @@ -use crate::lsp::json_rpc::JsonRpc; -use crate::lsp::process::Process; -use crate::lsp::{ExpectedMessageKey, JsonRpcHandler, ProcessHandler}; use async_trait::async_trait; -use common::utils::file_utils::{fix_relative_uris, search_paths, FileType}; +use common::utils::file_utils::fix_relative_uris; use common::utils::language_utils::detect_language_string; use log::{debug, error, info, warn}; use lsp_types::{ ClientCapabilities, DidOpenTextDocumentParams, DocumentSymbolClientCapabilities, - GotoDefinitionParams, GotoDefinitionResponse, InitializeParams, InitializeResult, Location, - PartialResultParams, Position, PublishDiagnosticsClientCapabilities, ReferenceContext, - ReferenceParams, TagSupport, TextDocumentClientCapabilities, TextDocumentIdentifier, - TextDocumentItem, TextDocumentPositionParams, Url, WorkDoneProgressParams, WorkspaceFolder, + GeneralClientCapabilities, GotoDefinitionParams, GotoDefinitionResponse, InitializeParams, + InitializeResult, Location, PartialResultParams, Position, PositionEncodingKind, + PublishDiagnosticsClientCapabilities, ReferenceContext, ReferenceParams, TagSupport, + TextDocumentClientCapabilities, TextDocumentIdentifier, TextDocumentItem, + TextDocumentPositionParams, Url, WorkDoneProgressParams, }; use std::error::Error; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; +use std::sync::LazyLock; use common::utils::workspace_documents::{ - DidOpenConfiguration, WorkspaceDocuments, WorkspaceDocumentsHandler, DEFAULT_EXCLUDE_PATTERNS, + DidOpenConfiguration, WorkspaceDocuments, WorkspaceDocumentsHandler, }; -use super::PendingRequests; +use crate::lsp::json_rpc::JsonRpc; +use crate::lsp::process::Process; +use crate::lsp::{ExpectedMessageKey, JsonRpcHandler, PendingRequests, ProcessHandler}; + +#[derive(Clone, Debug)] +pub enum PostInitializeMessage { + Request { + method: String, + params: Option, + }, + Notification { + method: String, + params: Option, + }, + ExpectNotification { + method: String, + params: serde_json::Value, + }, +} #[async_trait] -pub trait LspClient: Send { - async fn initialize( +pub trait LspConfig: Send + Sync { + async fn get_initialize_params( + &mut self, + root_path: String, + ) -> Result>; + + fn get_post_initialize_messages(&self) -> Vec { + vec![] + } + + fn get_root_files(&mut self) -> Vec { + vec![".git".to_string()] + } + + fn include_patterns(&self) -> Vec; + + fn exclude_patterns(&self) -> Vec; + + fn did_open_configuration(&self) -> DidOpenConfiguration; +} + +pub(crate) static CLIENT_CAPABILITES: LazyLock = LazyLock::new(|| { + let mut capabilities = ClientCapabilities::default(); + capabilities.general = Some(GeneralClientCapabilities { + position_encodings: Some(vec![PositionEncodingKind::UTF16]), + ..Default::default() + }); + capabilities.text_document = Some(TextDocumentClientCapabilities { + document_symbol: Some(DocumentSymbolClientCapabilities { + dynamic_registration: Some(false), + hierarchical_document_symbol_support: Some(true), + ..Default::default() + }), + // Turn off diagnostics for performance, we don't use them at the moment + publish_diagnostics: Some(PublishDiagnosticsClientCapabilities { + related_information: Some(false), + tag_support: Some(TagSupport { value_set: vec![] }), + code_description_support: Some(false), + data_support: Some(false), + version_support: Some(false), + }), + ..Default::default() + }); + + capabilities.experimental = Some(serde_json::json!({ + "serverStatusNotification": true + })); + capabilities +}); + +pub struct LspClient { + config: Box, + process: ProcessHandler, + json_rpc: JsonRpcHandler, + workspace_documents: WorkspaceDocumentsHandler, + pending_requests: PendingRequests, + unexpected_notifications_tx: tokio::sync::broadcast::Sender, +} + +impl LspClient { + pub fn new(config: Box, process: ProcessHandler, root_path: &str) -> Self { + let json_rpc = JsonRpcHandler::new(); + let pending_requests = PendingRequests::new(); + let (unexpected_notifications_tx, _) = + tokio::sync::broadcast::channel::(1); + let (_, workspace_docs_rx) = + tokio::sync::broadcast::channel::(1); + + let include_patterns = config.include_patterns(); + let exclude_patterns = config.exclude_patterns(); + let did_open_configuration = config.did_open_configuration(); + + let workspace_documents = WorkspaceDocumentsHandler::new( + std::path::Path::new(root_path), + include_patterns, + exclude_patterns, + workspace_docs_rx, + did_open_configuration, + ); + + Self { + config, + process, + json_rpc, + workspace_documents, + pending_requests, + unexpected_notifications_tx, + } + } + + pub async fn initialize( &mut self, root_path: String, ) -> Result> { info!("Initializing LSP client with root path: {:?}", root_path); self.start_response_listener().await?; - let params = self.get_initialize_params(root_path.clone()).await?; + let params = self.config.get_initialize_params(root_path.clone()).await?; let result = self .send_request("initialize", Some(serde_json::to_value(params)?)) @@ -39,61 +145,54 @@ pub trait LspClient: Send { debug!("Initialization successful: {:?}", init_result); self.send_initialized().await?; - Ok(init_result) - } - - fn get_capabilities(&mut self) -> ClientCapabilities { - let mut capabilities = ClientCapabilities::default(); - capabilities.text_document = Some(TextDocumentClientCapabilities { - document_symbol: Some(DocumentSymbolClientCapabilities { - dynamic_registration: Some(false), - hierarchical_document_symbol_support: Some(true), - ..Default::default() - }), - // Turn off diagnostics for performance, we don't use them at the moment - publish_diagnostics: Some(PublishDiagnosticsClientCapabilities { - related_information: Some(false), - tag_support: Some(TagSupport { value_set: vec![] }), - code_description_support: Some(false), - data_support: Some(false), - version_support: Some(false), - }), - ..Default::default() - }); + for message in self.config.get_post_initialize_messages() { + match message { + PostInitializeMessage::Request { method, params } => { + debug!("Post-init: calling request method: {}", method); + self.send_request(&method, params).await?; + } + PostInitializeMessage::Notification { method, params } => { + debug!("Post-init: sending notification: {}", method); + self.send_notification(&method, params).await?; + } + PostInitializeMessage::ExpectNotification { method, params } => { + debug!("Post-init: waiting for notification: {}", method); + let mut notification_rx = self + .expect_notification(ExpectedMessageKey { method, params }) + .await?; + // Wait indefinitely for ServiceReady notification + // The orchestrator health check and CLI timeout control overall timing + notification_rx.recv().await?; + } + } + } + debug!("Post-init successful"); - capabilities.experimental = Some(serde_json::json!({ - "serverStatusNotification": true - })); - capabilities + Ok(init_result) } - async fn get_initialize_params( + pub async fn send_notification( &mut self, - root_path: String, - ) -> Result> { - let workspace_folders = self.find_workspace_folders(root_path.clone()).await?; - #[allow(deprecated)] - let params = InitializeParams { - capabilities: self.get_capabilities(), - workspace_folders: Some(workspace_folders), - root_uri: Some(Url::from_file_path(&root_path).unwrap()), // primarily for python - ..Default::default() - }; - Ok(params) + method: &str, + params: Option, + ) -> Result<(), Box> { + let notification = self.json_rpc.create_notification(method, params); + debug!("Sending notification: {}", method); + self.process.send(¬ification).await?; + Ok(()) } - async fn send_request( + pub async fn send_request( &mut self, method: &str, params: Option, ) -> Result> { - let (id, request) = self.get_json_rpc().create_request(method, params); + let (id, request) = self.json_rpc.create_request(method, params); - let mut response_receiver = self.get_pending_requests().add_request(id).await?; + let mut response_receiver = self.pending_requests.add_request(id).await?; - let message = format!("Content-Length: {}\r\n\r\n{}", request.len(), request); - debug!("Message: {:?}", message); - self.get_process().send(&message).await?; + debug!("Sending request {}: {}", id, method); + self.process.send(&request).await?; let response = response_receiver .recv() @@ -114,44 +213,50 @@ pub trait LspClient: Send { } async fn start_response_listener(&mut self) -> Result<(), Box> { - let mut process = self.get_process().clone(); - let pending_requests = self.get_pending_requests().clone(); - let json_rpc = self.get_json_rpc().clone(); + let mut process = self.process.clone(); + let pending_requests = self.pending_requests.clone(); + let json_rpc = self.json_rpc.clone(); + let notification_channel = self.unexpected_notifications_tx.clone(); tokio::spawn(async move { loop { if let Ok(raw_response) = process.receive().await { if let Ok(message) = json_rpc.parse_message(&raw_response) { - if let Some(id) = message.id { + if let Some(id) = &message.id { + // we always use u64 ids here, so the server process should respond with those as well + let Some(id) = id.as_u64() else { + debug!("Message has invalid id type: {:?}", message.id); + continue; + }; debug!("Received response for request {}", id); if let Ok(Some(sender)) = pending_requests.remove_request(id).await { if sender.send(message.clone()).is_err() { error!("Failed to send response for request {}", id); } } else { - debug!( - "Responding to server message {} - Message: {:?}", - id, message - ); let response = json_rpc.create_success_response(id); - - let message = format!( - "Content-Length: {}\r\n\r\n{}", - response.len(), - response - ); - let _ = process.send(&message).await; + let _ = process.send(&response).await; } - } else if let Some(params) = message.params.clone() { - let message_key = ExpectedMessageKey { - method: message.method.clone().unwrap(), - params, - }; - if let Some(sender) = - pending_requests.remove_notification(message_key).await - { - sender.send(message).unwrap(); + } else if let Some(method) = message.method.clone() { + debug!("Received notification {}", method); + let mut handled = false; + if let Some(params) = &message.params { + let message_key = ExpectedMessageKey { + method, + params: params.clone(), + }; + if let Some(sender) = + pending_requests.remove_notification(message_key).await + { + handled = true; + sender.send(message.clone()).unwrap(); + } } + if !handled { + let _ = notification_channel.send(message); + } + } else { + debug!("Received unexpected message: {:?}", message); } } } @@ -162,16 +267,9 @@ pub trait LspClient: Send { } async fn send_initialized(&mut self) -> Result<(), Box> { - debug!("Sending 'initialized' notification"); - let notification = self - .get_json_rpc() - .create_notification("initialized", serde_json::json!({})); - let message = format!( - "Content-Length: {}\r\n\r\n{}", - notification.len(), - notification - ); - self.get_process().send(&message).await + info!("Sending initialized"); + self.send_notification("initialized", Some(serde_json::json!({}))) + .await } async fn text_document_did_open( @@ -181,18 +279,11 @@ pub trait LspClient: Send { let params = DidOpenTextDocumentParams { text_document: item, }; - let notification = self - .get_json_rpc() - .create_notification("textDocument/didOpen", serde_json::to_value(params)?); - let message = format!( - "Content-Length: {}\r\n\r\n{}", - notification.len(), - notification - ); - self.get_process().send(&message).await + self.send_notification("textDocument/didOpen", Some(serde_json::to_value(params)?)) + .await } - async fn text_document_definition( + pub async fn text_document_definition( &mut self, file_path: &str, position: Position, @@ -203,7 +294,7 @@ pub trait LspClient: Send { ); let needs_open = { - let workspace_documents = self.get_workspace_documents(); + let workspace_documents = &self.workspace_documents; workspace_documents.get_did_open_configuration() == DidOpenConfiguration::Lazy && !workspace_documents.is_did_open_document(file_path) }; @@ -212,7 +303,7 @@ pub trait LspClient: Send { if needs_open { info!("Sending textDocument/didOpen for {}", file_path); let document_text = self - .get_workspace_documents() + .workspace_documents .read_text_document(&PathBuf::from(file_path), None) .await?; @@ -224,8 +315,7 @@ pub trait LspClient: Send { }) .await?; - self.get_workspace_documents() - .add_did_open_document(file_path); + self.workspace_documents.add_did_open_document(file_path); } let params = GotoDefinitionParams { @@ -252,7 +342,7 @@ pub trait LspClient: Send { } else { // Pre-process the result to fix relative URIs (Sorbet issue) let workspace_path = self - .get_workspace_documents() + .workspace_documents .root_path() .to_str() .ok_or("Invalid workspace path")?; @@ -296,14 +386,19 @@ pub trait LspClient: Send { Ok(goto_resp) } - async fn text_document_reference( + pub async fn text_document_reference( &mut self, file_path: &str, position: Position, ) -> Result, Box> { + debug!( + "Requesting goto references for {}, line {}, character {}", + file_path, position.line, position.character + ); + // Get the configuration and check if document is opened first let needs_open = { - let workspace_documents = self.get_workspace_documents(); + let workspace_documents = &self.workspace_documents; workspace_documents.get_did_open_configuration() == DidOpenConfiguration::Lazy && !workspace_documents.is_did_open_document(file_path) }; @@ -312,7 +407,7 @@ pub trait LspClient: Send { if needs_open { info!("Sending textDocument/didOpen for {}", file_path); let document_text = self - .get_workspace_documents() + .workspace_documents .read_text_document(&PathBuf::from(file_path), None) .await?; @@ -324,8 +419,7 @@ pub trait LspClient: Send { }) .await?; - self.get_workspace_documents() - .add_did_open_document(file_path); + self.workspace_documents.add_did_open_document(file_path); } let params = ReferenceParams { @@ -354,7 +448,7 @@ pub trait LspClient: Send { } else { // Pre-process the result to fix relative URIs (Sorbet issue) let workspace_path = self - .get_workspace_documents() + .workspace_documents .root_path() .to_str() .ok_or("Invalid workspace path")?; @@ -365,91 +459,23 @@ pub trait LspClient: Send { Ok(ref_resp) } - fn get_process(&mut self) -> &mut ProcessHandler; - - fn get_json_rpc(&mut self) -> &mut JsonRpcHandler; - - fn get_root_files(&mut self) -> Vec { - vec![".git".to_string()] + pub fn subscribe_to_unexpected_notifications( + &self, + ) -> tokio::sync::broadcast::Receiver { + self.unexpected_notifications_tx.subscribe() } - fn get_pending_requests(&mut self) -> &mut PendingRequests; - - fn get_workspace_documents(&mut self) -> &mut WorkspaceDocumentsHandler; - - /// Sets up the workspace for the language server. - /// - /// Some language servers require specific commands to be run before - /// workspace-wide features are available. For example: - /// - TypeScript Language Server needs an explicit didOpen notification for each file - /// - Rust Analyzer needs a reloadWorkspace command - /// - /// # Arguments - /// - /// * `root_path` - The root path of the workspace - /// - /// # Returns - /// - /// A Result containing () if successful, or a boxed Error if an error occurred - #[allow(unused)] - async fn setup_workspace( + pub async fn expect_notification( &mut self, - root_path: &str, - ) -> Result<(), Box> { - Ok(()) + key: ExpectedMessageKey, + ) -> Result< + tokio::sync::broadcast::Receiver, + Box, + > { + self.pending_requests.add_notification(key).await } - async fn find_workspace_folders( - &mut self, - root_path: String, - ) -> Result, Box> { - let mut workspace_folders: Vec = Vec::new(); - let include_patterns = self - .get_root_files() - .into_iter() - .map(|f| format!("**/{f}")) - .collect(); - let exclude_patterns = DEFAULT_EXCLUDE_PATTERNS - .iter() - .map(|&s| s.to_string()) - .collect(); - - match search_paths( - Path::new(&root_path), - include_patterns, - exclude_patterns, - true, - FileType::Dir, - ) { - Ok(dirs) => { - for dir in dirs { - let folder_path = Path::new(&root_path).join(&dir); - if let Ok(uri) = Url::from_file_path(&folder_path) { - workspace_folders.push(WorkspaceFolder { - uri, - name: folder_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_string(), - }); - } - } - } - Err(e) => return Err(Box::new(e)), - } - - if workspace_folders.is_empty() { - // Fallback: use the root_path itself as a workspace folder - warn!("No workspace folders found. Using root path as workspace."); - if let Ok(uri) = Url::from_file_path(&root_path) { - workspace_folders.push(WorkspaceFolder { - uri, - name: root_path.to_string(), - }); - } - } - - Ok(workspace_folders.into_iter().collect()) + pub fn get_workspace_documents(&self) -> &WorkspaceDocumentsHandler { + &self.workspace_documents } } diff --git a/crates/wrapper/src/lsp/json_rpc.rs b/crates/wrapper/src/lsp/json_rpc.rs index 348a8c86..d477d595 100644 --- a/crates/wrapper/src/lsp/json_rpc.rs +++ b/crates/wrapper/src/lsp/json_rpc.rs @@ -1,8 +1,7 @@ -use serde::{Deserialize, Serialize}; +use common::api_types::{JsonRpcError, JsonRpcErrorCode, JsonRpcMessage}; use serde_json::Value; use std::collections::HashMap; use std::error::Error; -use std::fmt; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use tokio::sync::broadcast::{channel, Receiver, Sender}; @@ -11,35 +10,10 @@ use tokio::sync::Mutex; pub trait JsonRpc: Send + Sync { fn create_success_response(&self, id: u64) -> String; fn create_request(&self, method: &str, params: Option) -> (u64, String); - fn create_notification(&self, method: &str, params: Value) -> String; + fn create_notification(&self, method: &str, params: Option) -> String; fn parse_message(&self, data: &str) -> Result; } -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct JsonRpcMessage { - pub jsonrpc: String, - pub id: Option, - pub method: Option, - pub params: Option, - pub result: Option, - pub error: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct JsonRpcError { - pub code: i32, - pub message: String, - pub data: Option, -} - -impl fmt::Display for JsonRpcError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Error {}: {}", self.code, self.message) - } -} - -impl std::error::Error for JsonRpcError {} - #[derive(Clone)] pub struct JsonRpcHandler { id_counter: Arc, @@ -75,18 +49,18 @@ impl JsonRpc for JsonRpcHandler { (id, request) } - fn create_notification(&self, method: &str, params: Value) -> String { + fn create_notification(&self, method: &str, params: Option) -> String { serde_json::json!({ "jsonrpc": "2.0", "method": method, - "params": params + "params": params.unwrap_or(serde_json::Value::Null) }) .to_string() } fn parse_message(&self, data: &str) -> Result { serde_json::from_str(data).map_err(|e| JsonRpcError { - code: -32700, + code: JsonRpcErrorCode::ParseError, message: e.to_string(), data: None, }) diff --git a/crates/wrapper/src/lsp/languages/generic.rs b/crates/wrapper/src/lsp/languages/generic.rs index 88bdd1bc..4bfe5b97 100644 --- a/crates/wrapper/src/lsp/languages/generic.rs +++ b/crates/wrapper/src/lsp/languages/generic.rs @@ -1,133 +1,136 @@ -use std::path::Path; - -use crate::lsp::{JsonRpcHandler, LspClient, PendingRequests, ProcessHandler}; +use crate::lsp::client::{LspConfig, PostInitializeMessage, CLIENT_CAPABILITES}; use async_trait::async_trait; -use common::utils::workspace_documents::{ - DidOpenConfiguration, WorkspaceDocumentsHandler, DEFAULT_EXCLUDE_PATTERNS, -}; -use lsp_types::InitializeParams; +use common::utils::file_utils::{search_paths, FileType}; +use common::utils::workspace_documents::{DidOpenConfiguration, DEFAULT_EXCLUDE_PATTERNS}; +use log::warn; +use lsp_types::{InitializeParams, WorkspaceFolder}; use std::error::Error; +use std::path::Path; +use url::Url; -pub struct GenericLspClient { - process: ProcessHandler, - json_rpc: JsonRpcHandler, - workspace_documents: WorkspaceDocumentsHandler, - pending_requests: PendingRequests, +#[derive(Clone)] +pub struct GenericConfig { initialization_options: Option, - setup_workspace_method: Option, + post_initialize_messages: Vec, + file_patterns: Vec, + exclude_patterns: Vec, + did_open_configuration: DidOpenConfiguration, } #[async_trait] -impl LspClient for GenericLspClient { - fn get_process(&mut self) -> &mut ProcessHandler { - &mut self.process - } - - fn get_json_rpc(&mut self) -> &mut JsonRpcHandler { - &mut self.json_rpc - } - - fn get_root_files(&mut self) -> Vec { - vec![] // Generic client doesn't specify root files - } - - fn get_workspace_documents(&mut self) -> &mut WorkspaceDocumentsHandler { - &mut self.workspace_documents - } - - fn get_pending_requests(&mut self) -> &mut PendingRequests { - &mut self.pending_requests - } - - #[allow(deprecated)] - +impl LspConfig for GenericConfig { async fn get_initialize_params( &mut self, root_path: String, ) -> Result> { let workspace_folders = self.find_workspace_folders(root_path.clone()).await?; Ok(InitializeParams { - capabilities: self.get_capabilities(), + capabilities: CLIENT_CAPABILITES.clone(), workspace_folders: Some(workspace_folders), - root_uri: Some(lsp_types::Url::from_file_path(&root_path).unwrap()), + #[allow(deprecated)] + root_uri: Some(Url::from_file_path(&root_path).unwrap()), initialization_options: self.initialization_options.clone(), ..Default::default() }) } - async fn setup_workspace( - &mut self, - _root_path: &str, - ) -> Result<(), Box> { - if let Some(method) = self.setup_workspace_method.clone() { - log::info!("Calling setup workspace method: {}", method); - self.send_request(&method, None).await?; - } - Ok(()) + fn get_post_initialize_messages(&self) -> Vec { + self.post_initialize_messages.clone() + } + + fn get_root_files(&mut self) -> Vec { + vec![] + } + + fn include_patterns(&self) -> Vec { + self.file_patterns.clone() + } + + fn exclude_patterns(&self) -> Vec { + self.exclude_patterns.clone() + } + + fn did_open_configuration(&self) -> DidOpenConfiguration { + self.did_open_configuration.clone() } } -impl GenericLspClient { - /// Create a new GenericLspClient with configurable file patterns and did-open behavior +impl GenericConfig { pub fn new( - process: ProcessHandler, - root_path: String, file_patterns: Vec, - did_open_config: DidOpenConfiguration, + exclude_patterns: Vec, + did_open_configuration: DidOpenConfiguration, ) -> Self { - let (_tx, rx) = tokio::sync::broadcast::channel(1); - - let workspace_documents = WorkspaceDocumentsHandler::new( - Path::new(&root_path), + Self { + initialization_options: None, + post_initialize_messages: vec![], file_patterns, - DEFAULT_EXCLUDE_PATTERNS - .iter() - .map(|&s| s.to_string()) - .collect(), - rx, - did_open_config, - ); + exclude_patterns, + did_open_configuration, + } + } - let json_rpc_handler = JsonRpcHandler::new(); + async fn find_workspace_folders( + &mut self, + root_path: String, + ) -> Result, Box> { + let mut workspace_folders: Vec = Vec::new(); + let include_patterns = self + .get_root_files() + .into_iter() + .map(|f| format!("**/{f}")) + .collect(); + let exclude_patterns = DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(); + + match search_paths( + Path::new(&root_path), + include_patterns, + exclude_patterns, + true, + FileType::Dir, + ) { + Ok(dirs) => { + for dir in dirs { + let folder_path = Path::new(&root_path).join(&dir); + if let Ok(uri) = Url::from_file_path(&folder_path) { + workspace_folders.push(WorkspaceFolder { + uri, + name: folder_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(), + }); + } + } + } + Err(e) => return Err(Box::new(e)), + } - Self { - process, - json_rpc: json_rpc_handler, - workspace_documents, - pending_requests: PendingRequests::new(), - initialization_options: None, - setup_workspace_method: None, + if workspace_folders.is_empty() { + warn!("No workspace folders found. Using root path as workspace."); + if let Ok(uri) = Url::from_file_path(&root_path) { + workspace_folders.push(WorkspaceFolder { + uri, + name: root_path.to_string(), + }); + } } + + Ok(workspace_folders.into_iter().collect()) } - /// Set initialization options for the LSP server (e.g., Rust cargo.sysroot) pub fn with_initialization_options(mut self, options: serde_json::Value) -> Self { self.initialization_options = Some(options); self } - /// Set setup workspace method to call after initialization (e.g., rust-analyzer/reloadWorkspace) - pub fn with_setup_workspace_method(mut self, method: String) -> Self { - self.setup_workspace_method = Some(method); + pub fn with_post_initialize_messages(mut self, messages: Vec) -> Self { + self.post_initialize_messages = messages; self } - - /// Extract all components at once (consumes self) - /// Returns (ProcessHandler, JsonRpcHandler, WorkspaceDocumentsHandler, PendingRequests) - pub fn into_components( - self, - ) -> ( - ProcessHandler, - JsonRpcHandler, - WorkspaceDocumentsHandler, - PendingRequests, - ) { - ( - self.process, - self.json_rpc, - self.workspace_documents, - self.pending_requests, - ) - } } diff --git a/crates/wrapper/src/lsp/languages/golang.rs b/crates/wrapper/src/lsp/languages/golang.rs index 84ed2458..47051086 100644 --- a/crates/wrapper/src/lsp/languages/golang.rs +++ b/crates/wrapper/src/lsp/languages/golang.rs @@ -1,45 +1,17 @@ -use std::path::{Path, PathBuf}; - -use crate::lsp::{JsonRpcHandler, LspClient, PendingRequests, ProcessHandler}; +use crate::lsp::client::{LspConfig, CLIENT_CAPABILITES}; use async_trait::async_trait; +use common::utils::workspace_documents::{DidOpenConfiguration, GOLANG_FILE_PATTERNS}; use log::{info, warn}; use lsp_types::{InitializeParams, Url, WorkspaceFolder}; use std::error::Error; +use std::path::{Path, PathBuf}; -pub struct GoplsClient { - process: ProcessHandler, - json_rpc: JsonRpcHandler, - workspace_documents: common::utils::workspace_documents::WorkspaceDocumentsHandler, - pending_requests: PendingRequests, -} +pub struct GoplsConfig; #[async_trait] -impl LspClient for GoplsClient { - fn get_process(&mut self) -> &mut ProcessHandler { - &mut self.process - } - - fn get_json_rpc(&mut self) -> &mut JsonRpcHandler { - &mut self.json_rpc - } - - fn get_root_files(&mut self) -> Vec { - vec![] // Gopls doesn't use root files in the new architecture - } - - fn get_workspace_documents( - &mut self, - ) -> &mut common::utils::workspace_documents::WorkspaceDocumentsHandler { - &mut self.workspace_documents - } - - fn get_pending_requests(&mut self) -> &mut PendingRequests { - &mut self.pending_requests - } - +impl LspConfig for GoplsConfig { #[allow(deprecated)] - async fn get_initialize_params( &mut self, root_path: String, @@ -47,14 +19,41 @@ impl LspClient for GoplsClient { let workspace_folders = self.find_workspace_folders(root_path.clone()).await?; Ok(InitializeParams { - capabilities: self.get_capabilities(), - // Prefer workspaceFolders; do not also set root_uri to avoid confusion + capabilities: CLIENT_CAPABILITES.clone(), workspace_folders: Some(workspace_folders), root_uri: None, ..Default::default() }) } + fn get_root_files(&mut self) -> Vec { + vec![] + } + + fn include_patterns(&self) -> Vec { + GOLANG_FILE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect() + } + + fn exclude_patterns(&self) -> Vec { + common::utils::workspace_documents::DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect() + } + + fn did_open_configuration(&self) -> DidOpenConfiguration { + DidOpenConfiguration::None + } +} + +impl GoplsConfig { + pub fn new() -> Self { + Self + } + async fn find_workspace_folders( &mut self, root_path: String, @@ -115,29 +114,15 @@ impl LspClient for GoplsClient { } } -impl GoplsClient { - /// Create a new GoplsClient from the existing GenericLspClient components - pub fn new( - process: ProcessHandler, - json_rpc: JsonRpcHandler, - workspace_documents: common::utils::workspace_documents::WorkspaceDocumentsHandler, - pending_requests: PendingRequests, - ) -> Self { - Self { - process, - json_rpc, - workspace_documents, - pending_requests, - } +impl Default for GoplsConfig { + fn default() -> Self { + Self::new() } } -/// Walk upward from `start` to filesystem root, returning the first directory -/// that contains a child named `needle` (e.g., "go.work" or "go.mod"). fn nearest_ancestor_with(start: &Path, needle: &str) -> Option { let mut cur = start; - // If `start` is a file path, prefer its parent. if cur.is_file() { cur = cur.parent()?; } @@ -149,7 +134,6 @@ fn nearest_ancestor_with(start: &Path, needle: &str) -> Option { return Some(dir); } - // Stop at filesystem root if let Some(parent) = dir.parent() { dir = parent.to_path_buf(); } else { diff --git a/crates/wrapper/src/lsp/languages/mod.rs b/crates/wrapper/src/lsp/languages/mod.rs index def9cfd1..ee4bf80c 100644 --- a/crates/wrapper/src/lsp/languages/mod.rs +++ b/crates/wrapper/src/lsp/languages/mod.rs @@ -1,7 +1,136 @@ pub mod generic; pub mod golang; +#[allow(unused)] +pub mod python; pub mod sorbet; -pub use generic::GenericLspClient; -pub use golang::GoplsClient; -pub use sorbet::SorbetClient; +use crate::lsp::client::PostInitializeMessage; +use common::utils::workspace_documents::{ + DidOpenConfiguration, CSHARP_FILE_PATTERNS, C_AND_CPP_FILE_PATTERNS, DEFAULT_EXCLUDE_PATTERNS, + JAVA_FILE_PATTERNS, PHP_FILE_PATTERNS, PYTHON_FILE_PATTERNS, RUBY_FILE_PATTERNS, + RUST_FILE_PATTERNS, TYPESCRIPT_AND_JAVASCRIPT_FILE_PATTERNS, +}; +use std::sync::LazyLock; + +pub use generic::GenericConfig; +pub use golang::GoplsConfig; +pub use sorbet::SorbetConfig; + +pub static PHP_CONFIG: LazyLock = LazyLock::new(|| { + GenericConfig::new( + PHP_FILE_PATTERNS.iter().map(|&s| s.to_string()).collect(), + DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DidOpenConfiguration::Lazy, + ) +}); + +pub static PYTHON_CONFIG: LazyLock = LazyLock::new(|| { + GenericConfig::new( + PYTHON_FILE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DidOpenConfiguration::None, + ) +}); + +pub static RUBY_CONFIG: LazyLock = LazyLock::new(|| { + GenericConfig::new( + RUBY_FILE_PATTERNS.iter().map(|&s| s.to_string()).collect(), + DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DidOpenConfiguration::None, + ) +}); + +pub static TYPESCRIPT_AND_JAVASCRIPT_CONFIG: LazyLock = LazyLock::new(|| { + GenericConfig::new( + TYPESCRIPT_AND_JAVASCRIPT_FILE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DidOpenConfiguration::Lazy, + ) +}); + +pub static RUST_CONFIG: LazyLock = LazyLock::new(|| { + GenericConfig::new( + RUST_FILE_PATTERNS.iter().map(|&s| s.to_string()).collect(), + DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DidOpenConfiguration::None, + ) + .with_initialization_options(serde_json::json!({ + "cargo": { + "sysroot": serde_json::Value::Null + } + })) + .with_post_initialize_messages(vec![PostInitializeMessage::Request { + method: "rust-analyzer/reloadWorkspace".to_string(), + params: None, + }]) +}); + +pub static JAVA_CONFIG: LazyLock = LazyLock::new(|| { + GenericConfig::new( + JAVA_FILE_PATTERNS.iter().map(|&s| s.to_string()).collect(), + DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DidOpenConfiguration::None, + ) + .with_post_initialize_messages(vec![PostInitializeMessage::ExpectNotification { + method: "language/status".to_string(), + params: serde_json::json!({ + "type": "ServiceReady", + "message": "ServiceReady" + }), + }]) +}); + +pub static C_AND_CPP_CONFIG: LazyLock = LazyLock::new(|| { + GenericConfig::new( + C_AND_CPP_FILE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DidOpenConfiguration::Lazy, + ) + .with_initialization_options(serde_json::json!({ + "clangdFileStatus": true + })) +}); + +pub static CSHARP_CONFIG: LazyLock = LazyLock::new(|| { + GenericConfig::new( + CSHARP_FILE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DidOpenConfiguration::None, + ) +}); diff --git a/crates/wrapper/src/lsp/languages/python.rs b/crates/wrapper/src/lsp/languages/python.rs index b0891680..7d6257b5 100644 --- a/crates/wrapper/src/lsp/languages/python.rs +++ b/crates/wrapper/src/lsp/languages/python.rs @@ -1,116 +1,117 @@ -use std::{path::Path, process::Stdio}; +use crate::lsp::client::{LspConfig, CLIENT_CAPABILITES}; use async_trait::async_trait; -use notify_debouncer_mini::DebouncedEvent; -use tokio::process::Command; -use tokio::sync::broadcast::Receiver; - -use crate::lsp::{JsonRpcHandler, LspClient, PendingRequests, ProcessHandler}; - +use common::utils::file_utils::{search_paths, FileType}; use common::utils::workspace_documents::{ - DidOpenConfiguration, WorkspaceDocumentsHandler, DEFAULT_EXCLUDE_PATTERNS, - PYTHON_FILE_PATTERNS, PYTHON_ROOT_FILES, + DEFAULT_EXCLUDE_PATTERNS, PYTHON_FILE_PATTERNS, PYTHON_ROOT_FILES, }; +use log::warn; +use lsp_types::{InitializeParams, Url, WorkspaceFolder}; +use std::error::Error; +use std::path::Path; -pub struct JediClient { - process: ProcessHandler, - json_rpc: JsonRpcHandler, - workspace_documents: WorkspaceDocumentsHandler, - pending_requests: PendingRequests, -} +pub struct JediConfig; #[async_trait] -impl LspClient for JediClient { - fn get_process(&mut self) -> &mut ProcessHandler { - &mut self.process - } - - fn get_json_rpc(&mut self) -> &mut JsonRpcHandler { - &mut self.json_rpc +impl LspConfig for JediConfig { + #[allow(deprecated)] + async fn get_initialize_params( + &mut self, + root_path: String, + ) -> Result> { + let workspace_folders = self.find_workspace_folders(root_path.clone()).await?; + Ok(InitializeParams { + capabilities: CLIENT_CAPABILITES.clone(), + workspace_folders: Some(workspace_folders), + root_uri: Some(Url::from_file_path(&root_path).unwrap()), + ..Default::default() + }) } fn get_root_files(&mut self) -> Vec { PYTHON_ROOT_FILES.iter().map(|&s| s.to_string()).collect() } - fn get_workspace_documents(&mut self) -> &mut WorkspaceDocumentsHandler { - &mut self.workspace_documents + fn include_patterns(&self) -> Vec { + PYTHON_FILE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect() + } + + fn exclude_patterns(&self) -> Vec { + common::utils::workspace_documents::DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect() } - fn get_pending_requests(&mut self) -> &mut PendingRequests { - &mut self.pending_requests + fn did_open_configuration(&self) -> common::utils::workspace_documents::DidOpenConfiguration { + common::utils::workspace_documents::DidOpenConfiguration::None } } -impl JediClient { - /// Create a new JediClient from an existing ProcessHandler - /// (for lsp-wrapper where we've already spawned the process) - pub fn new(process: ProcessHandler, root_path: String) -> Self { - let (tx, rx) = tokio::sync::broadcast::channel(1); +impl JediConfig { + pub fn new() -> Self { + Self + } - let workspace_documents = WorkspaceDocumentsHandler::new( + async fn find_workspace_folders( + &mut self, + root_path: String, + ) -> Result, Box> { + let mut workspace_folders: Vec = Vec::new(); + let include_patterns = self + .get_root_files() + .into_iter() + .map(|f| format!("**/{f}")) + .collect(); + let exclude_patterns = DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(); + + match search_paths( Path::new(&root_path), - PYTHON_FILE_PATTERNS - .iter() - .map(|&s| s.to_string()) - .collect(), - DEFAULT_EXCLUDE_PATTERNS - .iter() - .map(|&s| s.to_string()) - .collect(), - rx, - DidOpenConfiguration::None, - ); - - let json_rpc_handler = JsonRpcHandler::new(); - - Self { - process, - json_rpc: json_rpc_handler, - workspace_documents, - pending_requests: PendingRequests::new(), + include_patterns, + exclude_patterns, + true, + FileType::Dir, + ) { + Ok(dirs) => { + for dir in dirs { + let folder_path = Path::new(&root_path).join(&dir); + if let Ok(uri) = Url::from_file_path(&folder_path) { + workspace_folders.push(WorkspaceFolder { + uri, + name: folder_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(), + }); + } + } + } + Err(e) => return Err(Box::new(e)), } - } - - /// Create a new JediClient by spawning a new jedi-language-server process - /// (for base implementation with watch events) - pub async fn from_spawn( - root_path: &str, - watch_events_rx: Receiver, - ) -> Result> { - let process = Command::new("jedi-language-server") - .current_dir(root_path) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| Box::new(e) as Box)?; - - let process_handler = ProcessHandler::new(process) - .await - .map_err(|e| format!("Failed to create ProcessHandler: {}", e))?; - let workspace_documents = WorkspaceDocumentsHandler::new( - Path::new(root_path), - PYTHON_FILE_PATTERNS - .iter() - .map(|&s| s.to_string()) - .collect(), - DEFAULT_EXCLUDE_PATTERNS - .iter() - .map(|&s| s.to_string()) - .collect(), - watch_events_rx, - DidOpenConfiguration::None, - ); + if workspace_folders.is_empty() { + warn!("No workspace folders found. Using root path as workspace."); + if let Ok(uri) = Url::from_file_path(&root_path) { + workspace_folders.push(WorkspaceFolder { + uri, + name: root_path.to_string(), + }); + } + } - let json_rpc_handler = JsonRpcHandler::new(); + Ok(workspace_folders.into_iter().collect()) + } +} - Ok(Self { - process: process_handler, - json_rpc: json_rpc_handler, - workspace_documents, - pending_requests: PendingRequests::new(), - }) +impl Default for JediConfig { + fn default() -> Self { + Self::new() } } diff --git a/crates/wrapper/src/lsp/languages/sorbet.rs b/crates/wrapper/src/lsp/languages/sorbet.rs index 1bcf6267..ca2e08a0 100644 --- a/crates/wrapper/src/lsp/languages/sorbet.rs +++ b/crates/wrapper/src/lsp/languages/sorbet.rs @@ -1,61 +1,33 @@ -use std::fs; -use std::path::PathBuf; - -use crate::lsp::{JsonRpcHandler, LspClient, PendingRequests, ProcessHandler}; +use crate::lsp::client::{LspConfig, CLIENT_CAPABILITES}; use async_trait::async_trait; +use common::utils::workspace_documents::{DidOpenConfiguration, RUBY_FILE_PATTERNS}; use log::{info, warn}; use lsp_types::{InitializeParams, Url, WorkspaceFolder}; use std::error::Error; +use std::fs; +use std::path::PathBuf; use tokio::process::Command; const DEFAULT_RBENV_ROOT: &str = "/opt/rbenv"; -pub struct SorbetClient { - process: ProcessHandler, - json_rpc: JsonRpcHandler, - workspace_documents: common::utils::workspace_documents::WorkspaceDocumentsHandler, - pending_requests: PendingRequests, -} +pub struct SorbetConfig; #[async_trait] -impl LspClient for SorbetClient { - fn get_process(&mut self) -> &mut ProcessHandler { - &mut self.process - } - - fn get_json_rpc(&mut self) -> &mut JsonRpcHandler { - &mut self.json_rpc - } - - fn get_root_files(&mut self) -> Vec { - vec![] // Sorbet doesn't use root files in the new architecture - } - - fn get_workspace_documents( - &mut self, - ) -> &mut common::utils::workspace_documents::WorkspaceDocumentsHandler { - &mut self.workspace_documents - } - - fn get_pending_requests(&mut self) -> &mut PendingRequests { - &mut self.pending_requests - } - - #[expect(deprecated)] +impl LspConfig for SorbetConfig { + #[allow(deprecated)] async fn get_initialize_params( &mut self, root_path: String, ) -> Result> { let workspace_folders = self.find_workspace_folders(root_path.clone()).await?; - // Sorbet initialization options for lazy indexing let init_options = serde_json::json!({ "sorbet.lsp.lazyIndexing": true }); Ok(InitializeParams { - capabilities: self.get_capabilities(), + capabilities: CLIENT_CAPABILITES.clone(), workspace_folders: Some(workspace_folders), root_uri: None, initialization_options: Some(init_options), @@ -63,17 +35,41 @@ impl LspClient for SorbetClient { }) } + fn get_root_files(&mut self) -> Vec { + vec![] + } + + fn include_patterns(&self) -> Vec { + RUBY_FILE_PATTERNS.iter().map(|&s| s.to_string()).collect() + } + + fn exclude_patterns(&self) -> Vec { + common::utils::workspace_documents::DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect() + } + + fn did_open_configuration(&self) -> DidOpenConfiguration { + DidOpenConfiguration::Lazy + } +} + +impl SorbetConfig { + pub fn new() -> Self { + Self + } + async fn find_workspace_folders( &mut self, root_path: String, ) -> Result, Box> { info!( - "SorbetClient::find_workspace_folders called with root_path: {}", + "SorbetConfig::find_workspace_folders called with root_path: {}", root_path ); let root = PathBuf::from(&root_path); - // 1) Look for sorbet/config file let sorbet_config_path = root.join("sorbet").join("config"); info!( "Looking for sorbet/config at {:?}, exists: {}", @@ -83,7 +79,6 @@ impl LspClient for SorbetClient { if sorbet_config_path.exists() { info!("Found sorbet/config at {:?}", sorbet_config_path); - // Parse the config file to find --dir entries match fs::read_to_string(&sorbet_config_path) { Ok(contents) => { let mut workspace_folders = Vec::new(); @@ -92,9 +87,7 @@ impl LspClient for SorbetClient { while let Some(line) = lines.next() { let trimmed = line.trim(); - // Look for --dir option if trimmed == "--dir" { - // The directory path should be on the next line if let Some(dir_line) = lines.next() { let dir_path = dir_line.trim(); let full_path = root.join(dir_path); @@ -139,7 +132,6 @@ impl LspClient for SorbetClient { } } - // 2) Fallback: use the provided root_path warn!( "No sorbet/config found or no valid directories specified. Falling back to root: {}", root.display() @@ -157,26 +149,13 @@ impl LspClient for SorbetClient { } } -impl SorbetClient { - /// Create a new SorbetClient from the existing GenericLspClient components - pub fn new( - process: ProcessHandler, - json_rpc: JsonRpcHandler, - workspace_documents: common::utils::workspace_documents::WorkspaceDocumentsHandler, - pending_requests: PendingRequests, - ) -> Self { - Self { - process, - json_rpc, - workspace_documents, - pending_requests, - } +impl Default for SorbetConfig { + fn default() -> Self { + Self::new() } } -/// Parse Sorbet version from Gemfile.lock (preferred) or Gemfile fn parse_sorbet_version(workspace_path: &str) -> Option { - // Prefer Gemfile.lock let lock_path = PathBuf::from(workspace_path).join("Gemfile.lock"); if let Ok(contents) = fs::read_to_string(&lock_path) { if let Some(ver) = parse_sorbet_version_from_lock(&contents) { @@ -184,7 +163,6 @@ fn parse_sorbet_version(workspace_path: &str) -> Option { } } - // Fallback to Gemfile let gemfile_path = PathBuf::from(workspace_path).join("Gemfile"); if let Ok(contents) = fs::read_to_string(&gemfile_path) { if let Some(ver) = parse_sorbet_version_from_gemfile(&contents) { @@ -196,7 +174,6 @@ fn parse_sorbet_version(workspace_path: &str) -> Option { } fn parse_sorbet_version_from_lock(lock_contents: &str) -> Option { - // Look for lines like " sorbet (0.5.12414)" for line in lock_contents.lines() { let trimmed = line.trim(); if let Some(rest) = trimmed.strip_prefix("sorbet (") { @@ -212,14 +189,11 @@ fn parse_sorbet_version_from_lock(lock_contents: &str) -> Option { } fn parse_sorbet_version_from_gemfile(gemfile_contents: &str) -> Option { - // Look for lines like: gem "sorbet", "0.5.12414" for line in gemfile_contents.lines() { let trimmed = line.trim(); if trimmed.starts_with("gem") && trimmed.contains("sorbet") { - // Support double or single quotes let quote = if trimmed.contains('"') { '"' } else { '\'' }; let parts: Vec<&str> = trimmed.split(quote).collect(); - // parts at odd indices are quoted values if parts.len() >= 4 && parts[1].contains("sorbet") { let ver = parts[3].trim(); if !ver.is_empty() { @@ -292,7 +266,6 @@ async fn install_sorbet(version: &str) -> Result<(), String> { } } - // Refresh shims let rehash_status = command_with_rbenv_env(&rbenv_bin) .arg("rehash") .status() @@ -306,8 +279,6 @@ async fn install_sorbet(version: &str) -> Result<(), String> { Ok(()) } -/// Ensure the Sorbet gem version requested by the project is available and return it. -/// Returns Ok(Some(version)) when detected, Ok(None) when unspecified. pub async fn ensure_sorbet_version(workspace_path: &str) -> Result, String> { let Some(desired_version) = parse_sorbet_version(workspace_path) else { info!("No Sorbet version specified in Gemfile.lock or Gemfile; using preinstalled version"); diff --git a/crates/wrapper/src/lsp/mod.rs b/crates/wrapper/src/lsp/mod.rs index 2d1e7962..3f3dc7b7 100644 --- a/crates/wrapper/src/lsp/mod.rs +++ b/crates/wrapper/src/lsp/mod.rs @@ -1,9 +1,9 @@ +/// Local LSP client. This module is responsible for communication with the local LSP server. pub mod client; pub mod json_rpc; pub mod languages; pub mod process; // Re-export commonly used types -pub use client::LspClient; pub use json_rpc::{ExpectedMessageKey, JsonRpcHandler, PendingRequests}; pub use process::ProcessHandler; diff --git a/crates/wrapper/src/lsp/process.rs b/crates/wrapper/src/lsp/process.rs index f82f5221..44877107 100644 --- a/crates/wrapper/src/lsp/process.rs +++ b/crates/wrapper/src/lsp/process.rs @@ -30,9 +30,11 @@ impl ProcessHandler { #[async_trait::async_trait] impl Process for ProcessHandler { - async fn send(&mut self, data: &str) -> Result<(), Box> { + async fn send(&mut self, content: &str) -> Result<(), Box> { + let message = format!("Content-Length: {}\r\n\r\n{}", content.len(), content); let mut stdin = self.stdin.lock().await; - stdin.write_all(data.as_bytes()).await?; + debug!("Sending content: {}", content); + stdin.write_all(message.as_bytes()).await?; stdin.flush().await?; Ok(()) } diff --git a/crates/wrapper/src/main.rs b/crates/wrapper/src/main.rs index 80bc6ca2..3e8305f5 100644 --- a/crates/wrapper/src/main.rs +++ b/crates/wrapper/src/main.rs @@ -7,17 +7,15 @@ const DEFAULT_RBENV_ROOT: &str = "/opt/rbenv"; mod handlers; mod lsp; -mod manager; +mod managers; -use common::utils::workspace_documents::{ - DidOpenConfiguration, CSHARP_FILE_PATTERNS, C_AND_CPP_FILE_PATTERNS, GOLANG_FILE_PATTERNS, - JAVA_FILE_PATTERNS, PHP_FILE_PATTERNS, PYTHON_FILE_PATTERNS, RUBY_FILE_PATTERNS, - RUST_FILE_PATTERNS, TYPESCRIPT_AND_JAVASCRIPT_FILE_PATTERNS, +use crate::lsp::client::{LspClient, LspConfig}; +use crate::lsp::languages::{ + GoplsConfig, SorbetConfig, CSHARP_CONFIG, C_AND_CPP_CONFIG, JAVA_CONFIG, PHP_CONFIG, + PYTHON_CONFIG, RUBY_CONFIG, RUST_CONFIG, TYPESCRIPT_AND_JAVASCRIPT_CONFIG, }; -use lsp::client::LspClient; -use lsp::languages::{GenericLspClient, GoplsClient, SorbetClient}; -use lsp::process::ProcessHandler; -use manager::Manager; +use crate::lsp::process::ProcessHandler; +use crate::managers::api::ApiManager; /// HTTP wrapper for LSP servers /// Provides HTTP endpoints for LSP JSON-RPC communication @@ -44,10 +42,12 @@ struct Args { /// Application state shared across handlers pub struct AppState { - pub manager: Manager, + pub api_manager: ApiManager, } -/// Health check endpoint - simple version that just returns OK +/// Health check endpoint +/// +/// The HTTP server only starts after the LSP server has initialized, so it simply returns OK. async fn health() -> impl Responder { HttpResponse::Ok().body("ok") } @@ -159,89 +159,52 @@ async fn main() -> std::io::Result<()> { std::io::Error::new(std::io::ErrorKind::Other, e) })?; - // Configure based on language - let (file_patterns, did_open_config) = match language.as_str() { - "php" => (PHP_FILE_PATTERNS.to_vec(), DidOpenConfiguration::Lazy), - "python" => (PYTHON_FILE_PATTERNS.to_vec(), DidOpenConfiguration::None), - "ruby" => (RUBY_FILE_PATTERNS.to_vec(), DidOpenConfiguration::None), - "ruby-sorbet" => { - info!("Detected ruby-sorbet language - using Lazy didOpen configuration"); - (RUBY_FILE_PATTERNS.to_vec(), DidOpenConfiguration::Lazy) - } - "typescript" | "javascript" => ( - TYPESCRIPT_AND_JAVASCRIPT_FILE_PATTERNS.to_vec(), - DidOpenConfiguration::Lazy, - ), - "rust" => (RUST_FILE_PATTERNS.to_vec(), DidOpenConfiguration::None), - "go" => (GOLANG_FILE_PATTERNS.to_vec(), DidOpenConfiguration::None), - "java" => (JAVA_FILE_PATTERNS.to_vec(), DidOpenConfiguration::None), - "cpp" | "c" => (C_AND_CPP_FILE_PATTERNS.to_vec(), DidOpenConfiguration::Lazy), - "csharp" => (CSHARP_FILE_PATTERNS.to_vec(), DidOpenConfiguration::None), - _ => { - error!("Unknown language '{}'. Supported languages: php, python, ruby, ruby-sorbet, typescript, javascript, rust, go, java, cpp, c, csharp", language); - return Err(std::io::Error::new( - std::io::ErrorKind::Other, - format!("Unsupported language: {}", language), - )); - } - }; - - // Create base client - let base_client = GenericLspClient::new( - process_handler, - args.workspace_path.clone(), - file_patterns.iter().map(|&s| s.to_string()).collect(), - did_open_config, - ); - - // Apply language-specific initialization and setup + // Apply language-specific configuration info!( "Checking LSP command for language-specific configuration: '{}'", args.lsp_command ); - let mut client: Box = match args.lsp_command.as_str() { + + let config: Box = match args.lsp_command.as_str() { "srb" => { info!("Configuring Sorbet with custom workspace folder detection (sorbet/config)"); - // Convert GenericLspClient components to SorbetClient - let (process, json_rpc, workspace_documents, pending_requests) = - base_client.into_components(); - let sorbet_client = - SorbetClient::new(process, json_rpc, workspace_documents, pending_requests); - Box::new(sorbet_client) + Box::new(SorbetConfig::new()) } _ => match language.as_str() { - "go" => { - info!("Configuring Go with custom workspace folder detection (go.work/go.mod)"); - // Convert GenericLspClient components to GoplsClient - let (process, json_rpc, workspace_documents, pending_requests) = - base_client.into_components(); - let gopls_client = - GoplsClient::new(process, json_rpc, workspace_documents, pending_requests); - Box::new(gopls_client) + "php" => Box::new(PHP_CONFIG.clone()), + "python" => Box::new(PYTHON_CONFIG.clone()), + "ruby" => Box::new(RUBY_CONFIG.clone()), + "ruby-sorbet" => { + info!("Detected ruby-sorbet language - using Sorbet config"); + Box::new(SorbetConfig::new()) } + "typescript" | "javascript" => Box::new(TYPESCRIPT_AND_JAVASCRIPT_CONFIG.clone()), "rust" => { info!("Configuring Rust with initialization options and setup workspace"); - let configured_client = base_client - .with_initialization_options(serde_json::json!({ - "cargo": { - "sysroot": serde_json::Value::Null - } - })) - .with_setup_workspace_method("rust-analyzer/reloadWorkspace".to_string()); - Box::new(configured_client) + Box::new(RUST_CONFIG.clone()) + } + "go" => { + info!("Configuring Go with custom workspace folder detection (go.work/go.mod)"); + Box::new(GoplsConfig::new()) } + "java" => Box::new(JAVA_CONFIG.clone()), "cpp" | "c" => { info!("Configuring C/C++ with clangd initialization options"); - let configured_client = - base_client.with_initialization_options(serde_json::json!({ - "clangdFileStatus": true - })); - Box::new(configured_client) + Box::new(C_AND_CPP_CONFIG.clone()) + } + "csharp" => Box::new(CSHARP_CONFIG.clone()), + _ => { + error!("Unknown language '{}'. Supported languages: php, python, ruby, ruby-sorbet, typescript, javascript, rust, go, java, cpp, c, csharp", language); + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Unsupported language: {}", language), + )); } - _ => Box::new(base_client), }, }; + let mut client = LspClient::new(config, process_handler, &args.workspace_path); + // Initialize the LSP server client .initialize(args.workspace_path.clone()) @@ -251,76 +214,39 @@ async fn main() -> std::io::Result<()> { std::io::Error::new(std::io::ErrorKind::Other, e) })?; - // Java-specific: Wait for ServiceReady notification - if language.as_str() == "java" { - use lsp::ExpectedMessageKey; - info!("Java: waiting for ServiceReady notification (no timeout - caller controls overall timeout)..."); - - let mut notification_rx = client - .get_pending_requests() - .add_notification(ExpectedMessageKey { - method: "language/status".to_string(), - params: serde_json::json!({ - "type": "ServiceReady", - "message": "ServiceReady" - }), - }) - .await - .map_err(|e| { - error!("Failed to add ServiceReady notification listener: {}", e); - std::io::Error::new(std::io::ErrorKind::Other, e) - })?; - - // Wait indefinitely for ServiceReady notification - // The orchestrator health check and CLI timeout control overall timing - notification_rx.recv().await.map_err(|e| { - error!("Error receiving ServiceReady notification: {}", e); - std::io::Error::new(std::io::ErrorKind::Other, e) - })?; - - info!("Java: ServiceReady notification received!"); - } - - // Setup workspace (e.g., rust-analyzer/reloadWorkspace) - client - .setup_workspace(&args.workspace_path) - .await - .map_err(|e| { - error!("Failed to setup workspace: {}", e); - std::io::Error::new(std::io::ErrorKind::Other, e) - })?; - info!("LSP server started and initialized successfully"); - let manager = Manager::new(Arc::new(tokio::sync::Mutex::new(client))); + let lsp_client = Arc::new(tokio::sync::Mutex::new(client)); + let api_manager = ApiManager::new(lsp_client); - let app_state = web::Data::new(AppState { manager }); + let app_state = web::Data::new(AppState { api_manager }); // Start HTTP server HttpServer::new(move || { App::new() .app_data(app_state.clone()) - .route("/health", web::get().to(health)) + .route("/lsp/ws", web::get().to(handlers::lsp_ws::lsp_ws)) .route( - "/symbol/find-identifier", - web::post().to(handlers::find_identifier::find_identifier), + "/symbol/definitions-in-file", + web::get().to(handlers::definitions_in_file::definitions_in_file), ) .route( "/symbol/find-definition", web::post().to(handlers::find_definition::find_definition), ) .route( - "/symbol/find-references", - web::post().to(handlers::find_references::find_references), + "/symbol/find-identifier", + web::post().to(handlers::find_identifier::find_identifier), ) .route( "/symbol/find-referenced-symbols", web::post().to(handlers::find_referenced_symbols::find_referenced_symbols), ) .route( - "/symbol/definitions-in-file", - web::get().to(handlers::definitions_in_file::definitions_in_file), + "/symbol/find-references", + web::post().to(handlers::find_references::find_references), ) + .route("/system/health", web::get().to(health)) }) .bind(("0.0.0.0", args.port))? .run() diff --git a/crates/wrapper/src/manager.rs b/crates/wrapper/src/managers/api.rs similarity index 74% rename from crates/wrapper/src/manager.rs rename to crates/wrapper/src/managers/api.rs index fa9d86a2..5616d6a7 100644 --- a/crates/wrapper/src/manager.rs +++ b/crates/wrapper/src/managers/api.rs @@ -3,22 +3,25 @@ use crate::lsp::client::LspClient; /// /// Unlike the main Nuanced LSP Manager that orchestrates multiple language servers, /// this Manager wraps a single LSP client for the configured language. -use common::api_types::{get_mount_dir, Identifier, Symbol}; +use common::api_types::{get_mount_dir, Identifier, JsonRpcMessage, Symbol}; use common::ast_grep::client::AstGrepClient; use common::ast_grep::types::AstGrepMatch; use common::utils::file_utils::uri_to_relative_path_string; use common::utils::workspace_documents::WorkspaceDocuments; -use log::warn; +use log::{error, warn}; use lsp_types::{GotoDefinitionResponse, Location, Position, Range}; use std::sync::Arc; use thiserror::Error; use tokio::sync::Mutex; #[derive(Error, Debug)] -pub enum LspManagerError { +pub enum ApiManagerError { #[error("File not found: {0}")] FileNotFound(String), + #[error("Bad request: {0}")] + BadRequest(String), + #[error("Internal error: {0}")] InternalError(String), @@ -35,16 +38,17 @@ pub enum LspManagerError { NotImplemented(String), } -pub struct Manager { - // Box for polymorphism - supports any language client +/// Threadsafe manager that maps API calls to invocations to the local LSP server. +pub struct ApiManager { + // LspClient supports any language client // Mutex for interior mutability (LSP client needs &mut self) // Arc for shared ownership across actix-web handlers - client: Arc>>, + client: Arc>, ast_grep: AstGrepClient, } -impl Manager { - pub fn new(client: Arc>>) -> Self { +impl ApiManager { + pub fn new(client: Arc>) -> Self { Self { client, ast_grep: AstGrepClient::new_wrapper(), @@ -54,11 +58,11 @@ impl Manager { pub async fn get_file_identifiers( &self, file_path: &str, - ) -> Result, LspManagerError> { + ) -> Result, ApiManagerError> { let full_path = get_mount_dir().join(file_path); if !full_path.exists() { - return Err(LspManagerError::FileNotFound(file_path.to_string())); + return Err(ApiManagerError::FileNotFound(file_path.to_string())); } let full_path_str = full_path.to_str().unwrap_or_default(); @@ -67,7 +71,7 @@ impl Manager { .get_file_identifiers(full_path_str) .await .map_err(|e| { - LspManagerError::InternalError(format!("Symbol retrieval failed: {}", e)) + ApiManagerError::InternalError(format!("Symbol retrieval failed: {}", e)) })?; Ok(ast_grep_result.into_iter().map(|s| s.into()).collect()) @@ -76,11 +80,11 @@ impl Manager { pub async fn get_definitions_in_file( &self, file_path: &str, - ) -> Result, LspManagerError> { + ) -> Result, ApiManagerError> { let full_path = get_mount_dir().join(file_path); if !full_path.exists() { - return Err(LspManagerError::FileNotFound(file_path.to_string())); + return Err(ApiManagerError::FileNotFound(file_path.to_string())); } let full_path_str = full_path.to_str().unwrap_or_default(); @@ -89,7 +93,7 @@ impl Manager { .get_definitions_in_file(full_path_str) .await .map_err(|e| { - LspManagerError::InternalError(format!("Symbol retrieval failed: {}", e)) + ApiManagerError::InternalError(format!("Symbol retrieval failed: {}", e)) })?; Ok(ast_grep_result) @@ -99,7 +103,7 @@ impl Manager { &self, file_path: &str, identifier_position: &lsp_types::Position, - ) -> Result { + ) -> Result { let full_path = get_mount_dir().join(file_path); let full_path_str = full_path.to_str().unwrap_or_default(); match self @@ -108,7 +112,7 @@ impl Manager { .await { Ok(ast_grep_symbol) => Ok(Symbol::from(ast_grep_symbol)), - Err(e) => Err(LspManagerError::InternalError(e.to_string())), + Err(e) => Err(ApiManagerError::InternalError(e.to_string())), } } @@ -116,11 +120,11 @@ impl Manager { &self, file_path: &str, position: Position, - ) -> Result { + ) -> Result { let full_path = get_mount_dir().join(file_path); if !full_path.exists() { - return Err(LspManagerError::FileNotFound(file_path.to_string())); + return Err(ApiManagerError::FileNotFound(file_path.to_string())); } let full_path_str = full_path.to_str().unwrap_or_default(); @@ -130,7 +134,7 @@ impl Manager { .text_document_definition(full_path_str, position) .await .map_err(|e| { - LspManagerError::InternalError(format!("Definition retrieval failed: {}", e)) + ApiManagerError::InternalError(format!("Definition retrieval failed: {}", e)) })?; // Sort the locations if there are multiple @@ -170,11 +174,11 @@ impl Manager { &self, file_path: &str, position: Position, - ) -> Result, LspManagerError> { + ) -> Result, ApiManagerError> { let full_path = get_mount_dir().join(file_path); if !full_path.exists() { - return Err(LspManagerError::FileNotFound(file_path.to_string())); + return Err(ApiManagerError::FileNotFound(file_path.to_string())); } let full_path_str = full_path.to_str().unwrap_or_default(); @@ -184,7 +188,7 @@ impl Manager { .text_document_reference(full_path_str, position) .await .map_err(|e| { - LspManagerError::InternalError(format!("References retrieval failed: {}", e)) + ApiManagerError::InternalError(format!("References retrieval failed: {}", e)) })?; // Sort locations @@ -205,11 +209,11 @@ impl Manager { file_path: &str, position: Position, full_scan: bool, - ) -> Result, LspManagerError> { + ) -> Result, ApiManagerError> { let full_path = get_mount_dir().join(file_path); if !full_path.exists() { - return Err(LspManagerError::FileNotFound(file_path.to_string())); + return Err(ApiManagerError::FileNotFound(file_path.to_string())); } let full_path_str = full_path.to_str().unwrap_or_default(); @@ -221,7 +225,7 @@ impl Manager { { Ok(result) => result, Err(e) => { - return Err(LspManagerError::InternalError(format!( + return Err(ApiManagerError::InternalError(format!( "Failed to find referenced symbols, {}", e ))); @@ -253,7 +257,7 @@ impl Manager { // Only return an error if we couldn't get any definitions at all if definitions.is_empty() && !references_to_symbols.is_empty() { - return Err(LspManagerError::InternalError( + return Err(ApiManagerError::InternalError( "Failed to retrieve any definitions for the referenced symbols".to_string(), )); } @@ -265,15 +269,15 @@ impl Manager { &self, file_path: &str, range: Option, - ) -> Result { + ) -> Result { let full_path = get_mount_dir().join(file_path); - let mut locked_client = self.client.lock().await; + let locked_client = self.client.lock().await; locked_client .get_workspace_documents() .read_text_document(&full_path, range) .await .map_err(|e| { - LspManagerError::InternalError(format!("Source code retrieval failed: {}", e)) + ApiManagerError::InternalError(format!("Source code retrieval failed: {}", e)) }) } @@ -281,16 +285,46 @@ impl Manager { pub fn get_client(&self, _lang: common::api_types::SupportedLanguages) -> Option<()> { Some(()) } + + /// Get the underlying LSP client for WebSocket connections + pub fn get_lsp_client(&self) -> Arc> { + self.client.clone() + } + + /// Forward a raw LSP JSON-RPC request to the LSP server + /// + /// This provides lightweight pass-through of JSON-RPC requests with minimal processing. + pub async fn lsp(&self, request: JsonRpcMessage) -> Result { + let req_id = request.id; + let Some(method) = request.method else { + return Err(ApiManagerError::BadRequest("missing method".to_string())); + }; + + // Forward to LSP server + let mut locked_client = self.client.lock().await; + let result = locked_client + .send_request(&method, request.params) + .await + .map_err(|e| { + error!("Failed to forward LSP request: {}", e); + ApiManagerError::InternalError(format!("LSP request failed: {}", e)) + })?; + + // Build response + let response = JsonRpcMessage::new_result_response(req_id, result); + + Ok(response) + } } // Convert from common LspError to wrapper-specific LspManagerError -impl From for LspManagerError { +impl From for ApiManagerError { fn from(err: common::error::LspError) -> Self { use common::error::LspError as CommonError; match &err { - CommonError::FileNotFound(s) => LspManagerError::FileNotFound(s.clone()), - CommonError::UnsupportedFileType(s) => LspManagerError::UnsupportedFileType(s.clone()), - _ => LspManagerError::InternalError(err.to_string()), + CommonError::FileNotFound(s) => ApiManagerError::FileNotFound(s.clone()), + CommonError::UnsupportedFileType(s) => ApiManagerError::UnsupportedFileType(s.clone()), + _ => ApiManagerError::InternalError(err.to_string()), } } } diff --git a/crates/wrapper/src/managers/mod.rs b/crates/wrapper/src/managers/mod.rs new file mode 100644 index 00000000..b3bd2fc0 --- /dev/null +++ b/crates/wrapper/src/managers/mod.rs @@ -0,0 +1,2 @@ +pub mod api; +pub mod ws; diff --git a/crates/wrapper/src/managers/ws.rs b/crates/wrapper/src/managers/ws.rs new file mode 100644 index 00000000..221c4a29 --- /dev/null +++ b/crates/wrapper/src/managers/ws.rs @@ -0,0 +1,173 @@ +use crate::lsp::client::LspClient; +use actix_ws::{Message as WsMessage, MessageStream, Session}; +use common::api_types::{JsonRpcErrorCode, JsonRpcMessage}; +use futures::StreamExt; +use log::{debug, error, warn}; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Manages WebSocket connections for bidirectional LSP communication +pub struct WsManager { + client: Arc>, +} + +impl WsManager { + pub fn new(client: Arc>) -> Self { + Self { client } + } + + /// Handle a WebSocket connection, forwarding messages between WebSocket and LSP + pub async fn handle_connection( + &self, + mut session: Session, + mut msg_stream: MessageStream, + ) -> Result<(), Box> { + debug!("New WebSocket connection established"); + + spawn_notification_forwarder(self.client.clone(), session.clone()); + + while let Some(Ok(msg)) = msg_stream.next().await { + match msg { + WsMessage::Text(text) => { + if let Err(e) = + handle_ws_text_message(text.to_string(), &self.client, &session).await + { + error!("Error handling text message: {}", e); + break; + } + } + WsMessage::Ping(bytes) => { + debug!("Received ping"); + if let Err(e) = session.pong(&bytes).await { + error!("Failed to send pong: {}", e); + break; + } + } + WsMessage::Pong(_) => { + debug!("Received pong"); + } + WsMessage::Close(reason) => { + debug!("WebSocket closed: {:?}", reason); + break; + } + _ => { + warn!("Received unsupported WebSocket message type"); + } + } + } + + debug!("WebSocket connection ended"); + Ok(()) + } +} + +/// Spawn a task to forward LSP notifications to the WebSocket +fn spawn_notification_forwarder(client: Arc>, mut session: Session) { + tokio::spawn(async move { + let mut notification_rx = { + let locked_client = client.lock().await; + locked_client.subscribe_to_unexpected_notifications() + }; + + while let Ok(notification) = notification_rx.recv().await { + debug!( + "Forwarding notification to WebSocket: {}", + notification + .method + .as_ref() + .unwrap_or(&"unknown".to_string()) + ); + + match serde_json::to_string(¬ification) { + Ok(json) => { + if let Err(e) = session.text(json).await { + error!("Failed to send notification to WebSocket: {}", e); + break; + } + } + Err(e) => { + error!("Failed to serialize notification: {}", e); + } + } + } + debug!("Notification forwarding task ended"); + }); +} + +/// Handle a text message from the WebSocket +async fn handle_ws_text_message( + text: String, + client: &Arc>, + session: &Session, +) -> Result<(), Box> { + debug!("Received WebSocket message: {}", text); + + let json_rpc_msg: JsonRpcMessage = serde_json::from_str(&text)?; + + let method = json_rpc_msg + .method + .clone() + .ok_or("Received message without method field")?; + + if json_rpc_msg.id.is_some() { + handle_lsp_request(json_rpc_msg, method, client.clone(), session.clone()); + } else { + handle_lsp_notification(method, json_rpc_msg.params, client).await?; + } + + Ok(()) +} + +/// Spawn a task to handle an LSP request and send the response +fn handle_lsp_request( + json_rpc_msg: JsonRpcMessage, + method: String, + client: Arc>, + mut session: Session, +) { + tokio::spawn(async move { + let request_id = json_rpc_msg.id.clone(); + + // Send request to LSP server and wait for response + let response = { + let mut locked_client = client.lock().await; + match locked_client + .send_request(&method, json_rpc_msg.params) + .await + { + Ok(result) => JsonRpcMessage::new_result_response(request_id, result), + Err(e) => { + error!("LSP request failed: {}", e); + JsonRpcMessage::new_error_response( + request_id, + JsonRpcErrorCode::InternalError as i32, + e.to_string(), + ) + } + } + }; + + // Send response back to WebSocket + match serde_json::to_string(&response) { + Ok(json) => { + if let Err(e) = session.text(json).await { + error!("Failed to send response to WebSocket: {}", e); + } + } + Err(e) => { + error!("Failed to serialize response: {}", e); + } + } + }); +} + +/// Handle an LSP notification (no response expected) +async fn handle_lsp_notification( + method: String, + params: Option, + client: &Arc>, +) -> Result<(), Box> { + let mut locked_client = client.lock().await; + locked_client.send_notification(&method, params).await?; + Ok(()) +} diff --git a/docs/lsproxy.md b/docs/lsproxy.md index 662d0c5c..ef798247 100644 --- a/docs/lsproxy.md +++ b/docs/lsproxy.md @@ -1,4 +1,4 @@ -# Relation to LSProxy +# Relation to LSProxy Nuanced LSP started as a fork of `agentic-labs/lsproxy`. We celebrate and call out the capabilities and contribution from Agentic Labs, and thank them for graciously providing `lsproxy` as an open-source project. We applaud the originality and creativity of using `ast-grep` in combination with LSP capabilities like `find-definition` and `find-references` within a single binary that makes it easy to "proxy" to LSP servers. The Agentic Labs vision of `lsproxy` is still a shining example of what building code intelligence tooling for AI workflows can be, and we are grateful for the opportunity to build on `lsproxy`. diff --git a/openapi.json b/openapi.json index 723b586a..17bc6c76 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ "name": "MIT", "url": "https://opensource.org/licenses/MIT" }, - "version": "0.4.9" + "version": "0.5.0" }, "servers": [ { @@ -68,7 +68,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetDefinitionRequest" + "$ref": "#/components/schemas/FindDefinitionRequest" } } }, @@ -80,7 +80,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DefinitionResponse" + "$ref": "#/components/schemas/FindDefinitionResponse" } } } @@ -117,7 +117,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IdentifierResponse" + "$ref": "#/components/schemas/FindIdentifierResponse" } } } @@ -142,7 +142,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetReferencedSymbolsRequest" + "$ref": "#/components/schemas/FindReferencedSymbolsRequest" } } }, @@ -154,7 +154,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReferencedSymbolsResponse" + "$ref": "#/components/schemas/FindReferencedSymbolsResponse" } } } @@ -179,7 +179,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetReferencesRequest" + "$ref": "#/components/schemas/FindReferencesRequest" } } }, @@ -191,7 +191,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReferencesResponse" + "$ref": "#/components/schemas/FindReferencesResponse" } } } @@ -249,7 +249,17 @@ "operationId": "list_files", "responses": { "200": { - "description": "Files listed successfully" + "description": "Files listed successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } }, "500": { "description": "Internal server error" @@ -276,7 +286,14 @@ }, "responses": { "200": { - "description": "Source code retrieved successfully" + "description": "Source code retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadSourceCodeResponse" + } + } + } }, "400": { "description": "Bad request" @@ -305,36 +322,17 @@ } } }, - "DefinitionResponse": { + "DefinitionsInFileRequest": { "type": "object", - "description": "Response to a definition request.\n\nThe definition(s) of the symbol.\nPoints to the start position of the symbol's identifier.\n\ne.g. for the definition of `User` on line 5 of `src/main.py` with the code:\n```text\n0: class User:\n_________^\n1: def __init__(self, name, age):\n2: self.name = name\n3: self.age = age\n4:\n5: user = User(\"John\", 30)\n__________^\n```\nThe definition(s) will be `[{\"path\": \"src/main.py\", \"line\": 0, \"character\": 6}]`.", + "description": "Request to get the symbols in a file.", "required": [ - "definitions", - "selected_identifier" + "file_path" ], "properties": { - "definitions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FilePosition" - } - }, - "raw_response": { - "description": "The raw response from the langserver.\n\nhttps://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition" - }, - "selected_identifier": { - "$ref": "#/components/schemas/Identifier", - "description": "The identifier that was \"clicked-on\" to get the definition." - }, - "source_code_context": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/components/schemas/CodeContext" - }, - "description": "The source code of symbol definitions." + "file_path": { + "type": "string", + "description": "The path to the file to get the symbols for, relative to the root of the workspace.", + "example": "src/main.py" } } }, @@ -389,17 +387,57 @@ } } }, - "FileSymbolsRequest": { + "FindDefinitionRequest": { "type": "object", - "description": "Request to get the symbols in a file.", "required": [ - "file_path" + "position" ], "properties": { - "file_path": { - "type": "string", - "description": "The path to the file to get the symbols for, relative to the root of the workspace.", - "example": "src/main.py" + "include_raw_response": { + "type": "boolean", + "description": "Whether to include the raw response from the langserver in the response.\nDefaults to false.", + "example": false + }, + "include_source_code": { + "type": "boolean", + "description": "Whether to include the source code around the symbol's identifier in the response.\nDefaults to false.\nTODO: Implement this", + "example": false + }, + "position": { + "$ref": "#/components/schemas/FilePosition" + } + } + }, + "FindDefinitionResponse": { + "type": "object", + "description": "Response to a definition request.\n\nThe definition(s) of the symbol.\nPoints to the start position of the symbol's identifier.\n\ne.g. for the definition of `User` on line 5 of `src/main.py` with the code:\n```text\n0: class User:\n_________^\n1: def __init__(self, name, age):\n2: self.name = name\n3: self.age = age\n4:\n5: user = User(\"John\", 30)\n__________^\n```\nThe definition(s) will be `[{\"path\": \"src/main.py\", \"line\": 0, \"character\": 6}]`.", + "required": [ + "definitions", + "selected_identifier" + ], + "properties": { + "definitions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilePosition" + } + }, + "raw_response": { + "description": "The raw response from the langserver.\n\nhttps://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition" + }, + "selected_identifier": { + "$ref": "#/components/schemas/Identifier", + "description": "The identifier that was \"clicked-on\" to get the definition." + }, + "source_code_context": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/CodeContext" + }, + "description": "The source code of symbol definitions." } } }, @@ -433,28 +471,21 @@ } } }, - "GetDefinitionRequest": { + "FindIdentifierResponse": { "type": "object", "required": [ - "position" + "identifiers" ], "properties": { - "include_raw_response": { - "type": "boolean", - "description": "Whether to include the raw response from the langserver in the response.\nDefaults to false.", - "example": false - }, - "include_source_code": { - "type": "boolean", - "description": "Whether to include the source code around the symbol's identifier in the response.\nDefaults to false.\nTODO: Implement this", - "example": false - }, - "position": { - "$ref": "#/components/schemas/FilePosition" + "identifiers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Identifier" + } } } }, - "GetReferencedSymbolsRequest": { + "FindReferencedSymbolsRequest": { "type": "object", "description": "Request to get all symbols that are referenced from a symbol at the given position, either\nfocusing on function calls, or more permissively finding all references\n\nThe input position must point to a symbol (e.g. function name, class name, variable name).\nThe response will include all symbols that are referenced from that input symbol.\nFor example, if the position points to a function name, the response will include\nall symbols referenced within that function's implementation.", "required": [ @@ -472,7 +503,36 @@ } } }, - "GetReferencesRequest": { + "FindReferencedSymbolsResponse": { + "type": "object", + "description": "Response containing symbols referenced from the requested position\n\nThe symbols are categorized into:\n- workspace_symbols: References to symbols that were found and have definitions in the workspace\n- external_symbols: References to symbols from outside the workspace (built-in functions, external libraries)\n- not_found: References where the symbol definition could not be found", + "required": [ + "workspace_symbols", + "external_symbols", + "not_found" + ], + "properties": { + "external_symbols": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Identifier" + } + }, + "not_found": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Identifier" + } + }, + "workspace_symbols": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReferenceWithSymbolDefinitions" + } + } + } + }, + "FindReferencesRequest": { "type": "object", "required": [ "identifier_position" @@ -498,6 +558,39 @@ } } }, + "FindReferencesResponse": { + "type": "object", + "description": "Response to a references request.\n\nPoints to the start position of the symbol's identifier.\n\ne.g. for the references of `User` on line 0 character 6 of `src/main.py` with the code:\n```text\n0: class User:\n1: def __init__(self, name, age):\n2: self.name = name\n3: self.age = age\n4:\n5: user = User(\"John\", 30)\n_________^\n6:\n7: print(user.name)\n```\nThe references will be `[{\"path\": \"src/main.py\", \"line\": 5, \"character\": 7}]`.", + "required": [ + "references", + "selected_identifier" + ], + "properties": { + "context": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/CodeContext" + }, + "description": "The source code around the references." + }, + "raw_response": { + "description": "The raw response from the langserver.\n\nhttps://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references" + }, + "references": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilePosition" + } + }, + "selected_identifier": { + "$ref": "#/components/schemas/Identifier", + "description": "The identifier that was \"clicked-on\" to get the references." + } + } + }, "HealthResponse": { "type": "object", "description": "Response returned by the health check endpoint", @@ -631,20 +724,6 @@ } } }, - "IdentifierResponse": { - "type": "object", - "required": [ - "identifiers" - ], - "properties": { - "identifiers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Identifier" - } - } - } - }, "LanguageVariant": { "type": "string", "description": "Language variant for languages that have multiple type system modes\n\nSome languages support different type-checking modes that require different\nlanguage servers or configurations. This enum captures those variants.", @@ -722,6 +801,17 @@ } } }, + "ReadSourceCodeResponse": { + "type": "object", + "required": [ + "source_code" + ], + "properties": { + "source_code": { + "type": "string" + } + } + }, "ReferenceWithSymbolDefinitions": { "type": "object", "description": "A reference to a symbol along with its definition(s) found in the workspace\n\ne.g. for a reference to `User` in `main.py`:\n```python\nuser = User(\"John\", 30)\n_______^\n```\nThis would contain:\n- The reference location and name (\"User\" at line 0)\n- The symbol definition(s) (e.g. \"class User\" in models.py)", @@ -741,68 +831,6 @@ } } }, - "ReferencedSymbolsResponse": { - "type": "object", - "description": "Response containing symbols referenced from the requested position\n\nThe symbols are categorized into:\n- workspace_symbols: References to symbols that were found and have definitions in the workspace\n- external_symbols: References to symbols from outside the workspace (built-in functions, external libraries)\n- not_found: References where the symbol definition could not be found", - "required": [ - "workspace_symbols", - "external_symbols", - "not_found" - ], - "properties": { - "external_symbols": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Identifier" - } - }, - "not_found": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Identifier" - } - }, - "workspace_symbols": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ReferenceWithSymbolDefinitions" - } - } - } - }, - "ReferencesResponse": { - "type": "object", - "description": "Response to a references request.\n\nPoints to the start position of the symbol's identifier.\n\ne.g. for the references of `User` on line 0 character 6 of `src/main.py` with the code:\n```text\n0: class User:\n1: def __init__(self, name, age):\n2: self.name = name\n3: self.age = age\n4:\n5: user = User(\"John\", 30)\n_________^\n6:\n7: print(user.name)\n```\nThe references will be `[{\"path\": \"src/main.py\", \"line\": 5, \"character\": 7}]`.", - "required": [ - "references", - "selected_identifier" - ], - "properties": { - "context": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/components/schemas/CodeContext" - }, - "description": "The source code around the references." - }, - "raw_response": { - "description": "The raw response from the langserver.\n\nhttps://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references" - }, - "references": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FilePosition" - } - }, - "selected_identifier": { - "$ref": "#/components/schemas/Identifier", - "description": "The identifier that was \"clicked-on\" to get the references." - } - } - }, "SupportedLanguages": { "oneOf": [ { @@ -917,38 +945,6 @@ "example": "User" } } - }, - "Vec": { - "type": "array", - "items": { - "type": "object", - "required": [ - "name", - "kind", - "identifier_position", - "file_range" - ], - "properties": { - "file_range": { - "$ref": "#/components/schemas/FileRange", - "description": "The full range of the symbol." - }, - "identifier_position": { - "$ref": "#/components/schemas/FilePosition", - "description": "The start position of the symbol's identifier." - }, - "kind": { - "type": "string", - "description": "The kind of the symbol (e.g., function, class).", - "example": "class" - }, - "name": { - "type": "string", - "description": "The name of the symbol.", - "example": "User" - } - } - } } }, "securitySchemes": { diff --git a/scripts/include/lib.sh b/scripts/include/lib.sh index 388f3477..57d31b91 100644 --- a/scripts/include/lib.sh +++ b/scripts/include/lib.sh @@ -3,14 +3,16 @@ # Check if all given commands are present # Usage: has_commands COMMAND... has_commands() { - result=0 + local missing=() for cmd in "$@"; do if ! command -v "$cmd" &> /dev/null; then - echo "missing command: $cmd" >&2 - result=1 + missing+=("$cmd") fi done - return $result + if [ "${#missing[@]}" -gt 0 ]; then + echo "${missing[@]}" + return 1 + fi } # Ensure there's a line `[version] - YYYY-MM-DD` in a changelog file diff --git a/scripts/test-all-endpoints.sh b/scripts/test-all-endpoints.sh index e6c28df9..6a86fd99 100755 --- a/scripts/test-all-endpoints.sh +++ b/scripts/test-all-endpoints.sh @@ -6,6 +6,7 @@ SCRIPT_DIR="$(cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")" && pwd)" source "$SCRIPT_DIR/include/colors.sh" source "$SCRIPT_DIR/include/constants.sh" +source "$SCRIPT_DIR/include/lib.sh" usage() { echo "Usage: $0 [--language-tag=TAG] [--no-cleanup] [--service-tag=TAG]" @@ -60,7 +61,7 @@ for arg in "$@"; do done # Configuration -BASE_URL="${BASE_URL:-http://localhost:4444/v1}" +BASE_URL="http://localhost:4444" WORKSPACE_PATH="$(cd "$SCRIPT_DIR/../sample_project/all" && pwd)" # Counters @@ -71,6 +72,15 @@ FAILED_TESTS=0 # Track if we started the service (to know if we should clean it up) STARTED_SERVICE=false +# Workspace URI for testing LSP endpoint +WORKSPACE_URI="file://$(realpath "$WORKSPACE_PATH")" + +# Check required commands +if ! missing=$(has_commands curl docker websocat); then + echo -e "${RED}Missing required commands: $missing${NC}" + exit 1 +fi + # Cleanup function cleanup() { local exit_code=$? @@ -112,17 +122,16 @@ trap cleanup EXIT INT TERM # Language configurations # Format: language_key test_file symbol_name symbol_line symbol_char health_key LANGUAGE_CONFIGS=" -python|main.py|main|14|4|python -typescript|src/main.ts|main|5|6|typescript_javascript -javascript|src/main.ts|main|5|6|typescript_javascript -rust|src/main.rs|main|10|3|rust -go|main.go|main|7|5|golang -java|Main.java|main|5|23|java cpp|astar_search.cpp|main|2|4|cpp csharp|Program.cs|Main|4|20|csharp +go|main.go|main|7|5|golang +java|Main.java|main|5|23|java +javascript|src/main.ts|main|5|6|typescript_javascript php|AStar.php|findPathTo|26|20|php +python|main.py|main|14|4|python ruby|main.rb|main|35|4|ruby_3_4_4 -ruby-sorbet|user_service.rb|create_user|15|6|ruby_sorbet_3_4_4 +rust|src/main.rs|main|10|3|rust +typescript|src/main.ts|main|5|6|typescript_javascript " # Deep validation for find-referenced-symbols (ast-grep backed) @@ -147,22 +156,21 @@ ruby-sorbet|user_service.rb|create_user|15|6|ruby_sorbet_3_4_4 # Note: All languages have identifier and symbol rules, but find-referenced-symbols # specifically requires reference rules to find symbol usages within a method body. FIND_REF_TESTS=" -python|main.py|14|4|1|AStarGraph -typescript|src/astar.ts|60|12|2|isInBounds,isWalkable csharp|AStar.cs|23|27|1|AddNeighborsToOpenList php|AStar.php|26|20|1|addNeighborsToOpenList +python|main.py|14|4|1|AStarGraph +typescript|src/astar.ts|60|12|2|isInBounds,isWalkable " # Tests that are not working (commented out - need ast-grep reference rules) -# ruby|search.rb|31|15|1|initialize_search +# clangd|astar_search.cpp|??|??|1|?? # golang|golang_astar/astar.go|??|??|1|?? -# rust|src/astar.rs|??|??|1|?? # java|AStar.java|39|22|1|?? -# clangd|astar_search.cpp|??|??|1|?? +# ruby|search.rb|31|15|1|initialize_search +# rust|src/astar.rs|??|??|1|?? #" -# Test function -test_endpoint() { +test_http_endpoint() { local test_name="$1" local method="$2" local endpoint="$3" @@ -175,14 +183,14 @@ test_endpoint() { echo -n " Testing $test_name... " # Build curl command with timeout - local curl_cmd="curl -s -w '\n%{http_code}' --max-time 30 -X $method" + local curl_cmd=("curl" "-s" "-w" '\n%{http_code}' "--max-time" "30" "-X" "$method") if [ -n "$data" ]; then - curl_cmd="$curl_cmd -H 'Content-Type: application/json' -d '$data'" + curl_cmd+=("-H" "Content-Type: application/json" "-d" "$data") fi - curl_cmd="$curl_cmd '$BASE_URL$endpoint'" + curl_cmd+=("$BASE_URL$endpoint") # Execute request (curl has built-in timeout via --max-time) - if response=$(eval "$curl_cmd" 2>&1); then + if response=$("${curl_cmd[@]}" 2>&1); then # Split response body and status code local body status body=$(echo "$response" | sed '$d') @@ -231,7 +239,67 @@ test_endpoint() { fi } -# Enhanced test function for find-referenced-symbols with deep validation +test_ws_endpoint() { + local test_name="$1" + local endpoint="$2" + local method="$3" + local params="$4" + local validation_check="$5" + + TOTAL_TESTS=$((TOTAL_TESTS + 1)) + + echo -n " Testing $test_name... " + + # Build request + local url="ws${BASE_URL#http}$endpoint" + local request="{\"jsonrpc\":\"2.0\",\"id\":\"$TOTAL_TESTS\",\"method\":\"textDocument/references\",\"params\":$params}" + + # Execute request + # This is a bit tricky because of websocat exit. It can either + # exit when stdin is closed, but will do so before the response is + # received, or it will never exit. Using `timeout` isn't possible, + # because `timeout` exists with an error code. We use sleep to delay + # the closing of stdin. Hopefully 5 seconds is enough, because the + # test will always wait for the full delay, even if the response + # comes earlier. + if response=$((echo "$request" ; sleep 5) | websocat -q --exit-on-eof "$url" | jq -c --unbuffered "select(.id == \"$TOTAL_TESTS\") | .result , halt" 2>&1); then + local body="$response" + + # Validate JSON structure + if ! echo "$body" | jq . > /dev/null 2>&1; then + echo -e "${RED}✗ FAIL${NC} - Invalid JSON response" + echo " Response: $body" | head -3 + FAILED_TESTS=$((FAILED_TESTS + 1)) + return 1 + fi + + # Run custom validation if provided + if [ -n "$validation_check" ]; then + if ! echo "$body" | eval "$validation_check"; then + echo -e "${RED}✗ FAIL${NC} - Validation check failed" + echo " Check: $validation_check" + echo " Response: $body" | head -5 + FAILED_TESTS=$((FAILED_TESTS + 1)) + return 1 + fi + fi + + echo -e "${GREEN}✓ PASS${NC}" + PASSED_TESTS=$((PASSED_TESTS + 1)) + return 0 + else + local exit_code=$? + if [ $exit_code -eq 28 ]; then + echo -e "${RED}✗ FAIL${NC} - Timeout (30s)" + else + echo -e "${RED}✗ FAIL${NC} - Request failed (exit code: $exit_code)" + echo " Error: $response" | head -3 + fi + FAILED_TESTS=$((FAILED_TESTS + 1)) + return 1 + fi +} + test_find_referenced_symbols_enhanced() { local lang="$1" local file="$2" @@ -247,7 +315,7 @@ test_find_referenced_symbols_enhanced() { local data="{\"identifier_position\":{\"path\":\"$file\",\"position\":{\"line\":$line,\"character\":$char}},\"full_scan\":false}" # Make request - local curl_cmd="curl -s -w '\n%{http_code}' --max-time 30 -X POST -H 'Content-Type: application/json' -d '$data' '$BASE_URL/symbol/find-referenced-symbols'" + local curl_cmd="curl -s -w '\n%{http_code}' --max-time 30 -X POST -H 'Content-Type: application/json' -d '$data' '$BASE_URL/v1/symbol/find-referenced-symbols'" if response=$(eval "$curl_cmd" 2>&1); then # Split response body and status code @@ -384,7 +452,8 @@ else echo -e "${YELLOW} Waiting for service and language health (up to 100s)...${NC}" ready=false for i in $(seq 1 100); do - HEALTH=$(curl -sf "${BASE_URL}/system/health" || true) + HEALTH=$(curl -sf "${BASE_URL}/v1/system/health" || true) + echo "$HEALTH" | jq . STATUS=$(echo "$HEALTH" | jq -r '.status' 2>/dev/null || echo "") LANG_FAILED=$(echo "$HEALTH" | jq -r '.languages | to_entries[]? | select(.value == false) | .key' 2>/dev/null || true) @@ -425,9 +494,9 @@ echo # Test 1: System Health echo -e "${YELLOW}1. System Health Check${NC}" -test_endpoint "Health Check" \ +test_http_endpoint "Health Check" \ "GET" \ - "/system/health" \ + "/v1/system/health" \ "" \ "200" \ "jq -e '.status == \"ok\"' > /dev/null" @@ -435,9 +504,9 @@ echo # Test 2: List Files (language-agnostic) echo -e "${YELLOW}2. Workspace Endpoints (Language-Agnostic)${NC}" -test_endpoint "List Files" \ +test_http_endpoint "List Files" \ "GET" \ - "/workspace/list-files" \ + "/v1/workspace/list-files" \ "" \ "200" \ "jq -e 'type == \"array\" and length > 0' > /dev/null" @@ -454,72 +523,81 @@ while IFS='|' read -r lang test_file symbol_name symbol_line symbol_char health_ # Skip empty lines [ -z "$lang" ] && continue + test_uri="$WORKSPACE_URI/$test_file" + echo -e "${BLUE}Testing language: $(echo "$lang" | tr '[:lower:]' '[:upper:]')${NC}" # Health check for this language - test_endpoint "Health ($lang)" \ + test_http_endpoint "Health ($lang)" \ "GET" \ - "/system/health" \ + "/v1/system/health" \ "" \ "200" \ "jq -e '.languages.$health_key == true' > /dev/null" # Read Source Code - test_endpoint "Read Source ($lang)" \ + test_http_endpoint "Read Source ($lang)" \ "POST" \ - "/workspace/read-source-code" \ + "/v1/workspace/read-source-code" \ "{\"path\":\"$test_file\"}" \ "200" \ "jq -e '.source_code | type == \"string\" and length > 0' > /dev/null" # Read Source Code with Range - test_endpoint "Read Source with Range ($lang)" \ + test_http_endpoint "Read Source with Range ($lang)" \ "POST" \ - "/workspace/read-source-code" \ + "/v1/workspace/read-source-code" \ "{\"path\":\"$test_file\",\"range\":{\"start\":{\"line\":0,\"character\":0},\"end\":{\"line\":1,\"character\":0}}}" \ "200" \ "jq -e '.source_code | type == \"string\"' > /dev/null" # Find Definition (assert selected identifier and at least one definition) - test_endpoint "Find Definition ($lang)" \ + test_http_endpoint "Find Definition ($lang)" \ "POST" \ - "/symbol/find-definition" \ + "/v1/symbol/find-definition" \ "{\"position\":{\"path\":\"$test_file\",\"position\":{\"line\":$symbol_line,\"character\":$symbol_char}},\"include_source_code\":false,\"include_raw_response\":false}" \ "200" \ "jq -e '.selected_identifier.name == \"$symbol_name\" and (.definitions | length) >= 0 and (.selected_identifier.file_range.path == \"$test_file\")' > /dev/null" # Find References (assert selected identifier matches and references is an array) - test_endpoint "Find References ($lang)" \ + test_http_endpoint "Find References ($lang)" \ "POST" \ - "/symbol/find-references" \ + "/v1/symbol/find-references" \ "{\"identifier_position\":{\"path\":\"$test_file\",\"position\":{\"line\":$symbol_line,\"character\":$symbol_char}},\"include_code_context_lines\":0}" \ "200" \ "jq -e '.selected_identifier.name == \"$symbol_name\" and .selected_identifier.file_range.path == \"$test_file\" and (.references | type == \"array\")' > /dev/null" # Find Referenced Symbols - test_endpoint "Find Referenced Symbols ($lang)" \ + test_http_endpoint "Find Referenced Symbols ($lang)" \ "POST" \ - "/symbol/find-referenced-symbols" \ + "/v1/symbol/find-referenced-symbols" \ "{\"identifier_position\":{\"path\":\"$test_file\",\"position\":{\"line\":$symbol_line,\"character\":$symbol_char}},\"full_scan\":false}" \ "200" \ "jq -e 'type == \"object\"' > /dev/null" # Definitions in File - test_endpoint "Definitions in File ($lang)" \ + test_http_endpoint "Definitions in File ($lang)" \ "GET" \ - "/symbol/definitions-in-file?file_path=$test_file" \ + "/v1/symbol/definitions-in-file?file_path=$test_file" \ "" \ "200" \ "jq -e 'type == \"array\"' > /dev/null" # Find Identifier - test_endpoint "Find Identifier ($lang)" \ + test_http_endpoint "Find Identifier ($lang)" \ "POST" \ - "/symbol/find-identifier" \ + "/v1/symbol/find-identifier" \ "{\"path\":\"$test_file\",\"name\":\"$symbol_name\"}" \ "200" \ "jq -e 'type == \"object\"' > /dev/null" + # Find Definition (assert selected identifier and at least one definition) + test_ws_endpoint "LSP textDocument/references ($lang)" \ + "/lsp/ws" \ + "textDocument/references" \ + "{\"textDocument\":{\"uri\":\"$test_uri\"},\"position\":{\"line\":$symbol_line,\"character\":$symbol_char},\"context\":{\"includeDeclaration\":true}}" \ + "jq -e 'type == \"array\"' > /dev/null" + echo done <<< "$LANGUAGE_CONFIGS" diff --git a/scripts/test-container-lifecycle.sh b/scripts/test-container-lifecycle.sh index 739ffbc8..24cb706c 100755 --- a/scripts/test-container-lifecycle.sh +++ b/scripts/test-container-lifecycle.sh @@ -6,6 +6,7 @@ SCRIPT_DIR="$(cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")" && pwd)" source "$SCRIPT_DIR/include/colors.sh" source "$SCRIPT_DIR/include/constants.sh" +source "$SCRIPT_DIR/include/lib.sh" help() { echo "Test container lifecycle: build, run, health check, cleanup" @@ -83,6 +84,12 @@ PROXY_IMAGE="${REGISTRY:+$REGISTRY/}${PROXY_IMAGE:-nuanced-lsp-proxy:${SERVICE_T # Flag to track if we started containers CONTAINERS_STARTED=false +# Check required commands +if ! missing=$(has_commands curl docker); then + echo -e "${RED}Missing required commands: $missing${NC}" + exit 1 +fi + echo -e "${BLUE}=========================================${NC}" echo -e "${BLUE} Container Lifecycle Tests${NC}" echo -e "${BLUE} Workspace: $WORKSPACE_PATH${NC}" diff --git a/scripts/test-watchdog.sh b/scripts/test-watchdog.sh index 0a1e2727..c9c0fe23 100755 --- a/scripts/test-watchdog.sh +++ b/scripts/test-watchdog.sh @@ -6,6 +6,7 @@ SCRIPT_DIR="$(cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")" && pwd)" source "$SCRIPT_DIR/include/colors.sh" source "$SCRIPT_DIR/include/constants.sh" +source "$SCRIPT_DIR/include/lib.sh" help() { echo "Test watchdog container functionality" @@ -82,6 +83,12 @@ fi PROXY_IMAGE="${REGISTRY:+$REGISTRY/}${PROXY_IMAGE:-nuanced-lsp-proxy:${SERVICE_TAG:-$DEFAULT_SERVICE_TAG}}" +# Check required commands +if ! missing=$(has_commands curl docker); then + echo -e "${RED}Missing required commands: $missing${NC}" + exit 1 +fi + echo -e "${BLUE}=========================================${NC}" echo -e "${BLUE} Watchdog Functionality Tests${NC}" echo -e "${BLUE} Workspace: $WORKSPACE_PATH${NC}"