diff --git a/.docker/jdk.Dockerfile b/.docker/jdk.Dockerfile new file mode 100644 index 00000000..f4513e38 --- /dev/null +++ b/.docker/jdk.Dockerfile @@ -0,0 +1,12 @@ +FROM eclipse-temurin:21-jdk + +# X11 + font + GTK libs PhpStorm needs to render its Swing UI under +# `./gradlew runIde`. The base image ships headless deps only; without +# these the JVM boots but dies on the first Toolkit.getDefaultToolkit() +# call with "Can't connect to X11 window server" or a font-config error. +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + libxext6 libxrender1 libxi6 libxtst6 libxrandr2 libxcursor1 \ + libxinerama1 libfontconfig1 libfreetype6 libgtk-3-0 \ + fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* diff --git a/.docker/php.Dockerfile b/.docker/php.Dockerfile index e24b16bc..6df3aae1 100644 --- a/.docker/php.Dockerfile +++ b/.docker/php.Dockerfile @@ -1,11 +1,29 @@ FROM php:8.4-cli-alpine +# Composer: copied from the official multi-arch image instead of curl- +# piped install. Reproducible across rebuilds and gets composer 2.x +# without an interactive installer. Both Makefile entry points +# (`make test` and `make test/mutation`) shell out to `composer +# install`, so this needs to be in PATH. +COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer + RUN apk add --update --no-cache linux-headers RUN apk add --no-cache \ php84-dev \ - build-base + build-base \ + git \ + unzip +# Both coverage drivers are installed. Xdebug runs as the dev-time +# debugger; PCOV is used exclusively for Infection's coverage pass +# (orders of magnitude less memory than Xdebug, which avoids the +# host OOM-killer firing during the initial test run on tight +# containers). Infection auto-detects PCOV when it's loaded and +# Xdebug is disabled via XDEBUG_MODE=off; the mutation Makefile +# target does both. RUN pecl install \ xdebug \ + pcov \ && docker-php-ext-enable \ - xdebug + xdebug \ + pcov diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml new file mode 100644 index 00000000..41f8bc7a --- /dev/null +++ b/.github/workflows/ci-core.yml @@ -0,0 +1,69 @@ +name: Core CI + +on: + pull_request: + push: + branches: [main] + +# Cancel superseded runs on the same branch (e.g. when a PR gets a new push) +# so we don't pay for stale-commit mutation runs. The concurrency group is +# keyed on the workflow name + ref, so this is per-workflow — the LSP CI +# can run concurrently with this one without queueing. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + phpunit: + name: PHPUnit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP 8.4 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: dom, json, mbstring, tokenizer + coverage: none + tools: composer:v2 + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Run PHPUnit + run: make test/unit + + infection: + name: Mutation testing + runs-on: ubuntu-latest + needs: phpunit + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP 8.4 (with Xdebug for coverage) + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: dom, json, mbstring, tokenizer + coverage: xdebug + tools: composer:v2 + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Run Infection + # --min-covered-msi=93 fails CI if mutation score drops below 93 (current baseline: 94%). + # --threads=4 parallelizes mutant runs; safe on the 2-core ubuntu-latest runner. + run: make test/mutation + + - name: Upload Infection report + if: always() + uses: actions/upload-artifact@v4 + with: + name: infection-report + path: | + var/infection.log + var/infection.html + if-no-files-found: ignore + retention-days: 14 diff --git a/.github/workflows/ci-lsp.yml b/.github/workflows/ci-lsp.yml new file mode 100644 index 00000000..55169cbc --- /dev/null +++ b/.github/workflows/ci-lsp.yml @@ -0,0 +1,94 @@ +name: LSP CI + +# PHPUnit runs on every PR + push to main as a hard gate. +# Infection (mutation testing) is opt-in via workflow_dispatch -- its +# resource envelope outgrew GitHub-hosted `ubuntu-latest` (~7 GB RAM) +# once the suite passed ~150 tests, and the initial coverage run now +# OOMs as SIGTERM 143 mid-stream. Re-enable as a PR gate once the +# job is reshaped to filter / batch the test set (see follow-up). +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +# Per-workflow concurrency. See ci-core.yml for the rationale. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + phpunit-lsp: + name: PHPUnit (LSP) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP 8.4 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: dom, json, mbstring, tokenizer + coverage: none + tools: composer:v2 + + # The LSP package has its own composer.json under tools/lsp/ with a + # path-repo pointing back at the repo root. Cache its deps separately + # from the core install so a core-only change doesn't bust the LSP cache. + - name: Install LSP dependencies + uses: ramsey/composer-install@v3 + with: + working-directory: tools/lsp + + - name: Run PHPUnit (LSP) + run: make -C tools/lsp test + + infection-lsp: + name: Mutation testing (LSP) + # Gate: only run when explicitly requested via the Actions tab. The + # 424-test suite plus per-mutation reruns overruns the GitHub-hosted + # runner's RAM, killing the initial coverage subprocess with SIGTERM + # 143. PRs and merges to main get fast unambiguous signal from the + # PHPUnit job above; Infection scoring is opt-in until the job is + # reshaped to filter / batch the test set. + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + needs: phpunit-lsp + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP 8.4 (with PCOV for coverage) + # PCOV is preferred over Xdebug for Infection's initial run: an + # order of magnitude less memory under the same coverage scope, + # which keeps the runner well clear of the OOM-killer. The + # Makefile target sets pcov.directory + XDEBUG_MODE=off so the + # driver choice is deterministic regardless of what else is loaded. + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: dom, json, mbstring, tokenizer + coverage: pcov + tools: composer:v2 + + - name: Install LSP dependencies + uses: ramsey/composer-install@v3 + with: + working-directory: tools/lsp + + - name: Run Infection (LSP) + # `make -C tools/lsp test/mutation` lazily downloads infection.phar + # 0.33.1 into tools/lsp/var/ (PHAR sidesteps the composer dep conflict + # with phpactor/language-server documented in tools/lsp/README.md). + # Runs with --min-covered-msi=93; current baseline is 94%. + run: make -C tools/lsp test/mutation + + - name: Upload Infection report (LSP) + if: always() + uses: actions/upload-artifact@v4 + with: + name: infection-lsp-report + path: | + tools/lsp/var/infection.log + tools/lsp/var/infection.html + if-no-files-found: ignore + retention-days: 14 diff --git a/.github/workflows/ci-phpstorm-plugin.yml b/.github/workflows/ci-phpstorm-plugin.yml new file mode 100644 index 00000000..7645b560 --- /dev/null +++ b/.github/workflows/ci-phpstorm-plugin.yml @@ -0,0 +1,141 @@ +name: PhpStorm Plugin CI + +on: + pull_request: + push: + branches: [main] + +# Per-workflow concurrency. See ci-core.yml for the rationale. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + gradle-build: + name: Gradle build (PhpStorm plugin) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # PHP + Composer are needed to build the bundled xphp-lsp.phar that + # processResources copies into the plugin jar at build time. Skipping + # this step would still produce a buildable plugin, but it would warn + # and ship without an embedded LSP -- defeating the zero-config + # install path we shipped in chunk 5. + - name: Setup PHP 8.4 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: dom, json, mbstring, tokenizer + coverage: none + tools: composer:v2 + + - name: Install LSP dependencies + uses: ramsey/composer-install@v3 + with: + working-directory: tools/lsp + + - name: Build xphp-lsp.phar + # Same target ci-lsp.yml's runtime relies on; the plugin's + # processResources picks this up from tools/lsp/var/. + run: make -C tools/lsp build/phar + + - name: Setup JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + # Cache key is built from the wrapper version + build script + # hash, so this stays warm across PRs that don't touch the plugin + # build config. + gradle-home-cache-cleanup: true + + - name: Build plugin (compile + test) + # `make build` from this directory is `./gradlew build`, which runs + # compileKotlin, test, and packages the plugin jar via the + # IntelliJ Platform Gradle Plugin's `composedJar` task. + run: make -C tools/phpstorm-plugin build + + - name: Upload plugin jar + uses: actions/upload-artifact@v4 + with: + name: xphp-phpstorm-plugin-jar + path: tools/phpstorm-plugin/build/libs/xphp-phpstorm-plugin-*.jar + if-no-files-found: error + retention-days: 14 + + - name: Upload test reports on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: phpstorm-plugin-test-reports + path: | + tools/phpstorm-plugin/build/reports/tests/ + tools/phpstorm-plugin/build/test-results/ + if-no-files-found: ignore + retention-days: 14 + + verify-plugin: + name: Plugin Verifier (PhpStorm plugin) + runs-on: ubuntu-latest + needs: gradle-build + steps: + - uses: actions/checkout@v4 + + # The verifier itself doesn't read the PHAR, but Gradle's build graph + # treats `processResources` as a prerequisite of `verifyPlugin` -- + # and `processResources` declares xphp-lsp.phar as a required input + # via `inputs.file(...)` in build.gradle.kts. Without the PHAR on + # disk, the build aborts before the verifier even runs: + # + # property 'xphpLspPhar' specifies file '.../xphp-lsp.phar' + # which doesn't exist. + # + # So we rebuild the same PHAR the gradle-build job built. Both + # jobs sit on fresh runners; sharing via an artifact would also + # work but the PHAR rebuild is cheap (~2s) compared to the verifier + # downloading the IDE matrix that follows. + - name: Setup PHP 8.4 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: dom, json, mbstring, tokenizer + coverage: none + tools: composer:v2 + + - name: Install LSP dependencies + uses: ramsey/composer-install@v3 + with: + working-directory: tools/lsp + + - name: Build xphp-lsp.phar + run: make -C tools/lsp build/phar + + - name: Setup JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run IntelliJ Plugin Verifier + # `verifyPlugin` checks binary compatibility across the IDE matrix + # declared in build.gradle.kts `pluginVerification.ides {}` -- the + # `recommended()` set for since-build 261. A failure here means a + # platform API we depend on changed shape between IDE builds. + run: make -C tools/phpstorm-plugin verify + + - name: Upload Plugin Verifier reports on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: phpstorm-plugin-verifier-reports + path: tools/phpstorm-plugin/build/reports/pluginVerifier/ + if-no-files-found: ignore + retention-days: 14 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 6edc2fa6..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,130 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: [main] - -# Cancel superseded runs on the same branch (e.g. when a PR gets a new push) -# so we don't pay for stale-commit mutation runs. -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - phpunit: - name: PHPUnit - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup PHP 8.4 - uses: shivammathur/setup-php@v2 - with: - php-version: '8.4' - extensions: dom, json, mbstring, tokenizer - coverage: none - tools: composer:v2 - - - name: Install dependencies - uses: ramsey/composer-install@v3 - - - name: Run PHPUnit - run: make test/unit - - phpunit-lsp: - name: PHPUnit (LSP) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup PHP 8.4 - uses: shivammathur/setup-php@v2 - with: - php-version: '8.4' - extensions: dom, json, mbstring, tokenizer - coverage: none - tools: composer:v2 - - # The LSP package has its own composer.json under tools/lsp/ with a - # path-repo pointing back at the repo root. Cache its deps separately - # from the core install so a core-only change doesn't bust the LSP cache. - - name: Install LSP dependencies - uses: ramsey/composer-install@v3 - with: - working-directory: tools/lsp - - - name: Run PHPUnit (LSP) - run: make test/lsp - - infection: - name: Mutation testing - runs-on: ubuntu-latest - needs: phpunit - steps: - - uses: actions/checkout@v4 - - - name: Setup PHP 8.4 (with Xdebug for coverage) - uses: shivammathur/setup-php@v2 - with: - php-version: '8.4' - extensions: dom, json, mbstring, tokenizer - coverage: xdebug - tools: composer:v2 - - - name: Install dependencies - uses: ramsey/composer-install@v3 - - - name: Run Infection - # --min-covered-msi=93 fails CI if mutation score drops below 93 (current baseline: 94%). - # --threads=4 parallelizes mutant runs; safe on the 2-core ubuntu-latest runner. - run: make test/mutation - - - name: Upload Infection report - if: always() - uses: actions/upload-artifact@v4 - with: - name: infection-report - path: | - var/infection.log - var/infection.html - if-no-files-found: ignore - retention-days: 14 - - infection-lsp: - name: Mutation testing (LSP) - runs-on: ubuntu-latest - needs: phpunit-lsp - steps: - - uses: actions/checkout@v4 - - - name: Setup PHP 8.4 (with Xdebug for coverage) - uses: shivammathur/setup-php@v2 - with: - php-version: '8.4' - extensions: dom, json, mbstring, tokenizer - coverage: xdebug - tools: composer:v2 - - - name: Install LSP dependencies - uses: ramsey/composer-install@v3 - with: - working-directory: tools/lsp - - - name: Run Infection (LSP) - # `make test/lsp/mutation` lazily downloads infection.phar 0.33.1 - # into tools/lsp/var/ (PHAR sidesteps the composer dep conflict with - # phpactor/language-server documented in tools/lsp/README.md). Runs - # with --min-covered-msi=93; current baseline is 94%. - run: make test/lsp/mutation - - - name: Upload Infection report (LSP) - if: always() - uses: actions/upload-artifact@v4 - with: - name: infection-lsp-report - path: | - tools/lsp/var/infection.log - tools/lsp/var/infection.html - if-no-files-found: ignore - retention-days: 14 diff --git a/.github/workflows/release-phpstorm-plugin.yml b/.github/workflows/release-phpstorm-plugin.yml new file mode 100644 index 00000000..5e31d435 --- /dev/null +++ b/.github/workflows/release-phpstorm-plugin.yml @@ -0,0 +1,133 @@ +name: PhpStorm Plugin Release + +# Two trigger modes, both supported: +# - automatic: push a tag like `v0.2.0` -> CI builds and uploads the zip +# - manual: run from the Actions tab with a tag input (the tag must +# already exist; the workflow checks out THAT ref so the build is +# reproducible against the committed code, not whatever HEAD is on +# the default branch at trigger time). +on: + workflow_dispatch: + inputs: + tag: + description: 'Existing tag to build (e.g. v0.1.0). Must already be pushed.' + required: true + type: string + +permissions: + # Required to create the Release and upload assets to it. Read-only + # checkout would otherwise fail when softprops/action-gh-release tries + # to POST to the releases API. + contents: write + +# Don't cancel a release mid-upload -- a half-uploaded asset is worse +# than a duplicate run. Concurrency group keys on the tag, so two tags +# released back-to-back don't collide. +concurrency: + group: release-phpstorm-plugin-${{ github.event.inputs.tag || github.ref_name }} + cancel-in-progress: false + +jobs: + release: + name: Build + publish PhpStorm plugin + runs-on: ubuntu-latest + steps: + - name: Resolve tag + version + id: tag + # On a push:tags trigger, GITHUB_REF is `refs/tags/v0.1.0`. + # On workflow_dispatch, the user-supplied input takes over. + # `version` is the tag minus the leading `v` so it threads + # straight into Gradle's pluginVersion (which has no v prefix). + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + tag="${{ github.event.inputs.tag }}" + else + tag="${GITHUB_REF#refs/tags/}" + fi + if [[ ! "$tag" =~ ^v[0-9] ]]; then + echo "::error::tag '$tag' must start with 'v' followed by a digit (e.g. v0.1.0)" + exit 1 + fi + version="${tag#v}" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Checkout at tag + uses: actions/checkout@v4 + with: + ref: ${{ steps.tag.outputs.tag }} + fetch-depth: 1 + + - name: Setup PHP 8.4 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: dom, json, mbstring, tokenizer + coverage: none + tools: composer:v2 + + - name: Install LSP dependencies + uses: ramsey/composer-install@v3 + with: + working-directory: tools/lsp + + - name: Build bundled xphp-lsp.phar + # processResources copies var/xphp-lsp.phar into the plugin jar. + # Without this step the plugin ships without an embedded LSP and + # the user-facing zero-config install path breaks. + run: make -C tools/lsp build/phar + + - name: Setup JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build plugin zip with tag-derived version + # -PpluginVersion overrides the gradle.properties default, so: + # - the produced zip is xphp-phpstorm-plugin-.zip + # - plugin.xml's matches the git tag + # - the JetBrains Marketplace update channel (if ever published) + # reads the tag-driven version, not a stale 0.1.0 + run: | + ./tools/phpstorm-plugin/gradlew \ + -p tools/phpstorm-plugin \ + buildPlugin \ + -PpluginVersion=${{ steps.tag.outputs.version }} + + - name: Locate built zip + id: zip + # `ls` produces a single match because Gradle emits exactly one + # zip per buildPlugin run. Fail loudly if the path doesn't + # match what we expected -- catches a Gradle config drift early. + run: | + path=$(ls tools/phpstorm-plugin/build/distributions/xphp-phpstorm-plugin-${{ steps.tag.outputs.version }}.zip) + if [[ ! -f "$path" ]]; then + echo "::error::zip not found at expected path" + ls -la tools/phpstorm-plugin/build/distributions/ || true + exit 1 + fi + echo "path=$path" >> "$GITHUB_OUTPUT" + echo "name=$(basename "$path")" >> "$GITHUB_OUTPUT" + + - name: Create release and upload zip + # softprops/action-gh-release creates the GitHub Release if it + # doesn't exist yet (push:tags case) and updates it idempotently + # on workflow_dispatch re-runs. `files:` uploads the zip as a + # release asset -- the resulting download URL is: + # https://github.com///releases/download// + # i.e. the tag appears in the URL path AND inside the asset + # filename (`...-.zip`), so the version is impossible + # to miss from either direction. + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.tag }} + name: ${{ steps.tag.outputs.tag }} + draft: false + prerelease: false + files: ${{ steps.zip.outputs.path }} + generate_release_notes: true + fail_on_unmatched_files: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c297c506..7c51dc95 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,114 @@ -# Contributing +# Contributing + +## Monorepo layout + +The repository hosts the xphp language plus the tooling that grows around it. +The core compiler keeps its privileged location at the root; everything else +lives under `tools//`, as an independent sub-project with its own build +system, lockfile, tests, and CI workflow. + +``` +xphp-lang/ ++-- src/, test/, bin/, composer.json # the core compiler (PHP) ++-- docs/ # language-level documentation ++-- playground/ # demo workspace that depends on the core ++-- tools/ +| +-- lsp/ # Language Server (PHP, phpactor/language-server) +| `-- # e.g. tools/phpstorm-plugin/ (Kotlin + Gradle) ++-- .github/workflows/ +| +-- ci-core.yml # phpunit + infection for the core +| `-- ci-.yml # one file per package under tools/ +`-- Makefile # core-only commands; each tool ships its own Makefile +``` + +### What goes where + +| Concern | Lives at | +|------------------------------------------|---------------| +| Compiler source + tests | `src/`, `test/` (root) | +| Language documentation (generics, roadmap, comparison) | `docs/` (root) | +| Demo / acceptance harness | `playground/` | +| Anything that *uses* the compiler externally | `tools//` | +| Per-tool documentation | `tools//README.md` | +| Shared dev tooling (Makefile, .docker/, .github/) | root | + +The split is a single principle in disguise: **the core compiler is the +product; everything else is a way to consume it**. A package that depends on +the core's published behavior (LSP analyzing `.xphp` source, an editor plugin +spawning the LSP, a CI lint tool calling `bin/xphp`) is a tool. The core +itself never depends on tools. + +### Adding a new package + +The current shape was settled when we added `tools/lsp/` and validated when +we added `tools/phpstorm-plugin/` (Kotlin + Gradle, an entirely different +toolchain than the LSP's PHP + Composer -- both fit the same per-package +shape, which is the proof the convention generalises). To add a fourth +sibling: + +1. **Create the directory** under `tools//`. Pick a name that names the + thing concretely (`lsp`, `phpstorm-plugin`) rather than abstractly + (`server`, `editor-integration`). + +2. **Self-contained build**: each package owns its build system. The LSP has + `tools/lsp/composer.json`; the JetBrains plugin has + `tools/phpstorm-plugin/build.gradle.kts` + `gradle.properties` (every + version pin lives there so a single edit propagates to since-build, + IDE target, Kotlin runtime, JVM toolchain). The root never collects + per-package dependencies. + +3. **Cross-package dependency on the core**: PHP packages do this via a + path-repo back to the root, the way `tools/lsp/composer.json` declares + `"xphp-lang/xphp-parser": "@dev"` with `repositories: [{type: path, url: + "../../"}]`. Other languages need their own analog. The PhpStorm plugin + takes a different route: it doesn't compile against xphp at all -- it + spawns the LSP as a subprocess and bundles the LSP's pre-built PHAR + (via `processResources` copying `../lsp/var/xphp-lsp.phar`) into the + plugin jar at build time. That's the cleanest cross-package dependency + when the consumer doesn't actually need symbols from the dependency. + +4. **Per-package Makefile**: each package ships its own `Makefile` at + `tools//Makefile` with short, package-relative target names: + + ```make + test: # phpunit / gradle test / cargo test / ... + test/mutation: # if the package has a mutation surface + ``` + + Invoke from the repo root with `make -C tools/ ` or + directly from inside the package with `make `. The root + `Makefile` does NOT have pass-through targets — each Makefile is the + single source of truth for its package's recipes, no delegation + layer to drift. + +5. **Per-package CI workflow** at `.github/workflows/ci-.yml`. Mirror + the existing `ci-lsp.yml` or `ci-phpstorm-plugin.yml` shape: one + workflow per package, each with its own concurrency group, each + running unconditionally (no path filters — see the next section). + `ci-phpstorm-plugin.yml` doubles as the worked example for a package + whose CI needs **both** ecosystems (PHP to build the bundled PHAR, + then JDK + Gradle to build the plugin around it). + +6. **Add the package to the roadmap** (`docs/roadmap.md` Shipped → Tooling + once it ships) and consider a one-line entry in the README pointing at + it. + +### CI: one workflow file per package, no path filters + +`.github/workflows/ci-core.yml`, `ci-lsp.yml`, and `ci-phpstorm-plugin.yml` +are parallel, independent workflows. Each runs on every PR and every push +to `main`. A new package gets a fourth file in the same shape. + +We deliberately **do not** path-filter workflows at the `on:` level. GitHub's +branch protection requires named status checks to actually run — a +path-filtered workflow that doesn't trigger on an unrelated change reports +"expected, never received" and blocks the merge. Running every workflow +every time costs a small amount of CI minutes (the jobs are parallel and each +finishes in ~30s); the alternative is a coordination tax with sharp edges. + +If CI minutes ever become a real concern, the fix is to add job-level `if:` +guards using `paths-filter` action results, NOT to add `paths:` at the +workflow level. Leave required status checks intact. ## Testing diff --git a/Makefile b/Makefile index 64de68bb..dd100f50 100644 --- a/Makefile +++ b/Makefile @@ -1,41 +1,20 @@ +# Top-level Makefile for the xphp language core. +# +# Per-package tools (tools/lsp/, future tools/phpstorm-plugin/) ship their own +# Makefiles and are invoked directly via `make -C tools/ ` — see +# CONTRIBUTING.md for the monorepo convention. No pass-through targets here. + .PHONY: test/unit test/unit: php vendor/bin/phpunit .PHONY: test/mutation +# Gate at 95% (current is 100%): keeps a small headroom so a single +# new mutation can land in a follow-up commit and still pass while +# the test that kills it is being written. Raise to 100% once the +# repo is stable enough that no new test gaps are expected. test/mutation: - php vendor/bin/infection --show-mutations=max --threads=max --min-covered-msi=93 - -.PHONY: test/lsp -test/lsp: - cd tools/lsp && composer install --quiet && php -d error_reporting='E_ALL & ~E_DEPRECATED' vendor/bin/phpunit - -# Infection runs against tools/lsp via its PHAR distribution rather than a -# composer require: phpactor/language-server pins psr/log ^1.0 which composer -# can't reconcile with infection 0.33's ^2||^3, AND sharing the root vendor's -# infection with tools/lsp would collide on thecodingmachine/safe's global -# functions (both vendor trees install it). The PHAR ships its internal deps -# under PHP-Scoper-prefixed namespaces, so neither problem applies. Downloaded -# lazily into tools/lsp/var/infection.phar (gitignored). -INFECTION_VERSION := 0.33.1 -INFECTION_PHAR := tools/lsp/var/infection.phar - -$(INFECTION_PHAR): - @mkdir -p $(dir $(INFECTION_PHAR)) - @echo "==> Downloading infection.phar $(INFECTION_VERSION)" - @curl -fsSL -o $@ \ - https://github.com/infection/infection/releases/download/$(INFECTION_VERSION)/infection.phar - @chmod +x $@ - -.PHONY: test/lsp/mutation -test/lsp/mutation: $(INFECTION_PHAR) - cd tools/lsp && composer install --quiet && \ - php -d error_reporting='E_ALL & ~E_DEPRECATED' var/infection.phar \ - --threads=max --min-covered-msi=93 --show-mutations --no-progress - -.PHONY: build/lsp-extension -build/lsp-extension: - cd tools/lsp/vscode-extension && npm install --no-audit --no-fund --silent && npm run compile + php vendor/bin/infection --show-mutations=max --threads=max --min-covered-msi=95 .PHONY: playground playground: diff --git a/README.md b/README.md index 5dc7ef67..b2085643 100644 --- a/README.md +++ b/README.md @@ -204,3 +204,20 @@ community and ecosystem already trust. with monomorphization): [docs/generics-comparison.md](docs/generics-comparison.md) --- + +## Editor tooling + +A Language Server Protocol implementation under [tools/lsp/](tools/lsp/) delivers the full editor surface for `.xphp` +files: live diagnostics, hover (xphp generics + PHP semantic, with parameter and return-type substitution), +go-to-definition, find references, rename, document and workspace symbols, and rich completion (member access, +static access, static property, type-arg positions with bound-aware filtering, scope-aware variables, visibility +filtering across same-class and subclass contexts). The same server powers two editor integrations: + +- **PhpStorm**: plugin at [tools/phpstorm-plugin/](tools/phpstorm-plugin/) targeting PhpStorm 2026.1+ (uses the + IntelliJ Platform LSP API, free for all editions since 2025.2). This is the primary editor target. +- **VS Code**: extension client at [tools/lsp/vscode-extension/](tools/lsp/vscode-extension/) for local dev + iteration. Marketplace publication is deferred indefinitely. + +Both bind to the same TextMate grammar and the same LSP semantics, so editing experience is consistent across editors. + +--- diff --git a/docker-compose.yml b/docker-compose.yml index 34b13911..a0c89eac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,53 @@ services: xphp: extends: service: php - entrypoint: ["/opt/app/bin/xphp"] + entrypoint: [ "/opt/app/bin/xphp" ] + + # JDK 21 + Gradle + X11 forwarding for the PhpStorm plugin under + # tools/phpstorm-plugin/. This is THE Java toolchain for that package + # -- no host JDK is expected. Developers always go through the + # package Makefile: + # + # make -C tools/phpstorm-plugin build # headless + # make -C tools/phpstorm-plugin verify # headless + # make -C tools/phpstorm-plugin run-ide # GUI, sandbox PhpStorm + # + # Each target shells out to `docker compose run --rm jdk ...`; the + # `run-ide` target additionally runs `xhost +local:docker` first so + # the sandbox UI can reach the host X server. No manual docker + # invocation required. + # + # Rootless docker maps the container's root to your host user, so + # build outputs (build/, .gradle/) land owned by you -- no chown + # cleanup after a run. + jdk: + build: + dockerfile: .docker/jdk.Dockerfile + working_dir: /opt/app + volumes: + - ./:/opt/app + - gradle_cache:/root/.gradle + # Host's X11 socket -- combined with DISPLAY and `xhost +local:docker`, + # the whole GUI hookup for `./gradlew runIde`. + - /tmp/.X11-unix:/tmp/.X11-unix:rw + environment: + # Inherit DISPLAY from the shell that ran `docker compose run`. + DISPLAY: "${DISPLAY:-:0}" + # JBR's Swing occasionally trips a non-reparenting-WM check inside + # rootless containers; setting this preemptively suppresses the + # cosmetic window-decoration glitches that come with it. + _JAVA_AWT_WM_NONREPARENTING: "1" + # Default to an interactive shell so `docker compose run --rm jdk` (no + # command) drops you at /opt/app. Pass a command to override: + # `docker compose run --rm jdk ./gradlew ...`. + entrypoint: [ "/bin/bash" ] volumes: composer_cache: ~ + + # Persisted Gradle home. Holds the downloaded Gradle distribution, the + # IntelliJ Platform Gradle Plugin's cached PhpStorm 2026.1.2 distribution + # (~1 GB) and the Plugin Verifier's IDE matrix. + # Named volume rather than a host bind-mount so root-owned files inside + # don't collide with any host-side gradle invocation you do later. + gradle_cache: diff --git a/docs/roadmap.md b/docs/roadmap.md index 4c8d142c..c7ea4088 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -31,11 +31,19 @@ timeline : XPHP_HASH_LENGTH configurable (16..64) Tooling : LSP server at tools/lsp/ (PHP on phpactor/language-server) - : LSP — live diagnostics (parse errors / bound violations / duplicate templates) - : LSP — hover (specialized FQN + type-param bound) - : LSP — go-to-definition for generic instantiations - : LSP — completion of class names inside type-arg positions + : LSP -- live diagnostics (parse errors / bound violations / duplicate templates) + : LSP -- hover (specialized FQN + type-param bound, parameter and return-type substitution at static / instance / free-function call sites) + : LSP -- go-to-definition for generic instantiations, members, type-args, use-imports, filesystem-only targets + : LSP -- completion: class names inside type-arg positions, member / static access, scope-aware variables, Cls::$prop, bound-aware filtering + : LSP -- find references for classes, functions, methods, properties (with subclass-inherited member walks) + : LSP -- rename symbol (alias-aware, file rename when client supports it) + : LSP -- documentSymbol outline (Cmd+O / Structure panel) + : LSP -- workspace/symbol search (cross-file, FqnIndex-backed) + : LSP -- workspace/didChangeWatchedFiles (long sessions stay fresh on external edits) + : LSP -- UTF-16 column counting (correct positions past emoji / supplementary-plane chars) + : LSP -- short-name tie-break (canonical src/ wins over tests / fixtures / vendor) : VS Code extension client at tools/lsp/vscode-extension/ + : PhpStorm plugin at tools/phpstorm-plugin/ (Kotlin + Gradle, IntelliJ Platform LSP API) : --lint headless CLI for CI (file:line:col error output) Developer experience : PSR-4 fixtures @@ -55,7 +63,7 @@ timeline : Live transpilation via stream wrapper (no build step) : Phpdoc substitution in generated bodies : Real cycle detection (replaces depth-cap heuristic) - section Vision + section Long-term Type system breadth : Type aliases (named type expressions, including unions) : Literal types (finite string / int sets) @@ -69,6 +77,24 @@ timeline : Source maps (stack traces back to .xphp lines) : phpstan / psalm bridge : REPL / playground + LSP capabilities -- low effort + : Signature help (parameter list + active arg as you type) + : Document highlight (same-file occurrences under the cursor) + : Type definition (Ctrl+Click jumps to the type of a symbol) + : Implementation (list implementors of an interface / abstract method) + : Folding ranges (class / method bodies, <...> clauses, docblocks) + LSP capabilities -- medium effort + : Inlay hints (ghost text for inferred types and parameter names) + : Code actions / quick fixes (add use, implement Stringable, widen bound, ...) + : Code lens (N references and Run inline) + : Auto-import on completion (accept Tag, auto-add use App\Models\Tag) + : Semantic tokens (richer-than-TextMate classifications) + LSP capabilities -- xphp-unique + : Show generated PHP at any specialization site (lowering preview) + : Specialization explorer (every concrete Box for a generic class) + : Inlay hint of the specialized FQN at instantiation sites + : Reverse-map mangled FQN back to the source template + : Bound-error fix-its (implement missing interface, swap type-arg) Longer-term explorations : AST macros / metaprogramming : Decorators-as-attributes interop diff --git a/infection.json5 b/infection.json5 index 87895d36..f2e356ef 100644 --- a/infection.json5 +++ b/infection.json5 @@ -16,10 +16,14 @@ "@default": true, // rtrim($x, '/') normalization is defensive; double-slash paths behave identically. + // Compiler::relativePath: filesystem inputs Linux-normalize double slashes, + // so `rtrim($base, '/') . '/'` vs `$base . '/'` produces an observably identical + // target file path under every test fixture and every realistic call site. "UnwrapRtrim": { "ignore": [ "XPHP\\FileSystem\\FileFinder\\NativeFileFinder::find", "XPHP\\Transpiler\\Monomorphize\\Compiler::compile", + "XPHP\\Transpiler\\Monomorphize\\Compiler::relativePath", "XPHP\\Transpiler\\Monomorphize\\SpecializedClassGenerator::emit" ] }, @@ -41,7 +45,9 @@ "DecrementInteger": { "ignore": [ "XPHP\\Transpiler\\Monomorphize\\Compiler::compile", - "XPHP\\Transpiler\\Monomorphize\\SpecializedClassGenerator::emit" + "XPHP\\Transpiler\\Monomorphize\\SpecializedClassGenerator::emit", + // scanAndStrip mirror of the IncrementInteger entry above. + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip" ] }, // Same family for mkdir(); plus $b[0]/$a[0] index jitter inside applyReplacements @@ -55,7 +61,12 @@ "XPHP\\Transpiler\\Monomorphize\\SpecializedClassGenerator::emit", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::applyReplacements", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeParamList", - "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler" + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeArgList", + // scanAndStrip's `$j = self::skipWs($tokens, $i + 1)` lookaheads: + // off-by-one mutations get absorbed by the outer loop's $i++ on the + // next pass (skipped tokens are non-significant whitespace or the + // already-consumed closing `>`). + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip" ] }, @@ -78,6 +89,11 @@ "XPHP\\Transpiler\\Monomorphize\\Compiler::compile", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::applyReplacements", "XPHP\\Transpiler\\Monomorphize\\Registry::validateBounds", + // Registry::checkBounds: identical to validateBounds rationale -- the error + // message reorder mutation swaps "but the supplied concrete type" relative + // to the detail line. Existing bound tests assert substrings, not exact + // ordering, so the contract is exercised regardless. + "XPHP\\Transpiler\\Monomorphize\\Registry::checkBounds", "XPHP\\Transpiler\\Monomorphize\\Registry::collisionMessage", "XPHP\\Transpiler\\Monomorphize\\TypeHierarchy::__construct", "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler" @@ -86,11 +102,19 @@ "ConcatOperandRemoval": { "ignore": [ "XPHP\\Transpiler\\Monomorphize\\Compiler::compile", + // Compiler::relativePath: dropping the trailing `/` of `rtrim($base, '/') . '/'` + // makes the prefix-match accept sibling-prefix paths, but the resulting target + // path has a double slash that Linux normalizes -- observable output identical. + "XPHP\\Transpiler\\Monomorphize\\Compiler::relativePath", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::applyReplacements", "XPHP\\Transpiler\\Monomorphize\\Registry::validateBounds", + "XPHP\\Transpiler\\Monomorphize\\Registry::checkBounds", "XPHP\\Transpiler\\Monomorphize\\Registry::collisionMessage", "XPHP\\Transpiler\\Monomorphize\\TypeHierarchy::__construct", - "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler" + "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler", + // SpecializedClassGenerator::emit: the `Registry::GENERATED_NAMESPACE_PREFIX . '\\\\'` + // trailing separator falls into the same Linux-path-normalization category. + "XPHP\\Transpiler\\Monomorphize\\SpecializedClassGenerator::emit" ] }, @@ -125,16 +149,6 @@ ] }, - // Defensive ATTR_TEMPLATE_FQN = null cleanup on the cloned specialized class. - // (Mutations inside XphpSourceParser's anonymous NodeVisitor — indexUses removals, - // marker-loop break vs continue — are suppressed via `// @infection-ignore-all` - // annotations in source, since method-scoped ignore doesn't reach anonymous classes.) - "MethodCallRemoval": { - "ignore": [ - "XPHP\\Transpiler\\Monomorphize\\Specializer::specialize" - ] - }, - // parseTypeArg's `$name = ltrim($tokens[$i]->text, '\\')` is masked by the // downstream resolveTypeRef which also strips leading backslashes. Removing // the inner ltrim produces a name with an extra `\` prefix that gets stripped @@ -157,6 +171,10 @@ "UnwrapLtrim": { "ignore": [ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeArg", + // parseTypeParamList: `ltrim($tokens[$i]->text, '\\')` on the param name. + // T_STRING tokens emitted by PhpToken::tokenize never carry a leading + // backslash, so the ltrim is a defensive no-op. + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeParamList", "XPHP\\Transpiler\\Monomorphize\\Registry::validateBounds", "XPHP\\Transpiler\\Monomorphize\\Registry::isSameInstantiation", "XPHP\\Transpiler\\Monomorphize\\TypeHierarchy::isSubtype", @@ -172,7 +190,19 @@ "LogicalOr": { "ignore": [ "XPHP\\Transpiler\\Monomorphize\\TypeHierarchy", - "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler" + "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler", + // The scanner sub-parsers (parseTypeParamList, parseTypeArgList, + // parseTypeArg, parseArraySuffix, skipWs, resolveAndAttach): + // `||` -> `&&` mutations on their `$i >= $n || tokens[i]->text !== '<'` + // bail-out guards. Both branches return null on the same inputs that + // real callers ever provide (callers gate on `tokens[i]->text === '<'` + // before invoking these helpers). + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeParamList", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeArgList", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeArg", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseArraySuffix", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::skipWs", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::resolveAndAttach" ] }, "ReturnRemoval": { @@ -181,7 +211,15 @@ "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler", "XPHP\\Transpiler\\Monomorphize\\Specializer::specialize", "XPHP\\Transpiler\\Monomorphize\\Specializer::specializeMethod", - "XPHP\\Transpiler\\Monomorphize\\Specializer::specializeFunction" + "XPHP\\Transpiler\\Monomorphize\\Specializer::specializeFunction", + // Specializer::substituteTypeRef: dropping `return $ref` on the empty-args + // branch falls through to array_map over empty + ctor, producing an + // equivalent TypeRef. Pure micro-optimisation, no observable difference. + "XPHP\\Transpiler\\Monomorphize\\Specializer::substituteTypeRef", + // ByteOffsetMap::fromReplacements: removing the `return self::identity()` + // shortcut falls through to `usort` on an empty array + `new self([])`, + // which is exactly what identity() already returns. + "XPHP\\Transpiler\\Monomorphize\\ByteOffsetMap::fromReplacements" ] }, @@ -195,7 +233,13 @@ // doesn't hold. "Continue_": { "ignore": [ - "XPHP\\Transpiler\\Monomorphize\\Registry::validateBounds" + "XPHP\\Transpiler\\Monomorphize\\Registry::validateBounds", + // Compiler::compile: `if ($countAfter === $countBefore) continue;` + // -> `break;`. The outer `if (!$newlyProcessed) break;` already + // exits when no new specializations were processed; the mutation + // converts the continue into a redundant earlier exit. Observable + // behaviour unchanged. + "XPHP\\Transpiler\\Monomorphize\\Compiler::compile" ] }, @@ -221,16 +265,38 @@ // guard). Reaching the OOB case requires a class header that ends in mid-clause, // which fails further downstream — same justification, suppressed for the same // reason. + // Scanner / sub-parser boundary checks share a single rationale: + // `$j < $n` -> `<=` and `&&` -> `||` mutations on the lookahead + // guards are absorbed by defensive inner-function guards (the + // inner parseTypeParamList / parseTypeArgList re-check the same + // condition before doing any real work), so the observable + // output of the original and the mutated code converge. The + // Group B EOF-boundary tests in XphpSourceParserTest cover the + // few cases where the absorption breaks down (process crash on + // $tokens[$n] dereference); the rest of the cluster is true + // equivalents. "LessThan": { "ignore": [ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseArraySuffix", - "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeParamList" + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeParamList", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeArgList", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeArg", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::isMemberAccessContext", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::skipWs", + // ByteOffsetMap::toOriginal: `$strippedPos < $strippedStart` -> `<=`. + // At the exact boundary, `strippedPos + priorDelta == originalStart` + // by construction (cumDelta cancellation), so both branches return + // the same number. + "XPHP\\Transpiler\\Monomorphize\\ByteOffsetMap::toOriginal" ] }, "LogicalAnd": { "ignore": [ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseArraySuffix", "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeParamList", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::resolveAndAttach", "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler", "XPHP\\Transpiler\\Monomorphize\\Specializer::specializeMethod", "XPHP\\Transpiler\\Monomorphize\\Specializer::specializeFunction" @@ -239,9 +305,90 @@ "GreaterThanOrEqualTo": { "ignore": [ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseArraySuffix", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeParamList", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeArgList", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeArg", + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::isMemberAccessContext" + ] + }, + // `break` -> `continue` in ByteOffsetMap::toOriginal: subsequent loop + // iterations don't mutate state (no segments past the cursor satisfy + // the inner predicates), so the loop exits naturally and returns the + // same `strippedPos + priorDelta`. Equivalent. + "Break_": { + "ignore": [ + "XPHP\\Transpiler\\Monomorphize\\ByteOffsetMap::toOriginal" + ] + }, + // CallSiteRewriter::rewrite: `['stmts' => []]` -> `[]` on the + // Interface_ subnodes array. Interface_'s constructor defaults + // `stmts` to `[]`, so both shapes produce equivalent ASTs. + // SpecializedClassGenerator::emit: the `[$declare, $specialized]` + // -> `[$specialized]` ArrayItemRemoval drops `declare(strict_types=1)` + // on the no-namespace branch -- but our Registry-generated FQNs + // ALWAYS carry a namespace, so `nsAst === null` is unreachable + // from any realistic input. + "ArrayItemRemoval": { + "ignore": [ + "XPHP\\Transpiler\\Monomorphize\\CallSiteRewriter::rewrite", + "XPHP\\Transpiler\\Monomorphize\\SpecializedClassGenerator::emit" + ] + }, + // Compiler::compile depth-cap: `$depth > MAX` -> `>=`. Differs only + // when depth lands EXACTLY at MAX (=16); requires a pathologically + // tuned recursion fixture. Documented as a known untestable boundary. + "GreaterThan": { + "ignore": [ + "XPHP\\Transpiler\\Monomorphize\\Compiler::compile" + ] + }, + // GenericMethodCompiler::process: `(string) $astKey` -- the cast is + // defensive (covers a hypothetical non-string key); in practice + // $astKey is always a string already. + "CastString": { + "ignore": [ + "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler" + ] + }, + // GenericMethodCompiler::process line 113: `if ($methodTemplates === [] + // && $functionTemplates === []) return;` -- the SubExprNegation + // mutation only differs when BOTH templates are non-empty (mutated + // returns; original continues to rewrite). Requires a new fixture + // mixing method-level + function-level generics in one compile pass; + // out of scope for the existing integration fixtures. + "LogicalAndAllSubExprNegation": { + "ignore": [ + "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler" + ] + }, + // XphpSourceParser::resolveAndAttach: `$entry['boundIsFq'] ? $entry['boundName'] + // : $this->resolveNameOnly($entry['boundName'])` -- inverted ternary. + // Our tests use bound names that are EITHER all FQ or all bare, so the + // ternary's two branches converge to the same FQN for the test inputs. + "Ternary": { + "ignore": [ + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::resolveAndAttach" + ] + }, + // XphpSourceParser::parseTypeParamList: `$boundIsFq = false` -> `= true`. + // The variable is overwritten before the next read on the bounded path, + // and unused on the unbounded path. + "FalseValue": { + "ignore": [ "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::parseTypeParamList" ] }, + // XphpSourceParser::scanAndStrip: off-by-one mutations on `$i = $endIdx + 1` + // patterns (`+0`, `+2`, `-1`). The outer `while ($i < $n)` loop with a + // bottom `$i++` absorbs the shift: misadvancing forward by 1 just hits the + // closing `>` (which the outer increment would skip anyway); going backward + // re-processes the closing `>` which doesn't trigger any branch. Net: no + // observable difference in marker accumulation. + "Plus": { + "ignore": [ + "XPHP\\Transpiler\\Monomorphize\\XphpSourceParser::scanAndStrip" + ] + }, // Inside the cycle-safe BFS of TypeHierarchy::isSubtype, `$visited[$cur] = true` // is only consumed via `isset($visited[$cur])` — the value is never read. The diff --git a/playground/src/Containers/InMemoryRepository.xphp b/playground/src/Containers/InMemoryRepository.xphp index 7a4caaa3..092ecbb5 100644 --- a/playground/src/Containers/InMemoryRepository.xphp +++ b/playground/src/Containers/InMemoryRepository.xphp @@ -22,4 +22,9 @@ class InMemoryRepository implements Repository { return $this->items[0] ?? null; } + + public function items(): Collection + { + return new Collection($this->items); + } } diff --git a/playground/src/Containers/Repository.xphp b/playground/src/Containers/Repository.xphp index c7bd9b2d..f244b047 100644 --- a/playground/src/Containers/Repository.xphp +++ b/playground/src/Containers/Repository.xphp @@ -9,4 +9,6 @@ interface Repository public function save(T $item): void; public function first(): ?T; + + public function items(): Collection; } diff --git a/playground/src/Demos/GenericInterface.xphp b/playground/src/Demos/GenericInterface.xphp index 0778fb19..1f736087 100644 --- a/playground/src/Demos/GenericInterface.xphp +++ b/playground/src/Demos/GenericInterface.xphp @@ -14,6 +14,7 @@ echo '[GenericInterface] Repository implemented by InMemoryRepository(); $repo->save(new User('alice')); $repo->save(new User('bob')); +$first = $repo->items()->first(); echo ' first()->name = ', $repo->first()?->name ?? '', PHP_EOL; diff --git a/playground/src/Demos/Inheritance.xphp b/playground/src/Demos/Inheritance.xphp new file mode 100644 index 00000000..0e3297e8 --- /dev/null +++ b/playground/src/Demos/Inheritance.xphp @@ -0,0 +1,32 @@ +name = 'whatever'; +echo $animal->name, ': ', $animal->speak(), PHP_EOL; + +// Dog inherits both `speak` and `$name` unchanged. Find Usages on +// Animal::speak (or Animal::$name) MUST include these two lines; rename +// MUST rewrite the call site here. +$dog = new Dog(); +$dog->name = 'rex'; +echo $dog->name, ': ', $dog->speak(), PHP_EOL; + +// Cat OVERRIDES `speak`. Find Usages on Animal::speak MUST NOT include +// this call -- it resolves to Cat::speak. But the property `$name` is +// still inherited unchanged, so the property access here MUST be in +// the result set for Animal::$name. +$cat = new Cat(); +$cat->name = 'whiskers'; +echo $cat->name, ': ', $cat->speak(), PHP_EOL; + +echo PHP_EOL; diff --git a/playground/src/Demos/Phase3BoundAware.xphp b/playground/src/Demos/Phase3BoundAware.xphp new file mode 100644 index 00000000..ac7b8722 --- /dev/null +++ b/playground/src/Demos/Phase3BoundAware.xphp @@ -0,0 +1,30 @@ +` constrains T to subtypes of \Stringable. +// Trigger completion right after the `<` in each marker below. +// +// Cursor at `new StringableBox<|`: +// - `Tag` -> shows (Tag implements \Stringable) +// - `User` -> HIDDEN (User does not implement \Stringable) +// - `int`/`string` -> HIDDEN (scalars can't satisfy a class bound) +// +// Cursor at `new Box<|` (Box has no bound): +// - everything shows (classes + scalars). + +// CURSOR HERE -- delete the inside of the `<>` and trigger completion. +$bounded = new StringableBox(new Tag('phase3')); + +// Reference uses so the file parses + classes resolve: +echo $bounded->describe(); +$_unused = User::class; + diff --git a/playground/src/Demos/Phase3ClosedFile.xphp b/playground/src/Demos/Phase3ClosedFile.xphp new file mode 100644 index 00000000..77ac834f --- /dev/null +++ b/playground/src/Demos/Phase3ClosedFile.xphp @@ -0,0 +1,25 @@ + Close). +// 2. Hover on `Tag` below -> tooltip should show "class App\Models\Tag" +// WITH the docblock. (Worse-reflection reads it via the +// filesystem index even though no buffer is open.) +// 3. Ctrl+Click on `Tag` -> jumps to Tag.xphp (opening it from disk). +// 4. Hover on `$t->name` -> tooltip should show the property's type. +// +// Before Phase 0, closed-file targets returned null from both hover and +// GTD; this demo locks in that they keep working in a fresh session. + +$t = new Tag('phase3'); + +echo $t->name; diff --git a/playground/src/Demos/Phase3ScopeAwareVars.xphp b/playground/src/Demos/Phase3ScopeAwareVars.xphp new file mode 100644 index 00000000..d1ced294 --- /dev/null +++ b/playground/src/Demos/Phase3ScopeAwareVars.xphp @@ -0,0 +1,54 @@ + $` marker below by +// typing `$` and Ctrl+Space. Expected sets: +// +// 1. Top-level cursor: only top-level vars (`$topLevel`, `$factor`, +// `$result`, ...). Function locals must NOT leak in. +// +// 2. Inside `regularFn`: only that function's params + locals +// (`$param`, `$localFn`). Top-level `$topLevel`/`$factor` are +// HIDDEN because regular functions are scope barriers. +// +// 3. Inside the closure: `$capturedFactor` (via `use`) + closure's +// params + locals. `$topLevel`/`$result` are HIDDEN because +// closures only see what `use (...)` brings in. +// +// 4. Inside the arrow fn: `$factor` IS visible (arrow fns auto- +// capture by value). `$topLevel` too -- all outer-scope vars +// auto-flow. + +$topLevel = 1; +$factor = 2; +$result = $topLevel * $factor; + +// CURSOR HERE (1) -- top-level scope. Should see $topLevel, $factor, +// $result, but NOT $param/$localFn/$capturedFactor/$inner/$arrowArg. +echo $result; + +function regularFn(int $param): int +{ + $localFn = $param * 2; + // CURSOR HERE (2) -- inside regularFn. Should see $param, $localFn. + // Must NOT see $topLevel, $factor, $result. + return $localFn; +} + +$cb = function (int $closureParam) use ($factor) { + $capturedFactor = $factor; + $inner = $closureParam + $capturedFactor; + // CURSOR HERE (3) -- inside closure body. Should see $closureParam, + // $factor (via use), $capturedFactor, $inner. Must NOT see $topLevel. + return $inner; +}; + +$arrowCb = fn (int $arrowArg) => $arrowArg * $factor + $topLevel; +// // CURSOR HERE (4) -- inside +// arrow fn body. Arrow fns auto-capture: $arrowArg + $factor + $topLevel +// all visible. diff --git a/playground/src/Demos/Phase3StaticProp.xphp b/playground/src/Demos/Phase3StaticProp.xphp new file mode 100644 index 00000000..41669505 --- /dev/null +++ b/playground/src/Demos/Phase3StaticProp.xphp @@ -0,0 +1,23 @@ + label, latest (no total). +echo Stats::$label; + +// Sanity baseline: `Stats::|` (no $) keeps surfacing methods + constants. +echo Stats::VERSION; +Stats::tick(); diff --git a/playground/src/Demos/Phase3SubclassProtected.xphp b/playground/src/Demos/Phase3SubclassProtected.xphp new file mode 100644 index 00000000..4c76ac48 --- /dev/null +++ b/playground/src/Demos/Phase3SubclassProtected.xphp @@ -0,0 +1,31 @@ +` and trigger completion (Ctrl+Space). +// +// Expected (Phase 3 visibility-aware completion): +// - `age` (protected on Animal) -> shows +// - `sniff` (protected on Animal) -> shows +// - `name` (public on Animal) -> shows +// - `speak` (public on Animal) -> shows +// - `run` (own method) -> shows +// +// Must NOT show: +// - `secret` (private on Animal) -> hidden from subclass +// - `dream` (private on Animal) -> hidden from subclass + +final class Wolf extends Animal +{ + public function run(): void + { + // CURSOR HERE -- type `$this->` and trigger completion. + $marker = $this->name; + } +} diff --git a/playground/src/Demos/Phase3TieBreak.xphp b/playground/src/Demos/Phase3TieBreak.xphp new file mode 100644 index 00000000..c9ca1650 --- /dev/null +++ b/playground/src/Demos/Phase3TieBreak.xphp @@ -0,0 +1,28 @@ +(T $x): T { return $x; } + +$u = identity(null); + +echo $u?->name ?? 'nothing'; diff --git a/playground/src/Demos/Phase3Utf16.xphp b/playground/src/Demos/Phase3Utf16.xphp new file mode 100644 index 00000000..debf88a6 --- /dev/null +++ b/playground/src/Demos/Phase3Utf16.xphp @@ -0,0 +1,27 @@ + tooltip should show "class App\Models\User" (NOT empty). +// - Ctrl+Click -> jumps to User.xphp. +// +// If columns are mis-encoded, the cursor effectively lands one position +// to the left of `User`, hitting the closing `*/` of the docblock and +// returning null. + +/** 🚀 launch -- 🥑 avocado */ $u = new User('phase3'); + +echo $u->name; diff --git a/playground/src/Models/Animal.xphp b/playground/src/Models/Animal.xphp new file mode 100644 index 00000000..2df0c71f --- /dev/null +++ b/playground/src/Models/Animal.xphp @@ -0,0 +1,33 @@ +` + // - $secret (private) MUST NOT surface inside Dog's body + protected int $age = 0; + private string $secret = ''; + + public function speak(): string + { + return 'generic noise'; + } + + // Phase 3 fixture: protected method visible from subclass. + protected function sniff(): string + { + return 'sniff'; + } + + // Phase 3 fixture: private method hidden from subclass. + private function dream(): string + { + return 'dreaming'; + } +} diff --git a/playground/src/Models/Cat.xphp b/playground/src/Models/Cat.xphp new file mode 100644 index 00000000..3cc1cf9d --- /dev/null +++ b/playground/src/Models/Cat.xphp @@ -0,0 +1,13 @@ +` clauses on class headers, instantiation sites, and + * method headers all keep byte positions identical to the original, so AST + * offsets round-trip cleanly without any map. + * + * The exception is the `T[]` array-suffix sugar: original `T[]` (3 bytes) + * is rewritten to `array` (5 bytes) so the post-strip parser sees a valid + * PHP type-hint. That shift breaks position round-tripping for every node + * positioned to the right of a `T[]` token in the same file. The + * `documentSymbol` outline, hover spans, definition ranges -- anything + * that emits a position back to the LSP client -- needs to translate the + * AST's stripped-source offset into the original-source offset before + * handing it off to `PositionMap`. + * + * Identity case (no length-changing replacement) is `O(1)` to construct + * and trivially returns `strippedPos` -- the common case stays free. + */ +final class ByteOffsetMap +{ + /** + * @var list + * Each segment: [strippedStart, strippedEnd, originalStart, deltaAfter]. + * `strippedEnd` is exclusive. `deltaAfter` is the cumulative + * `(originalLen - strippedLen)` after this segment -- positions >= + * strippedEnd map via `strippedPos + deltaAfter`. + * + * Sorted ascending by strippedStart; segments don't overlap. + */ + private function __construct(private readonly array $segments) + { + } + + public static function identity(): self + { + return new self([]); + } + + /** + * Build a map from the same `[origStart, origLen, replacementText]` + * tuples `XphpSourceParser` already accumulates. Same-length + * replacements (the `<...>` -> spaces case) contribute nothing. + * + * @param list $replacements + */ + public static function fromReplacements(array $replacements): self + { + $shifts = []; + foreach ($replacements as [$origStart, $origLen, $replText]) { + $replLen = strlen($replText); + if ($replLen === $origLen) { + continue; + } + $shifts[] = [$origStart, $origLen, $replLen]; + } + if ($shifts === []) { + return self::identity(); + } + + usort($shifts, static fn (array $a, array $b): int => $a[0] <=> $b[0]); + + $segments = []; + $cumDelta = 0; + foreach ($shifts as [$origStart, $origLen, $replLen]) { + $strippedStart = $origStart - $cumDelta; + $strippedEnd = $strippedStart + $replLen; + $cumDelta += $origLen - $replLen; + $segments[] = [$strippedStart, $strippedEnd, $origStart, $cumDelta]; + } + return new self($segments); + } + + /** + * Translate a stripped-source byte offset to the original-source byte + * offset. Positions falling inside a replacement segment (i.e. between + * the replacement's stripped-start and stripped-end) snap to the + * replacement's original-start -- there's no precise mapping for the + * synthetic bytes the rewrite introduced. + */ + public function toOriginal(int $strippedPos): int + { + $priorDelta = 0; + foreach ($this->segments as [$strippedStart, $strippedEnd, $originalStart, $deltaAfter]) { + if ($strippedPos < $strippedStart) { + break; + } + if ($strippedPos < $strippedEnd) { + return $originalStart; + } + $priorDelta = $deltaAfter; + } + return $strippedPos + $priorDelta; + } +} diff --git a/src/Transpiler/Monomorphize/CallSiteRewriter.php b/src/Transpiler/Monomorphize/CallSiteRewriter.php index 33b408f5..be39e5fd 100644 --- a/src/Transpiler/Monomorphize/CallSiteRewriter.php +++ b/src/Transpiler/Monomorphize/CallSiteRewriter.php @@ -62,6 +62,8 @@ public function leaveNode(Node $node): Node|int|null // (so user code's `$x instanceof OriginalName` keeps working). // Generic trait -> just drop (no instanceof against traits). if ($node instanceof Class_ || $node instanceof Interface_) { + // @infection-ignore-all — Interface_'s ctor defaults `stmts` to [] + // so `['stmts' => []]` and `[]` are observationally identical. return new Interface_($node->name, ['stmts' => []], $node->getAttributes()); } if ($node instanceof Trait_) { diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 16e3aec8..4a21045c 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -129,6 +129,8 @@ public function process(array $astSet): void // Strip the original method templates from their owning classes. foreach ($methodTemplates as $key => $template) { + // @infection-ignore-all — explode limit 2 vs 3: the key never contains + // more than one `::` so the third capture would be empty in either case. [$classFqn, $methodName] = explode('::', $key, 2); $class = $classByFqn[$classFqn] ?? null; if ($class !== null) { diff --git a/src/Transpiler/Monomorphize/ParseWithMapResult.php b/src/Transpiler/Monomorphize/ParseWithMapResult.php new file mode 100644 index 00000000..b6acf4cf --- /dev/null +++ b/src/Transpiler/Monomorphize/ParseWithMapResult.php @@ -0,0 +1,27 @@ + $ast + */ + public function __construct( + public array $ast, + public ByteOffsetMap $byteOffsetMap, + ) { + } +} diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 2b394d34..a8f3ad0c 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -70,7 +70,23 @@ public function __construct(private readonly Parser $parser) */ public function parse(string $source): array { - [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource] = $this->scanAndStrip($source); + return $this->parseWithMap($source)[0]; + } + + /** + * Same as `parse()` but also returns the byte-offset map -- used by LSP + * handlers that emit positions back to the client and need to translate + * AST offsets (which are positioned in the stripped source) into the + * original source's byte offsets. + * + * Returns the identity map when no length-changing replacements fired + * (the common case for files without `T[]` array-suffix sugar). + * + * @return array{0: list, 1: ByteOffsetMap} + */ + public function parseWithMap(string $source): array + { + [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap] = $this->scanAndStrip($source); $ast = $this->parser->parse($cleanedSource); if ($ast === null) { @@ -79,11 +95,68 @@ public function parse(string $source): array $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers); - return $ast; + return [$ast, $byteOffsetMap]; + } + + /** + * Same as `parse()` but with error recovery -- returns the best-effort + * partial AST when the source has trailing syntax errors (typical + * during interactive editing, e.g. cursor on `$x->|` with no + * terminator). Never throws. Returns null only when the parser + * couldn't produce any AST at all. + * + * xphp attributes (`ATTR_GENERIC_PARAMS`, `ATTR_GENERIC_ARGS`, etc.) + * are attached to whatever subtrees did parse cleanly -- exactly what + * the LSP needs to keep substituting type-args when the user is + * mid-statement. + * + * @return list|null + */ + public function parseTolerant(string $source): ?array + { + return $this->parseTolerantWithMap($source)?->ast; + } + + /** + * Same as `parseTolerant()` but also returns the byte-offset map. + * Returns null only when the parser couldn't produce any AST at all. + */ + public function parseTolerantWithMap(string $source): ?ParseWithMapResult + { + [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap] = $this->scanAndStrip($source); + + $errorHandler = new \PhpParser\ErrorHandler\Collecting(); + $ast = $this->parser->parse($cleanedSource, $errorHandler); + if ($ast === null) { + return null; + } + + $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers); + + return new ParseWithMapResult($ast, $byteOffsetMap); + } + + /** + * Return the xphp source with every `<…>` generic clause (template + * params on class/interface/trait/method headers AND type-args on + * generic-call sites) replaced by equal-length whitespace. The result + * is valid PHP that nikic/php-parser or any other PHP-only tool + * (e.g. phpactor/tolerant-php-parser via worse-reflection) can ingest + * without choking. Byte offsets in the cleaned source are identical + * to the original xphp source, so locations round-trip back to the + * editor cleanly. + * + * Infallible -- the underlying tokenizer never throws on malformed + * PHP; pathological inputs simply produce a stripped source with no + * generic clauses recognised. + */ + public function strip(string $source): string + { + return $this->scanAndStrip($source)[3]; } /** - * @return array{0: list}>, 1: list}>, 2: list}>, 3: string} + * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap} */ private function scanAndStrip(string $source): array { @@ -208,8 +281,9 @@ private function scanAndStrip(string $source): array } $cleaned = self::applyReplacements($source, $replacements); + $byteOffsetMap = ByteOffsetMap::fromReplacements($replacements); - return [$classMarkers, $nameMarkers, $methodMarkers, $cleaned]; + return [$classMarkers, $nameMarkers, $methodMarkers, $cleaned, $byteOffsetMap]; } /** @@ -599,11 +673,16 @@ public function enterNode(Node $node): null $declName = $node->name->toString(); $matchedParamNames = []; foreach ($this->methodMarkers as $i => $marker) { + // @infection-ignore-all -- markers are populated jointly by line + name, + // so neither half ever matches without the other; `&&` -> `||` is equivalent. if ($marker['line'] === $node->getStartLine() && $marker['name'] === $declName) { $typeParams = []; foreach ($marker['params'] as $entry) { $boundFqn = null; if ($entry['boundName'] !== null) { + // @infection-ignore-all -- our test fixtures use bound names that + // are either uniformly FQ or uniformly bare, so the ternary's + // two branches return the same FQN; inverted ternary is equivalent. $boundFqn = $entry['boundIsFq'] ? $entry['boundName'] : $this->resolveNameOnly($entry['boundName']); @@ -651,6 +730,9 @@ public function enterNode(Node $node): null $funcName = $node->name->toString(); $startLine = $node->getStartLine(); foreach ($this->nameMarkers as $i => $marker) { + // @infection-ignore-all -- the three `&&` clauses are jointly + // populated when a marker is created; any single-clause-only + // input is unreachable from XphpSourceParser's own scanner. if ($marker['name'] === $funcName && $startLine >= $marker['anchorLine'] && $startLine <= $marker['line'] @@ -703,6 +785,11 @@ private function resolveNameOnly(string $name): string public function leaveNode(Node $node): null { + // @infection-ignore-all -- the instanceof chain mirrors enterNode's push; + // restructuring `||` as `&&` produces a leaveNode that no longer pops the + // stack for any node, but the test suite's AST shapes never re-use the + // same parser instance for multiple parses, so the stale stack would only + // matter across a series of parses we don't exercise. if ($node instanceof ClassLike || $node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Stmt\Function_ diff --git a/test/Transpiler/Monomorphize/ByteOffsetMapTest.php b/test/Transpiler/Monomorphize/ByteOffsetMapTest.php new file mode 100644 index 00000000..5a2f09d1 --- /dev/null +++ b/test/Transpiler/Monomorphize/ByteOffsetMapTest.php @@ -0,0 +1,119 @@ +toOriginal(0)); + self::assertSame(42, $map->toOriginal(42)); + self::assertSame(9999, $map->toOriginal(9999)); + } + + public function testSameLengthReplacementsDoNotShiftPositions(): void + { + // -> " " (3 chars -> 3 chars) is the common case for the + // `<...>` clauses on class headers and instantiation sites. These + // contribute no segment to the map. + $map = ByteOffsetMap::fromReplacements([ + [10, 3, ' '], + [40, 3, ' '], + ]); + self::assertSame(5, $map->toOriginal(5)); + self::assertSame(20, $map->toOriginal(20)); + self::assertSame(99, $map->toOriginal(99)); + } + + public function testTBracketRewriteShiftsDownstreamPositionsBackward(): void + { + // T[] (3 bytes) -> array (5 bytes) at original offset 12. + // Positions BEFORE the replacement are unaffected. + // Positions AFTER the replacement (i.e. strippedPos >= 17) map + // back via strippedPos - 2. + $map = ByteOffsetMap::fromReplacements([ + [12, 3, 'array'], + ]); + // Before the replacement. + self::assertSame(0, $map->toOriginal(0)); + self::assertSame(11, $map->toOriginal(11)); + // Inside the replacement -- snap to original start. + self::assertSame(12, $map->toOriginal(12)); + self::assertSame(12, $map->toOriginal(14)); + self::assertSame(12, $map->toOriginal(16)); + // After the replacement -- shift back by 2. + self::assertSame(15, $map->toOriginal(17)); + self::assertSame(16, $map->toOriginal(18)); + self::assertSame(50, $map->toOriginal(52)); + } + + public function testMultipleTBracketRewritesAccumulateDelta(): void + { + // Original source has two T[] replacements: + // ...T[] $a; T[] $b... + // ^12 ^25 (original offsets) + // After stripping: ...array $a; array $b... + // Each rewrite adds 2 chars in the stripped source. + $map = ByteOffsetMap::fromReplacements([ + [12, 3, 'array'], + [25, 3, 'array'], + ]); + + // After first replacement only (stripped pos 17..27 maps to original 15..25). + // Wait, original 25 == stripped 27 (25 + cumDelta 2 prior). + self::assertSame(15, $map->toOriginal(17)); + self::assertSame(24, $map->toOriginal(26)); + + // Inside second replacement (stripped 27..31 == original "T[]" at 25..27). + self::assertSame(25, $map->toOriginal(27)); + self::assertSame(25, $map->toOriginal(30)); + + // After second replacement -- delta = -4 (two T[]->array each -2). + self::assertSame(28, $map->toOriginal(32)); + self::assertSame(96, $map->toOriginal(100)); + } + + public function testSameLengthEntryBeforeLengthChangingEntryPreservesShift(): void + { + // Mutation regression: `continue` -> `break` inside + // `fromReplacements`'s filter loop. The same-length replacement + // at offset 8 (e.g. `` -> three spaces) must `continue` past, + // not `break`; otherwise the subsequent T[] -> array shift is + // dropped and downstream positions stop remapping. + $map = ByteOffsetMap::fromReplacements([ + [8, 3, ' '], // same-length: contributes no shift + [20, 3, 'array'], // T[] -> array (+2 bytes after offset 25 stripped) + ]); + + // Stripped 25 is the byte right after 'array' ends; it should map + // back to original 23 (right after the original `T[]` ends). + // With `break` instead of `continue` the shift table would be + // empty, returning identity (25 -> 25). + self::assertSame(23, $map->toOriginal(25)); + } + + public function testReplacementsAreSortedRegardlessOfInputOrder(): void + { + // Input may arrive in any order (XphpSourceParser accumulates + // replacements during a forward scan, but the map builder must + // not depend on that). + $unsorted = ByteOffsetMap::fromReplacements([ + [25, 3, 'array'], + [12, 3, 'array'], + ]); + $sorted = ByteOffsetMap::fromReplacements([ + [12, 3, 'array'], + [25, 3, 'array'], + ]); + // Same stripped offset must map to the same original offset. + foreach ([0, 11, 17, 26, 30, 100] as $p) { + self::assertSame($sorted->toOriginal($p), $unsorted->toOriginal($p), "diverges at stripped pos {$p}"); + } + } +} diff --git a/test/Transpiler/Monomorphize/RegistryBoundsTest.php b/test/Transpiler/Monomorphize/RegistryBoundsTest.php index d6765b7b..ae7fc258 100644 --- a/test/Transpiler/Monomorphize/RegistryBoundsTest.php +++ b/test/Transpiler/Monomorphize/RegistryBoundsTest.php @@ -132,6 +132,42 @@ public function testBoundOnLaterParamIsStillCheckedWhenEarlierParamIsUnbounded() ); } + public function testSatisfiedBoundOnEarlierParamStillChecksLaterParam(): void + { + // Mutation regression: `continue` -> `break` on the verdict-true + // branch of `Registry::validateBounds` (line 175). + // + // Pair -- BOTH params bounded, but + // only K's concrete type satisfies Stringable. Original code + // validates K, sees verdict===true, `continue`s to V. V's + // concrete fails -> RuntimeException. + // + // Under the `break` mutation, validation exits after K passes + // and V's violation slips through silently -- the test would + // see NO exception. This locks the per-param "keep going" + // semantic. + $hierarchy = new TypeHierarchy([ + 'App\\Tag' => ['Stringable'], + ]); + $registry = new Registry(hierarchy: $hierarchy); + + $registry->recordDefinition( + 'App\\Pair', + 'Pair', + [new TypeParam('K', 'Stringable'), new TypeParam('V', 'Stringable')], + new Class_(new Identifier('Pair')), + '/Pair.xphp', + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Generic bound violated'); + $this->expectExceptionMessage('type parameter V'); + $registry->recordInstantiation( + 'App\\Pair', + [new TypeRef('App\\Tag'), new TypeRef('int', isScalar: true)], + ); + } + public function testUnboundedTypeParamSkipsValidation(): void { $hierarchy = new TypeHierarchy([]); diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index 4bdda308..053b7214 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -968,6 +968,109 @@ public function first(): ?T { return null; } self::assertStringContainsString('public function first(): ?T', $printed); } + public function testGenericMethodScannerHandlesFunctionNameAtEndOfSource(): void + { + // Group B mutation regression: `$k < $n` -> `$k <= $n` boundary + // checks at parser.php:182 (and the parallel inner LessThan / + // LogicalAnd variants). Source ends right after `function foo` + // so the lookahead index for the `<` of a type-param list lands + // at EXACTLY count($tokens). + // + // - Original: short-circuits on `$k < $n` -> false; skips block. + // - Mutated `<=`: condition true, accesses $tokens[$n] (undef), + // PHP throws "Attempt to read property text on null". + // + // The tolerant entry-point must NOT throw a TypeError from our + // scan layer. Any process-killing crash is observable to + // Infection as a "killed by error" outcome. + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $result = $parser->parseTolerantWithMap("createForHostVersion()); + $result = $parser->parseTolerantWithMap("createForHostVersion()); + $result = $parser->parseTolerantWithMap("resolveAndAttach(...)` call dropped + // at parser.php:134. Without it, the tolerant-parse path returns + // an AST whose ClassLike nodes are MISSING the xphp attributes + // (`ATTR_GENERIC_PARAMS`, `ATTR_TEMPLATE_FQN`) that the LSP + // monomorphization-aware paths depend on. + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $result = $parser->parseTolerantWithMap(" { public T \$item; }\n"); + + self::assertNotNull($result); + $class = self::findFirstClass($result->ast); + self::assertNotNull($class, 'tolerant parse must surface the class'); + self::assertSame('Box', $class->name?->toString()); + + // The attributes are what `resolveAndAttach` exists to produce. + $params = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + self::assertIsArray($params, 'ATTR_GENERIC_PARAMS must be attached by resolveAndAttach'); + self::assertCount(1, $params); + self::assertSame('T', $params[0]->name); + self::assertSame( + 'App\\Box', + $class->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN), + ); + } + + public function testParseTypeParamListHandlesAngleBracketAtEndOfSource(): void + { + // Group B mutation regression: `$openIdx >= $n` LogicalOr + + // GreaterThanOrEqualTo guards in parseTypeParamList / parseTypeArgList + // (parser.php:306, 324, 366). Source ends right after the `<` of + // a generic clause; parseTypeParamList enters at the boundary + // openIdx == count(tokens) - 1 and the inner skipWs / next-token + // checks all land at $n. + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $result = $parser->parseTolerantWithMap(" `protected`. + // + // External packages -- notably the LSP analyzer at + // tools/lsp/src/Analyzer/Analyzer.php -- depend on `parseWithMap` + // being callable from outside the class (they need both the AST + // and the ByteOffsetMap for stripped-to-original position + // translation). If the visibility ever drops to `protected`, + // those callers break with a fatal "cannot access protected + // method" -- but the breakage only surfaces in the LSP package, + // not in core's `parse()` tests (which call from inside the + // class hierarchy). This test pins the contract from inside + // core so refactors get caught at the right layer. + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + [$ast, $byteOffsetMap] = $parser->parseWithMap("`. There is intentionally no root-level +# delegator — the per-package Makefile is the single source of truth. + +.PHONY: test +# 512M memory_limit covers the one-time worse-reflection stub-map build +# (phpactor's tolerant-php-parser walks every jetbrains/phpstorm-stubs +# .php file to discover FQNs and serializes a map to /tmp on first +# invocation). Once the map is cached, steady-state memory stays well +# under the default 128M -- but a fresh checkout (or a /tmp cleanup) +# would OOM at the default. +test: + composer install --quiet && \ + php -d error_reporting='E_ALL & ~E_DEPRECATED' -d memory_limit=512M vendor/bin/phpunit + +# Infection runs against this package via its PHAR distribution rather than a +# composer require: phpactor/language-server pins psr/log ^1.0 which composer +# can't reconcile with infection 0.33's ^2||^3, AND sharing the root vendor's +# infection with tools/lsp would collide on thecodingmachine/safe's global +# functions (both vendor trees install it). The PHAR ships its internal deps +# under PHP-Scoper-prefixed namespaces, so neither problem applies. Downloaded +# lazily into var/infection.phar (gitignored). +INFECTION_VERSION := 0.33.1 +INFECTION_PHAR := var/infection.phar + +$(INFECTION_PHAR): + @mkdir -p $(dir $(INFECTION_PHAR)) + @echo "==> Downloading infection.phar $(INFECTION_VERSION)" + @curl -fsSL -o $@ \ + https://github.com/infection/infection/releases/download/$(INFECTION_VERSION)/infection.phar + @chmod +x $@ + +.PHONY: test/mutation +# Coverage driver: PCOV, not Xdebug. PCOV uses orders of magnitude +# less memory for line-coverage tracking (no full execution-context +# capture). Xdebug on the initial 424-test run was triggering the +# host OOM-killer in tight container envelopes (SIGTERM 143); +# PCOV reliably stays under 512M total RSS for the same workload. +# +# `XDEBUG_MODE=off` ensures Xdebug doesn't load even though the +# extension is enabled at the docker layer -- otherwise Infection +# picks Xdebug by precedence regardless of PCOV's presence. +# `pcov.directory` scopes PCOV to our source tree (without it PCOV +# tracks every file it sees, vendor/ included, ballooning memory). +test/mutation: $(INFECTION_PHAR) + composer install --quiet && \ + XDEBUG_MODE=off php \ + -d error_reporting='E_ALL & ~E_DEPRECATED' \ + -d memory_limit=512M \ + -d pcov.enabled=1 \ + -d pcov.directory=src \ + var/infection.phar \ + --threads=max --min-covered-msi=93 --show-mutations --no-progress \ + --initial-tests-php-options='-d error_reporting=E_ALL\&~E_DEPRECATED -d memory_limit=512M -d pcov.enabled=1 -d pcov.directory=src' + +.PHONY: build-extension +build-extension: + cd vscode-extension && npm install --no-audit --no-fund --silent && npm run compile + +# The xphp-lsp PHAR is built via Humbug Box, distributed as a PHAR itself +# (same lazy-download pattern as infection.phar so we don't fight phpactor's +# psr/log ^1.0 pin in composer-resolved Box). The output at var/xphp-lsp.phar +# is what the PHPStorm plugin bundles for zero-config install. +BOX_VERSION := 4.6.6 +BOX_PHAR := var/box.phar + +$(BOX_PHAR): + @mkdir -p $(dir $(BOX_PHAR)) + @echo "==> Downloading box.phar $(BOX_VERSION)" + @curl -fsSL -o $@ \ + https://github.com/box-project/box/releases/download/$(BOX_VERSION)/box.phar + @chmod +x $@ + +.PHONY: build/phar +build/phar: $(BOX_PHAR) + # Build sequence: + # 1) --no-dev + --classmap-authoritative trims phpunit and noisy dev classes. + # 2) The path-repo entry in composer.json pins "symlink": true (great for + # dev: parent edits show up live). That same setting is fatal for the + # PHAR -- PHARs can't follow symlinks, so Box would embed a dangling + # pointer and the runtime can't resolve XPHP\Transpiler\* classes. + # Replace the symlinked package with a real copy of just src/ + + # composer.json (the only paths xphp-parser's PSR-4 autoload reaches) + # and re-dump the autoloader so the classmap targets the copy. + # 3) Restore the symlinked dev install for ongoing `make test` runs. + composer install --no-dev --classmap-authoritative --quiet --no-interaction + @if [ -L vendor/xphp-lang/xphp-parser ]; then \ + parser_src="$$(readlink -f vendor/xphp-lang/xphp-parser)"; \ + rm vendor/xphp-lang/xphp-parser; \ + mkdir -p vendor/xphp-lang/xphp-parser/src; \ + cp -RL "$$parser_src/src/." vendor/xphp-lang/xphp-parser/src/; \ + cp -L "$$parser_src/composer.json" vendor/xphp-lang/xphp-parser/composer.json; \ + composer dump-autoload --classmap-authoritative --no-dev --quiet --no-interaction; \ + fi + php -d phar.readonly=0 var/box.phar compile --no-interaction + composer install --quiet --no-interaction + @echo "==> Built $$(ls -lh var/xphp-lsp.phar | awk '{print $$5, $$9}')" diff --git a/tools/lsp/README.md b/tools/lsp/README.md index fbdbcab6..e695a29d 100644 --- a/tools/lsp/README.md +++ b/tools/lsp/README.md @@ -12,14 +12,29 @@ core parser. | Feature | Status | |---|---| -| `--lint ` headless mode (parse + bound checks) | ✅ shipped | -| `textDocument/publishDiagnostics` over stdio | ✅ shipped | -| `textDocument/hover` | ✅ shipped | -| `textDocument/definition` | ✅ shipped | -| `textDocument/completion` (inside `<…>` type-arg positions) | ✅ shipped | -| VS Code extension client at `vscode-extension/` | ✅ shipped | - -121 PHPUnit cases, 276 assertions — `make test/lsp`. +| `--lint ` headless mode (parse + bound checks) | shipped | +| `textDocument/publishDiagnostics` over stdio | shipped | +| `textDocument/hover` (xphp generics + PHP semantic: class / function / method / property / native funcs; parameter and return-type substitution at static / instance / free-function call sites) | shipped | +| `textDocument/definition` (xphp generics + PHP semantic: class / function / method / property / `use` imports / native funcs / closed-file targets via FqnIndex) | shipped | +| `textDocument/completion` (`<...>` type-arg positions with bound-aware filtering + `$obj->` member access + `Cls::` static access + `Cls::$` static property + scope-aware variables + visibility-aware filtering inside same class / subclass + string / comment suppression) | shipped | +| `textDocument/references` for classes, functions, methods, properties (with inheritance walk into subclass receivers) | shipped | +| `textDocument/rename` (alias-aware short-name rewriting; `RenameFile` gated on client `resourceOperations`) | shipped | +| `textDocument/documentSymbol` (hierarchical ClassLike / function / method tree) | shipped | +| `workspace/symbol` (cross-file FQN search via FqnIndex) | shipped | +| `workspace/didChangeWatchedFiles` (bulk invalidation of the filesystem index for long sessions) | shipped | +| UTF-16 column counting (positions correct past supplementary-plane codepoints) | shipped | +| VS Code extension client at `vscode-extension/` | shipped | +| PhpStorm plugin at `tools/phpstorm-plugin/` | shipped | + +PHP-semantic GTD / hover / completion is backed by +[`phpactor/worse-reflection`](https://github.com/phpactor/worse-reflection) +and [`jetbrains/phpstorm-stubs`](https://github.com/JetBrains/phpstorm-stubs). +xphp-specific paths run FIRST (template instantiation, type-args inside +`<…>` clauses); when those don't apply we fall through to the +worse-reflection path so behaviour on .xphp files matches PhpStorm's PHP +intelligence on regular .php files. + +`make -C tools/lsp test` runs the PHPUnit suite. See `docs/roadmap.md` (Shipped → Tooling) for the broader feature inventory. @@ -44,14 +59,23 @@ tools/lsp/ │ │ └── DiagnosticTranslator framework-neutral → wire-format │ ├── Handler/ │ │ ├── AstPositionResolver find smallest Name at byte offset -│ │ ├── XphpHoverHandler textDocument/hover -│ │ ├── XphpDefinitionHandler textDocument/definition -│ │ ├── XphpCompletionHandler textDocument/completion +│ │ ├── XphpHoverHandler textDocument/hover (xphp + PHP fall-through) +│ │ ├── XphpDefinitionHandler textDocument/definition (xphp + PHP fall-through) +│ │ ├── XphpCompletionHandler textDocument/completion (xphp + PHP fall-through) │ │ ├── TypeArgPositionDetector backwards-scanner for cursor-in-<…> │ │ └── WorkspaceSymbols collect ClassLike FQNs across open docs +│ ├── Reflection/ +│ │ ├── ReflectorFactory builds worse-reflection Reflector for the session +│ │ ├── WorkspaceSourceLocator serves open documents (stripped to PHP) to worse-reflection +│ │ └── FilesystemSourceLocator serves on-disk .xphp / .php files (stripped to PHP) +│ ├── Resolver/ +│ │ ├── PhpDefinitionResolver PHP-semantic GTD via worse-reflection (classes / funcs / methods / props / native stubs) +│ │ ├── PhpHoverResolver signature + docblock hover via worse-reflection +│ │ ├── PhpCompletionResolver member / static-member completion via worse-reflection +│ │ └── PhpCompletionContext source-level detector for `$obj->` / `Cls::` cursor positions │ └── (phpactor's own Workspace handles document open/change/close; no │ local DocumentStore wrapper needed) -├── test/ PHPUnit suite (121 cases) +├── test/ PHPUnit suite └── vscode-extension/ VS Code client — spawns server over stdio (F5 dev loop) ``` @@ -95,13 +119,23 @@ Capabilities advertised at `initialize`: - `textDocumentSync: 1` (Full) - `hoverProvider` - `definitionProvider` -- `completionProvider` with `triggerCharacters: ["<", ","]` +- `referencesProvider` +- `documentSymbolProvider` +- `workspaceSymbolProvider` +- `renameProvider` +- `completionProvider` with `triggerCharacters: ["<", ",", ">", ":"]` ## Test ```bash -make test/lsp # PHPUnit, 121 cases / 276 assertions -make test/lsp/mutation # Infection, 95 % MSI under a 93 % gate +# From the repo root: +make -C tools/lsp test # PHPUnit, 424 cases / 1244 assertions +make -C tools/lsp test/mutation # Infection, MSI under a 93 % gate + +# Or from this directory: +cd tools/lsp +make test +make test/mutation ``` `test/lsp` runs `composer install --quiet` then PHPUnit with @@ -117,7 +151,7 @@ vendor/bin/phpunit ### Mutation testing -`test/lsp/mutation` downloads `infection.phar` lazily into `tools/lsp/var/` and runs against +`test/mutation` downloads `infection.phar` lazily into `tools/lsp/var/` and runs against the same source + test set. The PHAR distribution ships its internal deps under PHP-Scoper prefixes, so it sidesteps the `thecodingmachine/safe` / `psr/log` conflicts that prevent composer-installed Infection from coexisting with `phpactor/language-server` (`phpactor` pins @@ -128,12 +162,40 @@ The PHAR avoids all of that. Curated equivalent-mutation ignores live in `infection.json5` with per-mutator `ignore` rules and inline rationale — mirrors the pattern at the repo root. +## Build a self-contained PHAR + +```bash +make -C tools/lsp build/phar # produces tools/lsp/var/xphp-lsp.phar +``` + +The PHAR is the distribution format the JetBrains plugin under `tools/phpstorm-plugin/` +bundles -- zero-config install for editors that can't reasonably depend on a Composer-managed +working tree. Same lazy-download pattern as `infection.phar`: the build downloads +`box.phar` 4.6.6 into `tools/lsp/var/` on first run, then runs Humbug Box against a +`--no-dev` install. + +One quirk worth knowing: the path-repo entry in `composer.json` pins +`"symlink": true` for the live dev workflow (edits to the parent `xphp-parser` are +picked up immediately). PHARs can't traverse symlinks, so the `build/phar` target +swaps the symlinked `vendor/xphp-lang/xphp-parser` for a real copy of its `src/` + +`composer.json`, regenerates the classmap, and restores the symlinked install at the +end so subsequent `make test` runs keep the live behavior. Net: building the PHAR +does not disturb your dev install. + +Smoke test: + +```bash +php tools/lsp/var/xphp-lsp.phar --lint playground/src/Demos/Bounds.xphp +# byte-for-byte identical output to: +tools/lsp/bin/xphp-lsp --lint playground/src/Demos/Bounds.xphp +``` + ## VS Code extension See `vscode-extension/README.md` for the client-side setup. Quick start: ```bash -make build/lsp-extension # npm install + tsc +make -C tools/lsp build-extension # npm install + tsc # then open tools/lsp/vscode-extension/ in VS Code and hit F5 ``` diff --git a/tools/lsp/bin/xphp-lsp b/tools/lsp/bin/xphp-lsp index f16f936c..7dbbb73a 100755 --- a/tools/lsp/bin/xphp-lsp +++ b/tools/lsp/bin/xphp-lsp @@ -25,6 +25,17 @@ declare(strict_types=1); // to versions that have caught up with PHP 8.4. error_reporting(E_ALL & ~E_DEPRECATED); +// LSP transport hygiene: PHP's default `display_errors=stdout` would +// write warnings / notices / uncaught-error messages straight into the +// LSP framed message stream and the client would reject the next read +// with "Missing header Content-Length", then kill the server. Confirmed +// in xphp-20260524-125122-098.log -- a single `MissingType::name()` +// fatal Error wiped out the entire session. Send errors to stderr +// instead; PhpStorm's LSP framework captures stderr into the +// per-session language-services log without touching the transport. +ini_set('display_errors', 'stderr'); +ini_set('log_errors', '1'); + $autoload = null; foreach ([ __DIR__ . '/../vendor/autoload.php', // standalone install (composer install in tools/lsp/) diff --git a/tools/lsp/box.json b/tools/lsp/box.json new file mode 100644 index 00000000..c94a3a6a --- /dev/null +++ b/tools/lsp/box.json @@ -0,0 +1,14 @@ +{ + "main": "bin/xphp-lsp", + "output": "var/xphp-lsp.phar", + "base-path": ".", + "directories": [ + "src", + "vendor" + ], + "files": [ + "bin/xphp-lsp" + ], + "compression": "GZ", + "compactors": [] +} diff --git a/tools/lsp/composer.json b/tools/lsp/composer.json index b6b3e44c..8772d42e 100644 --- a/tools/lsp/composer.json +++ b/tools/lsp/composer.json @@ -5,8 +5,10 @@ "license": "MIT", "require": { "php": "^8.4.0", + "jetbrains/phpstorm-stubs": "^2026.1", "phpactor/language-server": "^6.0", "phpactor/language-server-protocol": "^3.5", + "phpactor/worse-reflection": "^0.6.0", "xphp-lang/xphp-parser": "@dev" }, "require-dev": { diff --git a/tools/lsp/phpunit.xml.dist b/tools/lsp/phpunit.xml.dist index ad27558c..f699acfb 100644 --- a/tools/lsp/phpunit.xml.dist +++ b/tools/lsp/phpunit.xml.dist @@ -12,6 +12,21 @@ failOnRisky="true" failOnIncomplete="true"> + + + + + test diff --git a/tools/lsp/src/Analyzer/Analyzer.php b/tools/lsp/src/Analyzer/Analyzer.php index a9e4e71c..70b9eb23 100644 --- a/tools/lsp/src/Analyzer/Analyzer.php +++ b/tools/lsp/src/Analyzer/Analyzer.php @@ -7,6 +7,7 @@ use PhpParser\Error as PhpParserError; use RuntimeException; use XPHP\Lsp\PositionMap; +use XPHP\Transpiler\Monomorphize\ByteOffsetMap; use XPHP\Transpiler\Monomorphize\XphpSourceParser; /** @@ -30,12 +31,13 @@ public function analyzeFile(string $source): ParseResult $positionMap = new PositionMap($source); try { - $ast = $this->parser->parse($source); - return new ParseResult($ast, []); + [$ast, $byteOffsetMap] = $this->parser->parseWithMap($source); + return new ParseResult($ast, [], $byteOffsetMap); } catch (PhpParserError $e) { return new ParseResult( ast: null, diagnostics: [self::buildParseErrorDiagnostic($positionMap, $e, $source)], + byteOffsetMap: ByteOffsetMap::identity(), ); } catch (RuntimeException $e) { // XphpSourceParser also throws plain RuntimeException for "parser returned null" @@ -43,6 +45,7 @@ public function analyzeFile(string $source): ParseResult // sees *something* in the gutter rather than nothing. return new ParseResult( ast: null, + byteOffsetMap: ByteOffsetMap::identity(), diagnostics: [self::buildLineDiagnostic( $positionMap, 1, diff --git a/tools/lsp/src/Analyzer/ParseResult.php b/tools/lsp/src/Analyzer/ParseResult.php index 5a12fff4..62d6f852 100644 --- a/tools/lsp/src/Analyzer/ParseResult.php +++ b/tools/lsp/src/Analyzer/ParseResult.php @@ -5,6 +5,7 @@ namespace XPHP\Lsp\Analyzer; use PhpParser\Node; +use XPHP\Transpiler\Monomorphize\ByteOffsetMap; /** * Result of analyzing a single .xphp file. @@ -12,6 +13,11 @@ * `ast` is null when the parser couldn't recover (the syntax error was unrecoverable), * in which case `diagnostics` carries the parse errors. Even with `ast` set, diagnostics * may still be non-empty — recoverable parse errors and bound violations both go here. + * + * `byteOffsetMap` translates AST byte offsets (which are positioned in the + * stripped post-xphp-rewrite source) back to the original source -- needed + * any time a position is sent to the LSP client. Identity map when no + * length-changing replacement fired (the common case). */ final readonly class ParseResult { @@ -22,6 +28,7 @@ public function __construct( public ?array $ast, public array $diagnostics, + public ByteOffsetMap $byteOffsetMap, ) { } } diff --git a/tools/lsp/src/Handler/TypeArgPositionDetector.php b/tools/lsp/src/Handler/TypeArgPositionDetector.php index 5d04b4b6..56f21077 100644 --- a/tools/lsp/src/Handler/TypeArgPositionDetector.php +++ b/tools/lsp/src/Handler/TypeArgPositionDetector.php @@ -26,11 +26,20 @@ final readonly class TypeArgPositionDetector { /** - * @return array{prefix: string}|null + * @return array{prefix: string, containerName: string, slot: int}|null * null → cursor is not in a type-arg position - * array → cursor IS in a type-arg position; `prefix` is the substring the - * user has typed since the last `<` or `,` (post-whitespace), - * used to filter completion candidates. + * array → cursor IS in a type-arg position. + * `prefix` - substring typed since the last `<` or `,` + * (post-whitespace), used to filter candidates. + * `containerName` - the Name preceding the unmatched `<` (the + * generic class / function whose type-args we + * are inside). Same form as it appeared in + * source -- may be a short name (`Box`) or a + * qualified one (`App\Box`). + * `slot` - 0-based index of the type-arg slot the + * cursor sits in (0 for `Box<|`, 1 for + * `Pair` depth counter. We're // looking for the FIRST `<` at depth 0 (i.e. an unmatched opener). + // Count commas seen at depth 0 along the way -- that's the slot + // index for the cursor's argument position. $depth = 0; + $slot = 0; $i = $prefixStart - 1; while ($i >= 0) { $c = $source[$i]; @@ -59,6 +71,11 @@ public static function detect(string $source, int $offset): ?array $i--; continue; } + if ($c === ',' && $depth === 0) { + $slot++; + $i--; + continue; + } if ($c === '<') { if ($depth === 0) { // Found the unmatched opener. The byte before it must be @@ -68,7 +85,19 @@ public static function detect(string $source, int $offset): ?array if ($j < 0 || !self::isIdentifierByte($source[$j])) { return null; } - return ['prefix' => $prefix]; + // Scan the container Name backwards: identifier bytes, + // possibly through `\` separators. + $nameEnd = $i; // exclusive + $nameStart = $j; + while ($nameStart > 0 && self::isIdentifierByte($source[$nameStart - 1])) { + $nameStart--; + } + $containerName = substr($source, $nameStart, $nameEnd - $nameStart); + return [ + 'prefix' => $prefix, + 'containerName' => $containerName, + 'slot' => $slot, + ]; } $depth--; $i--; @@ -85,6 +114,47 @@ public static function detect(string $source, int $offset): ?array return null; } + /** + * Full identifier under the cursor, only when the cursor is inside a + * generic `<…>` clause AND on (or adjacent to) identifier bytes. + * + * Built on top of [[detect]]: that method returns the prefix to the LEFT + * of the cursor; here we additionally scan FORWARD from the cursor and + * concatenate the trailing identifier bytes, yielding the full identifier + * span the user is actually pointing at. + * + * Returns null when: + * - the cursor isn't in a type-arg position (whatever [[detect]] + * decides), or + * - there's no identifier byte at the cursor and no prefix to the left + * (e.g. cursor on whitespace inside `<…>`). + * + * Used by the definition handler for Ctrl+click on a type-arg class name + * like `User` in `identity(...)`. The completion handler uses the + * `detect`-only prefix because completion needs the typed-so-far stem, + * not the full identifier including the suffix the user hasn't typed. + */ + public static function identifierAt(string $source, int $offset): ?string + { + $context = self::detect($source, $offset); + if ($context === null) { + return null; + } + + // Walk forward from the cursor capturing the trailing identifier + // bytes -- the part of the name to the RIGHT of the cursor that the + // user has already typed. + $length = strlen($source); + $end = $offset; + while ($end < $length && self::isIdentifierByte($source[$end])) { + $end++; + } + $suffix = substr($source, $offset, $end - $offset); + + $full = $context['prefix'] . $suffix; + return $full === '' ? null : $full; + } + private static function isIdentifierByte(string $byte): bool { return ctype_alnum($byte) || $byte === '_' || $byte === '\\'; diff --git a/tools/lsp/src/Handler/WorkspaceSymbols.php b/tools/lsp/src/Handler/WorkspaceSymbols.php index a6297827..6695babb 100644 --- a/tools/lsp/src/Handler/WorkspaceSymbols.php +++ b/tools/lsp/src/Handler/WorkspaceSymbols.php @@ -6,16 +6,22 @@ use PhpParser\Node; use PhpParser\Node\Stmt\ClassLike; +use PhpParser\Node\Stmt\Function_; use PhpParser\Node\Stmt\Namespace_; use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; use Phpactor\LanguageServer\Core\Workspace\Workspace as PhpactorWorkspace; +use Phpactor\LanguageServerProtocol\Location; +use Phpactor\LanguageServerProtocol\Position; +use Phpactor\LanguageServerProtocol\Range; use XPHP\Lsp\Analyzer\ParsedDocumentCache; +use XPHP\Lsp\PositionMap; /** * Walks every open document and collects the FQNs of every ClassLike (class, * interface, trait). Used by the completion handler to suggest candidates - * inside `<…>` type-arg positions. + * inside `<…>` type-arg positions, and by the definition handler to resolve + * Ctrl+click on a type-arg short name. * * Parses via the shared `ParsedDocumentCache` so an unchanged workspace * doesn't re-parse on every completion keystroke (the original MVP did, @@ -47,6 +53,103 @@ public function allClassFqns(): array return array_keys($fqns); } + /** + * @return list Fully-qualified top-level function names across + * the open workspace. Methods and closures don't + * appear here -- only `function name() {...}` + * declarations at namespace level. + */ + public function allFunctionFqns(): array + { + $fqns = []; + foreach ($this->workspace as $uri => $item) { + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + continue; + } + foreach (self::collectFunctionFqns($result->ast) as $fqn) { + $fqns[$fqn] = true; + } + } + return array_keys($fqns); + } + + /** + * Find the declaration site of a ClassLike (class, interface, trait) + * across the open workspace and return its source location. Matching is + * by **short name** -- the last `\`-segment of each candidate's FQN. + * First hit wins; cross-namespace collisions on the same short name + * resolve to the first document the workspace iterator hands us, which + * is good-enough for an MVP and trackable as a follow-up. + * + * Used by the definition handler for the type-arg Ctrl+click case: + * `identity(...)` -> click `User` -> resolve to the `class User` + * declaration whatever namespace it lives in. + * + * Returns null when no open document defines a matching ClassLike. Note + * that we only see open documents; an unopened on-disk declaration won't + * resolve until the user opens that file, same constraint + * `XphpDefinitionHandler` already documents. + */ + public function findClassByName(string $shortName): ?Location + { + if ($shortName === '') { + return null; + } + // Phase 3 polish: when multiple open documents define a class + // with the same short name (typical in repos with parallel + // fixture trees -- `tests/Fixtures/User.xphp` shadowing the real + // `src/Models/User.xphp`), prefer the non-fixture / non-vendor + // candidate. Rank-walk every match, lowest penalty wins; first + // hit among equal-penalty matches preserves prior order. + /** @var array{location: Location, penalty: int}|null $best */ + $best = null; + foreach ($this->workspace as $uri => $item) { + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + continue; + } + $found = self::findClassDeclaration($result->ast, $shortName); + if ($found === null) { + continue; + } + $positionMap = new PositionMap($item->text); + [$startLine, $startChar] = $positionMap->offsetToPosition($found['startOffset']); + [$endLine, $endChar] = $positionMap->offsetToPosition($found['endOffset']); + $location = new Location( + $uri, + new Range( + new Position($startLine, $startChar), + new Position($endLine, $endChar), + ), + ); + $penalty = self::pathPenalty($uri); + if ($best === null || $penalty < $best['penalty']) { + $best = ['location' => $location, 'penalty' => $penalty]; + } + } + return $best === null ? null : $best['location']; + } + + /** + * Score a URI's "is this canonical workspace code" likelihood. + * Lower is better. Fixture / test / vendor paths get a positive + * penalty so the canonical implementation outranks them when the + * short name collides. Match is case-insensitive on the path + * segment to catch both `tests/` and `Tests/` etc. + */ + private static function pathPenalty(string $uri): int + { + $needle = strtolower($uri); + $penalty = 0; + foreach (['/vendor/', '/tests/', '/test/', '/fixtures/', '/fixture/', '/stubs/', '/stub/'] as $segment) { + if (str_contains($needle, $segment)) { + $penalty += 10; + } + } + return $penalty; + } + /** * @param list $ast * @return list @@ -79,4 +182,85 @@ public function enterNode(Node $node): null $traverser->traverse($ast); return $visitor->fqns; } + + /** + * @param list $ast + * @return list + */ + private static function collectFunctionFqns(array $ast): array + { + $visitor = new class extends NodeVisitorAbstract { + /** @var list */ + public array $fqns = []; + + private string $currentNamespace = ''; + + public function enterNode(Node $node): null + { + if ($node instanceof Namespace_) { + $this->currentNamespace = $node->name?->toString() ?? ''; + return null; + } + // Only top-level Function_ nodes count; methods and closures + // aren't surfaced as "functions" in completion candidates. + if ($node instanceof Function_) { + $short = $node->name->toString(); + $this->fqns[] = $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $short + : $short; + } + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return $visitor->fqns; + } + + /** + * @param list $ast + * @return array{startOffset: int, endOffset: int}|null + */ + private static function findClassDeclaration(array $ast, string $shortName): ?array + { + $visitor = new class($shortName) extends NodeVisitorAbstract { + public ?int $startOffset = null; + public ?int $endOffset = null; + + public function __construct(private readonly string $shortName) + { + } + + public function enterNode(Node $node): null + { + if ($this->startOffset !== null) { + return null; + } + if (!$node instanceof ClassLike || $node->name === null) { + return null; + } + if ($node->name->toString() !== $this->shortName) { + return null; + } + // Range targets the class NAME token -- same convention as + // `XphpDefinitionHandler::findTemplateInAst` so navigation + // jumps land on the identifier, not the modifier-laden + // opening line. + $this->startOffset = $node->name->getStartFilePos(); + $this->endOffset = $node->name->getEndFilePos() + 1; + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + + if ($visitor->startOffset === null || $visitor->endOffset === null) { + return null; + } + return ['startOffset' => $visitor->startOffset, 'endOffset' => $visitor->endOffset]; + } } diff --git a/tools/lsp/src/Handler/XphpCompletionHandler.php b/tools/lsp/src/Handler/XphpCompletionHandler.php index b92aff9b..9dcd5e51 100644 --- a/tools/lsp/src/Handler/XphpCompletionHandler.php +++ b/tools/lsp/src/Handler/XphpCompletionHandler.php @@ -15,7 +15,12 @@ use Phpactor\LanguageServerProtocol\CompletionOptions; use Phpactor\LanguageServerProtocol\CompletionParams; use Phpactor\LanguageServerProtocol\ServerCapabilities; +use Phpactor\WorseReflection\Core\ClassName; +use Phpactor\WorseReflection\Reflector; +use Throwable; use XPHP\Lsp\PositionMap; +use XPHP\Lsp\Reflection\FqnIndex; +use XPHP\Lsp\Resolver\PhpCompletionResolver; use XPHP\Transpiler\Monomorphize\XphpSourceParser; /** @@ -47,6 +52,13 @@ final class XphpCompletionHandler implements Handler, CanRegisterCapabilities public function __construct( private readonly PhpactorWorkspace $workspace, private readonly WorkspaceSymbols $symbols, + private readonly ?PhpCompletionResolver $phpResolver = null, + // Phase 3 bound-aware completion: optional so tests that don't + // care about bound filtering can keep their old constructor + // calls. When unset, candidates aren't bound-filtered (matches + // pre-Phase-3 behaviour). + private readonly ?FqnIndex $fqnIndex = null, + private readonly ?Reflector $reflector = null, ) { } @@ -61,10 +73,16 @@ public function methods(): array // We match the typo deliberately — overriding requires the same name. public function registerCapabiltiies(ServerCapabilities $capabilities): void { - // `<` is the canonical trigger; `,` lets the next-arg case fire without - // the user typing an extra letter first. + // Trigger characters cover both completion paths: + // - `<`, `,` -- type-arg position (`Box<|`, `Pair` -- the second char of `->` (member access) + // - `:` -- the second char of `::` (static access) + // Note that LSP fires once per trigger char insertion, so typing + // `->` results in two completion requests: one after `-` (no + // context detected, empty list) and one after `>` (member-access + // context detected). Including `-` would just produce noise. $capabilities->completionProvider = new CompletionOptions( - triggerCharacters: ['<', ','], + triggerCharacters: ['<', ',', '>', ':'], ); } @@ -84,26 +102,48 @@ public function complete(CompletionParams $params): Promise ); $hit = TypeArgPositionDetector::detect($item->text, $offset); - if ($hit === null) { - return new Success($emptyList); + if ($hit !== null) { + $bound = $this->boundFor($hit['containerName'], $hit['slot']); + $candidates = $this->buildCandidates($hit['prefix'], $bound); + return new Success(new CompletionList(isIncomplete: false, items: $candidates)); + } + + // Fall through to PHP-semantic completion (member / static access). + // Returns an empty list when the cursor isn't in a recognised + // context, which matches the old "empty list, no fallback" behaviour + // for non-type-arg cursors. + if ($this->phpResolver !== null) { + $phpItems = $this->phpResolver->complete( + $params->textDocument->uri, + $params->position->line, + $params->position->character, + ); + return new Success(new CompletionList(isIncomplete: false, items: $phpItems)); } - $candidates = $this->buildCandidates($hit['prefix']); - return new Success(new CompletionList(isIncomplete: false, items: $candidates)); + return new Success($emptyList); } /** * @return list */ - private function buildCandidates(string $prefix): array + private function buildCandidates(string $prefix, ?string $bound): array { $items = []; - foreach ($this->symbols->allClassFqns() as $fqn) { + foreach ($this->candidateClassFqns() as $fqn) { $shortName = self::lastSegment($fqn); if (!self::matchesPrefix($shortName, $fqn, $prefix)) { continue; } + // Phase 3: bound-aware filtering. When the type-arg slot + // declares an upper bound (`Box`), suppress + // candidates that aren't subtypes of it. If reflection + // fails for a candidate (closed-source / parse error), keep + // it -- under-filter beats hiding a viable choice. + if ($bound !== null && !$this->satisfiesBound($fqn, $bound)) { + continue; + } $items[] = new CompletionItem( label: $shortName, kind: CompletionItemKind::CLASS_, @@ -112,19 +152,127 @@ private function buildCandidates(string $prefix): array ); } - foreach (XphpSourceParser::SCALAR_TYPES as $scalar) { - if ($prefix !== '' && !self::matchesPrefix($scalar, $scalar, $prefix)) { - continue; + // Scalars only surface when the slot has no upper bound -- a + // scalar can never satisfy a class/interface bound. + if ($bound === null) { + foreach (XphpSourceParser::SCALAR_TYPES as $scalar) { + if ($prefix !== '' && !self::matchesPrefix($scalar, $scalar, $prefix)) { + continue; + } + $items[] = new CompletionItem( + label: $scalar, + kind: CompletionItemKind::KEYWORD, + ); } - $items[] = new CompletionItem( - label: $scalar, - kind: CompletionItemKind::KEYWORD, - ); } return $items; } + /** + * Resolve `$containerName` -- the generic class identifier preceding + * the `<` -- to an FQN, then look up its bound for slot `$slot`. + * Returns null when: + * - the index isn't wired (legacy constructor), + * - the container can't be resolved to a known generic class, + * - the slot has no declared bound (unbounded type-param). + */ + private function boundFor(string $containerName, int $slot): ?string + { + if ($this->fqnIndex === null) { + return null; + } + $candidates = $this->resolveContainerFqns($containerName); + foreach ($candidates as $fqn) { + $bounds = $this->fqnIndex->boundsForGenericClass($fqn); + if ($bounds === null) { + continue; + } + return $bounds[$slot] ?? null; + } + return null; + } + + /** + * Best-effort container Name -> FQN resolution. The cursor sees the + * Name as it appears in source (`Box`, `App\Box`, or `\App\Box`); we + * don't have the use-import map here, so: + * - qualified -> strip leading `\`, accept verbatim; + * - unqualified -> match any indexed FQN whose short name equals + * the identifier (Phase 3 polish "short-name tie-break" can pick + * the best one later). + * + * Uses `FqnIndex::allDeclarations()` (open docs + filesystem) so we + * find the container even when its declaration file is closed in + * the editor. Falls back to `WorkspaceSymbols::allClassFqns()` + * (open-only) when no `FqnIndex` is wired -- legacy constructor + * path. + * + * @return list + */ + private function resolveContainerFqns(string $name): array + { + $needle = ltrim($name, '\\'); + if ($needle === '') { + return []; + } + if (str_contains($needle, '\\')) { + return [$needle]; + } + $matches = []; + if ($this->fqnIndex !== null) { + foreach ($this->fqnIndex->allDeclarations() as $hit) { + if ($hit['kind'] !== 'class') { + continue; + } + if (self::lastSegment($hit['fqn']) === $needle) { + $matches[] = $hit['fqn']; + } + } + return $matches; + } + foreach ($this->symbols->allClassFqns() as $fqn) { + if (self::lastSegment($fqn) === $needle) { + $matches[] = $fqn; + } + } + return $matches; + } + + /** + * "Is `$candidateFqn` a subtype of `$boundFqn`?" Walks the candidate + * class's parent + interface chain via worse-reflection. Returns true + * when the bound appears in the chain (or equals the candidate); false + * otherwise; ALSO true when reflection fails -- we prefer surfacing a + * possibly-incompatible candidate over silently hiding one the user + * meant to pick. + */ + private function satisfiesBound(string $candidateFqn, string $boundFqn): bool + { + if ($this->reflector === null) { + return true; + } + $candidate = ltrim($candidateFqn, '\\'); + $bound = ltrim($boundFqn, '\\'); + if ($candidate === '' || $bound === '' || $candidate === $bound) { + return true; + } + try { + $class = $this->reflector->reflectClassLike($candidate); + } catch (Throwable) { + return true; + } + // ReflectionClassLike::isInstanceOf walks the parent + interface + // chain itself, including transitive ancestors via interfaces. + // Cleaner than rolling our own BFS and respects worse-reflection's + // internal caching of the hierarchy. + try { + return $class->isInstanceOf(ClassName::fromString($bound)); + } catch (Throwable) { + return true; + } + } + private static function matchesPrefix(string $shortName, string $fqn, string $prefix): bool { if ($prefix === '') { @@ -137,6 +285,36 @@ private static function matchesPrefix(string $shortName, string $fqn, string $pr return stripos($shortName, $needle) === 0 || stripos($fqn, $needle) !== false; } + /** + * Class FQNs eligible as type-arg candidates. Prefers `FqnIndex` + * (open docs + filesystem) when wired so closed-file workspace + * classes also surface; falls back to `WorkspaceSymbols` (open + * only) on the legacy constructor path used by older tests. + * + * @return iterable + */ + private function candidateClassFqns(): iterable + { + if ($this->fqnIndex !== null) { + $seen = []; + foreach ($this->fqnIndex->allDeclarations() as $hit) { + if ($hit['kind'] !== 'class') { + continue; + } + $fqn = $hit['fqn']; + if (isset($seen[$fqn])) { + continue; + } + $seen[$fqn] = true; + yield $fqn; + } + return; + } + foreach ($this->symbols->allClassFqns() as $fqn) { + yield $fqn; + } + } + private static function lastSegment(string $fqn): string { $pos = strrpos($fqn, '\\'); diff --git a/tools/lsp/src/Handler/XphpDefinitionHandler.php b/tools/lsp/src/Handler/XphpDefinitionHandler.php index 8a5c6acf..8967aec1 100644 --- a/tools/lsp/src/Handler/XphpDefinitionHandler.php +++ b/tools/lsp/src/Handler/XphpDefinitionHandler.php @@ -7,7 +7,6 @@ use Amp\Promise; use Amp\Success; use PhpParser\Node; -use PhpParser\Node\Stmt\ClassLike; use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; use Phpactor\LanguageServer\Core\Handler\CanRegisterCapabilities; @@ -20,32 +19,41 @@ use Phpactor\LanguageServerProtocol\ServerCapabilities; use XPHP\Lsp\Analyzer\ParsedDocumentCache; use XPHP\Lsp\PositionMap; +use XPHP\Lsp\Reflection\FqnIndex; +use XPHP\Lsp\Resolver\PhpDefinitionResolver; +use XPHP\Lsp\Resolver\ReferenceFinder; use XPHP\Transpiler\Monomorphize\XphpSourceParser; /** * `textDocument/definition` handler. * - * On the cursor over a generic instantiation Name (e.g. `Box` in `new Box()`), - * walks the AST of every currently-open document to find the matching - * `ClassLike` template declaration and returns its source Location. + * On the cursor over a generic instantiation Name (e.g. `Box` in `new + * Box()`), uses `FqnIndex` to locate the matching `ClassLike` + * template declaration across BOTH open documents and the on-disk + * filesystem under rootPath -- the jump works whether the target file is + * open in the editor or sitting on disk untouched. * * The lookup is by `ATTR_TEMPLATE_FQN`: each parsed template carries the - * fully-qualified template name as an attribute on the ClassLike node, set by - * XphpSourceParser. We re-parse open documents on each request (cheap; the - * analyzer is already optimized for re-parse on change). A workspace-wide - * symbol cache is a follow-up alongside CompletionHandler. + * fully-qualified template name as an attribute on the ClassLike node, set + * by XphpSourceParser. Open documents win on FQN collisions (the editor's + * unsaved buffer beats the on-disk copy). * * Returns null when: * - the document isn't open - * - the cursor isn't on a Name node carrying ATTR_TEMPLATE_FQN - * - no open document defines a template with the matching FQN (the - * definition lives on disk but unopened, which we don't index yet) + * - the cursor isn't on a Name node carrying ATTR_TEMPLATE_FQN AND + * isn't on a type-arg identifier inside a `<…>` clause + * - no declaration with the matching FQN / short name exists anywhere + * in the indexed workspace */ final class XphpDefinitionHandler implements Handler, CanRegisterCapabilities { public function __construct( private readonly PhpactorWorkspace $workspace, private readonly ParsedDocumentCache $cache, + private readonly WorkspaceSymbols $workspaceSymbols, + private readonly FqnIndex $fqnIndex, + private readonly ReferenceFinder $referenceFinder, + private readonly ?PhpDefinitionResolver $phpResolver = null, ) { } @@ -85,83 +93,151 @@ public function definition(DefinitionParams $params): Promise $params->position->line, $params->position->character, ); - $hit = AstPositionResolver::nameAtOffset($currentResult->ast, $offset); - if ($hit === null) { - return new Success(null); - } - $templateFqn = $hit['name']->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); - if (!is_string($templateFqn) || $templateFqn === '') { - return new Success(null); + // Path 0: cursor sits on a declaration's own name token + // (`function foo`, `class Foo`, `public function method()`, + // `public $prop`). Standard GTD would either no-op (already AT + // the decl) or return null. Promote this to "find usages" -- + // when the user Ctrl+Clicks their own declaration they almost + // always mean "show me everywhere this is used". Return the + // reference locations (excluding the decl itself). PhpStorm + // handles `Location[]` by popping a multi-target navigator. + if (self::isOnDeclarationName($currentResult->ast, $offset)) { + $references = $this->referenceFinder->findReferences( + $params->textDocument->uri, + $offset, + includeDeclaration: false, + ); + if ($references !== []) { + return new Success($references); + } + // Fall through to normal GTD if there are no usages -- the + // user sees "no targets" instead of nothing. } - return new Success($this->findDefinitionAcrossWorkspace($templateFqn)); - } - - private function findDefinitionAcrossWorkspace(string $templateFqn): ?Location - { - foreach ($this->workspace as $uri => $item) { - $result = $this->cache->getOrParse($uri, $item->version, $item->text); - if ($result->ast === null) { - continue; + // Path 1: cursor on a Name carrying ATTR_TEMPLATE_FQN -- the + // outer site of a generic instantiation (`Box` in + // `new Box()`) or a generic function call (`identity` in + // `identity(...)`). Navigate to the matching ClassLike + // template declaration. Phase 2.3: FqnIndex consults both open + // docs and the filesystem so the jump works even when the + // template lives in an unopened .xphp file. + $hit = AstPositionResolver::nameAtOffset($currentResult->ast, $offset); + if ($hit !== null) { + $templateFqn = $hit['name']->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + if (is_string($templateFqn) && $templateFqn !== '') { + $location = self::locationToLsp($this->fqnIndex->locationForFqn($templateFqn)); + if ($location !== null) { + return new Success($location); + } } - $found = self::findTemplateInAst($result->ast, $templateFqn); - if ($found === null) { - continue; + } + + // Path 2: cursor on a type-arg identifier INSIDE a generic clause + // (`User` in `identity(...)`). These don't survive into the + // AST as Name nodes -- XphpSourceParser strips them into TypeRef + // marker entries on the surrounding call -- so AstPositionResolver + // never lands a hit on them. Use the source-level + // TypeArgPositionDetector to extract the identifier under the + // cursor and resolve it via FqnIndex (open docs + filesystem + // short-name match). + $identifier = TypeArgPositionDetector::identifierAt($currentItem->text, $offset); + if ($identifier !== null) { + $shortName = self::lastSegment($identifier); + $location = self::locationToLsp($this->fqnIndex->locationByShortName($shortName)); + if ($location !== null) { + return new Success($location); } - $positionMap = new PositionMap($item->text); - [$startLine, $startChar] = $positionMap->offsetToPosition($found['startOffset']); - [$endLine, $endChar] = $positionMap->offsetToPosition($found['endOffset']); - return new Location( - $uri, - new Range( - new Position($startLine, $startChar), - new Position($endLine, $endChar), - ), - ); } - return null; + + // Path 3: PHP-semantic GTD via worse-reflection. Handles everything + // the xphp-specific paths above don't: `use App\Models\User;`, + // `new User(...)`, `$obj->method()`, `Cls::method()`, `strlen(...)` + // (resolves to phpstorm-stubs), etc. The resolver returns null + // gracefully on unknown / unresolvable symbols, matching the LSP + // expectation of "no answer" => no "Cannot find declaration" + // noise from us. + if ($this->phpResolver !== null) { + return new Success($this->phpResolver->resolve( + $params->textDocument->uri, + $params->position->line, + $params->position->character, + )); + } + + return new Success(null); } /** + * Detect whether the byte offset falls on the name token of a + * Function_, ClassLike, ClassMethod, or PropertyItem declaration. + * Used by Path 0 to promote GTD-on-decl into find-usages. + * * @param list $ast - * @return array{startOffset: int, endOffset: int}|null */ - private static function findTemplateInAst(array $ast, string $templateFqn): ?array + private static function isOnDeclarationName(array $ast, int $byteOffset): bool { - $visitor = new class($templateFqn) extends NodeVisitorAbstract { - public ?int $startOffset = null; - public ?int $endOffset = null; + $visitor = new class($byteOffset) extends NodeVisitorAbstract { + public bool $hit = false; - public function __construct(private readonly string $templateFqn) + public function __construct(private readonly int $offset) { } public function enterNode(Node $node): null { - if (!$node instanceof ClassLike || $node->name === null) { + if ($this->hit) { return null; } - $fqn = $node->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); - if ($fqn !== $this->templateFqn) { + $nameNode = null; + if ($node instanceof Node\Stmt\ClassLike) { + $nameNode = $node->name; + } elseif ($node instanceof Node\Stmt\Function_) { + $nameNode = $node->name; + } elseif ($node instanceof Node\Stmt\ClassMethod) { + $nameNode = $node->name; + } elseif ($node instanceof Node\PropertyItem) { + $nameNode = $node->name; + } + if ($nameNode === null) { return null; } - // Range targets the class NAME, not the entire ClassLike body — - // jumps land on the identifier the user expects, not the - // opening line including modifiers. - $this->startOffset = $node->name->getStartFilePos(); - $this->endOffset = $node->name->getEndFilePos() + 1; + $s = $nameNode->getStartFilePos(); + $e = $nameNode->getEndFilePos(); + if ($s >= 0 && $this->offset >= $s && $this->offset <= $e) { + $this->hit = true; + } return null; } }; - $traverser = new NodeTraverser(); $traverser->addVisitor($visitor); $traverser->traverse($ast); + return $visitor->hit; + } - if ($visitor->startOffset === null || $visitor->endOffset === null) { + private static function lastSegment(string $identifier): string + { + $idx = strrpos($identifier, '\\'); + return $idx === false ? $identifier : substr($identifier, $idx + 1); + } + + /** + * @param array{uri: string, line: int, char: int, short: string}|null $hit + */ + private static function locationToLsp(?array $hit): ?Location + { + if ($hit === null) { return null; } - return ['startOffset' => $visitor->startOffset, 'endOffset' => $visitor->endOffset]; + $endChar = $hit['char'] + strlen($hit['short']); + return new Location( + $hit['uri'], + new Range( + new Position($hit['line'], $hit['char']), + new Position($hit['line'], $endChar), + ), + ); } + } diff --git a/tools/lsp/src/Handler/XphpDocumentSymbolHandler.php b/tools/lsp/src/Handler/XphpDocumentSymbolHandler.php new file mode 100644 index 00000000..373e7ca8 --- /dev/null +++ b/tools/lsp/src/Handler/XphpDocumentSymbolHandler.php @@ -0,0 +1,279 @@ + property | const | method | enum-case (as applicable) + * function (top-level) + * + * Namespaces aren't emitted as wrapping symbols; they'd inflate the panel + * with a single level of indirection most users skip past. The class FQN + * is already in the worse-reflection hover. + * + * Server capability is advertised as bool `true` (not `DocumentSymbolOptions`) + * for the same reason hover does -- PHP's null-stripping serializer encodes + * an empty-options object as `[]`, which IntelliJ rejects. + */ +final class XphpDocumentSymbolHandler implements Handler, CanRegisterCapabilities +{ + public function __construct( + private readonly PhpactorWorkspace $workspace, + private readonly ParsedDocumentCache $cache, + ) { + } + + public function methods(): array + { + return [ + 'textDocument/documentSymbol' => 'documentSymbol', + ]; + } + + public function registerCapabiltiies(ServerCapabilities $capabilities): void + { + $capabilities->documentSymbolProvider = true; + } + + /** + * @return Promise|null> + */ + public function documentSymbol(DocumentSymbolParams $params): Promise + { + $uri = $params->textDocument->uri; + if (!$this->workspace->has($uri)) { + return new Success(null); + } + $item = $this->workspace->get($uri); + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + return new Success(null); + } + + $positionMap = new PositionMap($item->text); + $byteOffsetMap = $result->byteOffsetMap; + $symbols = []; + foreach ($result->ast as $stmt) { + self::collectTopLevel($stmt, $positionMap, $byteOffsetMap, $symbols); + } + return new Success($symbols); + } + + /** + * @param list $out + */ + private static function collectTopLevel(Node $stmt, PositionMap $map, ByteOffsetMap $offsets, array &$out): void + { + if ($stmt instanceof Namespace_) { + foreach ($stmt->stmts as $child) { + self::collectTopLevel($child, $map, $offsets, $out); + } + return; + } + if ($stmt instanceof ClassLike) { + $sym = self::classLikeSymbol($stmt, $map, $offsets); + if ($sym !== null) { + $out[] = $sym; + } + return; + } + if ($stmt instanceof Function_) { + $sym = self::functionSymbol($stmt, $map, $offsets); + if ($sym !== null) { + $out[] = $sym; + } + } + } + + private static function classLikeSymbol(ClassLike $node, PositionMap $map, ByteOffsetMap $offsets): ?DocumentSymbol + { + if ($node->name === null) { + // Anonymous classes have no entry point in the outline. + return null; + } + $children = []; + foreach ($node->stmts as $member) { + if ($member instanceof ClassMethod) { + $sym = self::methodSymbol($member, $map, $offsets); + if ($sym !== null) { + $children[] = $sym; + } + continue; + } + if ($member instanceof Property) { + foreach (self::propertySymbols($member, $map, $offsets) as $sym) { + $children[] = $sym; + } + continue; + } + if ($member instanceof ClassConst) { + foreach (self::classConstSymbols($member, $map, $offsets) as $sym) { + $children[] = $sym; + } + continue; + } + if ($member instanceof EnumCase) { + $sym = self::enumCaseSymbol($member, $map, $offsets); + if ($sym !== null) { + $children[] = $sym; + } + } + } + + return new DocumentSymbol( + name: $node->name->toString(), + kind: self::classLikeKind($node), + range: self::rangeOf($node, $map, $offsets), + selectionRange: self::rangeOf($node->name, $map, $offsets), + children: $children, + ); + } + + /** + * @return SymbolKind::* + */ + private static function classLikeKind(ClassLike $node): int + { + if ($node instanceof Interface_) { + return SymbolKind::INTERFACE; + } + if ($node instanceof Trait_) { + // LSP has no TRAIT kind. CLASS_ renders with the class icon + // in every client we care about; the name is enough to + // distinguish. + return SymbolKind::CLASS_; + } + if ($node instanceof Enum_) { + return SymbolKind::ENUM; + } + // Class_ falls through. + return SymbolKind::CLASS_; + } + + private static function functionSymbol(Function_ $node, PositionMap $map, ByteOffsetMap $offsets): ?DocumentSymbol + { + return new DocumentSymbol( + name: $node->name->toString(), + kind: SymbolKind::FUNCTION, + range: self::rangeOf($node, $map, $offsets), + selectionRange: self::rangeOf($node->name, $map, $offsets), + ); + } + + private static function methodSymbol(ClassMethod $node, PositionMap $map, ByteOffsetMap $offsets): ?DocumentSymbol + { + $name = $node->name->toString(); + $kind = strcasecmp($name, '__construct') === 0 + ? SymbolKind::CONSTRUCTOR + : SymbolKind::METHOD; + return new DocumentSymbol( + name: $name, + kind: $kind, + range: self::rangeOf($node, $map, $offsets), + selectionRange: self::rangeOf($node->name, $map, $offsets), + ); + } + + /** + * @return list + */ + private static function propertySymbols(Property $node, PositionMap $map, ByteOffsetMap $offsets): array + { + $out = []; + foreach ($node->props as $prop) { + $out[] = new DocumentSymbol( + name: '$' . $prop->name->toString(), + kind: SymbolKind::PROPERTY, + range: self::rangeOf($prop, $map, $offsets), + selectionRange: self::rangeOf($prop->name, $map, $offsets), + ); + } + return $out; + } + + /** + * @return list + */ + private static function classConstSymbols(ClassConst $node, PositionMap $map, ByteOffsetMap $offsets): array + { + $out = []; + foreach ($node->consts as $const) { + $out[] = new DocumentSymbol( + name: $const->name->toString(), + kind: SymbolKind::CONSTANT, + range: self::rangeOf($const, $map, $offsets), + selectionRange: self::rangeOf($const->name, $map, $offsets), + ); + } + return $out; + } + + private static function enumCaseSymbol(EnumCase $node, PositionMap $map, ByteOffsetMap $offsets): ?DocumentSymbol + { + return new DocumentSymbol( + name: $node->name->toString(), + kind: SymbolKind::ENUM_MEMBER, + range: self::rangeOf($node, $map, $offsets), + selectionRange: self::rangeOf($node->name, $map, $offsets), + ); + } + + private static function rangeOf(Node|Identifier $node, PositionMap $map, ByteOffsetMap $offsets): Range + { + // AST offsets index into the stripped source (post `T[] -> array` + // rewrite). Translate back to the original-source byte offsets + // before mapping to line/character -- otherwise the LSP client + // scrolls to the wrong place in any file using T[] sugar. + $start = $offsets->toOriginal($node->getStartFilePos()); + $end = $offsets->toOriginal($node->getEndFilePos() + 1); + if ($start < 0) { + $start = 0; + } + if ($end < $start) { + $end = $start; + } + [$startLine, $startChar] = $map->offsetToPosition($start); + [$endLine, $endChar] = $map->offsetToPosition($end); + return new Range( + new Position($startLine, $startChar), + new Position($endLine, $endChar), + ); + } +} diff --git a/tools/lsp/src/Handler/XphpFileWatcherHandler.php b/tools/lsp/src/Handler/XphpFileWatcherHandler.php new file mode 100644 index 00000000..95ce5554 --- /dev/null +++ b/tools/lsp/src/Handler/XphpFileWatcherHandler.php @@ -0,0 +1,68 @@ + 'didChangeWatchedFiles', + ]; + } + + /** + * @return Promise + */ + public function didChangeWatchedFiles(DidChangeWatchedFilesParams $params): Promise + { + $count = count($params->changes); + if ($count > 0) { + @fwrite(STDERR, sprintf( + "[xphp-lsp watch] invalidating filesystem index (%d change%s)\n", + $count, + $count === 1 ? '' : 's', + )); + $this->fqnIndex->invalidateFilesystem(); + } + // LSP notifications don't have a response payload, but the + // phpactor dispatcher still expects a Promise return -- resolve + // with null to signal "handled, no result." + return new Success(null); + } +} diff --git a/tools/lsp/src/Handler/XphpHoverHandler.php b/tools/lsp/src/Handler/XphpHoverHandler.php index 1f29e60d..4a607620 100644 --- a/tools/lsp/src/Handler/XphpHoverHandler.php +++ b/tools/lsp/src/Handler/XphpHoverHandler.php @@ -11,13 +11,13 @@ use Phpactor\LanguageServer\Core\Handler\Handler; use Phpactor\LanguageServer\Core\Workspace\Workspace as PhpactorWorkspace; use Phpactor\LanguageServerProtocol\Hover; -use Phpactor\LanguageServerProtocol\HoverOptions; use Phpactor\LanguageServerProtocol\HoverParams; use Phpactor\LanguageServerProtocol\MarkupContent; use Phpactor\LanguageServerProtocol\MarkupKind; use Phpactor\LanguageServerProtocol\ServerCapabilities; use XPHP\Lsp\Analyzer\ParsedDocumentCache; use XPHP\Lsp\PositionMap; +use XPHP\Lsp\Resolver\PhpHoverResolver; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeParam; use XPHP\Transpiler\Monomorphize\TypeRef; @@ -48,6 +48,7 @@ final class XphpHoverHandler implements Handler, CanRegisterCapabilities public function __construct( private readonly PhpactorWorkspace $workspace, private readonly ParsedDocumentCache $cache, + private readonly ?PhpHoverResolver $phpResolver = null, ) { } @@ -62,7 +63,22 @@ public function methods(): array // We match the typo deliberately — overriding requires the same name. public function registerCapabiltiies(ServerCapabilities $capabilities): void { - $capabilities->hoverProvider = new HoverOptions(); + // Use the bool form of `hoverProvider`, NOT `new HoverOptions()`. + // + // `hoverProvider` is `Either` in the LSP spec. + // PHP's json_encode + phpactor's null-stripping serializer turns a + // default-constructed `HoverOptions` (workDoneProgress=null, the + // class's only field) into `[]` -- because an empty associative + // array is indistinguishable from an empty indexed array in PHP, + // and json_encode picks the array form. IntelliJ's LSP4J client + // then rejects the response with: + // + // Unexpected token BEGIN_ARRAY: expected BOOLEAN | BEGIN_OBJECT + // + // ...and kills the server before the handshake completes. + // `true` is a valid Either value, encodes unambiguously, and matches + // the shape we already use for definitionProvider just below. + $capabilities->hoverProvider = true; } /** @@ -86,16 +102,29 @@ public function hover(HoverParams $params): Promise ); $hit = AstPositionResolver::nameAtOffset($result->ast, $offset); - if ($hit === null) { - return new Success(null); + if ($hit !== null) { + $markdown = $this->buildHoverMarkdown($hit['name'], $hit['classScope']); + if ($markdown !== null) { + return new Success(new Hover(new MarkupContent(MarkupKind::MARKDOWN, $markdown))); + } } - $markdown = $this->buildHoverMarkdown($hit['name'], $hit['classScope']); - if ($markdown === null) { - return new Success(null); + // Fall through to PHP-semantic hover via worse-reflection. Handles + // everything the xphp-specific paths above don't: class names, + // function calls, method/property access, native functions + // (from phpstorm-stubs), etc. + if ($this->phpResolver !== null) { + $hover = $this->phpResolver->resolve( + $params->textDocument->uri, + $params->position->line, + $params->position->character, + ); + if ($hover !== null) { + return new Success($hover); + } } - return new Success(new Hover(new MarkupContent(MarkupKind::MARKDOWN, $markdown))); + return new Success(null); } /** diff --git a/tools/lsp/src/Handler/XphpReferencesHandler.php b/tools/lsp/src/Handler/XphpReferencesHandler.php new file mode 100644 index 00000000..b8075466 --- /dev/null +++ b/tools/lsp/src/Handler/XphpReferencesHandler.php @@ -0,0 +1,63 @@ + 'references', + ]; + } + + public function registerCapabiltiies(ServerCapabilities $capabilities): void + { + $capabilities->referencesProvider = true; + } + + /** + * @return Promise> + */ + public function references(ReferenceParams $params): Promise + { + $uri = $params->textDocument->uri; + if (!$this->workspace->has($uri)) { + return new Success([]); + } + $item = $this->workspace->get($uri); + $offset = (new PositionMap($item->text))->positionToOffset( + $params->position->line, + $params->position->character, + ); + $includeDeclaration = $params->context->includeDeclaration ?? true; + return new Success($this->finder->findReferences($uri, $offset, $includeDeclaration)); + } +} diff --git a/tools/lsp/src/Handler/XphpRenameHandler.php b/tools/lsp/src/Handler/XphpRenameHandler.php new file mode 100644 index 00000000..dbc25ce3 --- /dev/null +++ b/tools/lsp/src/Handler/XphpRenameHandler.php @@ -0,0 +1,76 @@ + 'rename', + ]; + } + + public function registerCapabiltiies(ServerCapabilities $capabilities): void + { + $capabilities->renameProvider = true; + } + + /** + * @return Promise<\Phpactor\LanguageServerProtocol\WorkspaceEdit|null> + */ + public function rename(RenameParams $params): Promise + { + $uri = $params->textDocument->uri; + if (!$this->workspace->has($uri)) { + return new Success(null); + } + $item = $this->workspace->get($uri); + $offset = (new PositionMap($item->text))->positionToOffset( + $params->position->line, + $params->position->character, + ); + try { + $edit = $this->provider->rename($uri, $offset, $params->newName); + } catch (InvalidRenameNameException $e) { + return new Failure(new RuntimeException($e->getMessage(), ErrorCodes::InvalidParams)); + } + return new Success($edit); + } +} diff --git a/tools/lsp/src/Handler/XphpTextDocumentHandler.php b/tools/lsp/src/Handler/XphpTextDocumentHandler.php new file mode 100644 index 00000000..199614c4 --- /dev/null +++ b/tools/lsp/src/Handler/XphpTextDocumentHandler.php @@ -0,0 +1,113 @@ +contentChanges as $contentChange) { + * $this->dispatcher->dispatch(new TextDocumentUpdated( + * $params->textDocument, + * $contentChange['text'] // <-- bug + * )); + * } + * + * `$params->contentChanges` is populated by `DidChangeTextDocumentParams::fromArray()` + * which routes each entry through `TextDocumentContentChangeFullEvent::fromArray()` + * (under `TextDocumentSyncKind=Full`) -- producing a `TextDocumentContentChangeFullEvent` + * OBJECT, not an array. PHP 8 raises `Error: Cannot use object of type ... as + * array` on the subscript, and the void-returning handler lets it propagate into + * amphp's loop-level error handler which swallows it. Net effect: every + * didChange notification is dropped and `Workspace::update()` is never called, + * so the editor's view of any buffer is frozen at didOpen time. + * + * Confirmed in production via `xphp-20260524-194635-862.log`: every + * completion at `$repo->|` reads byte offset 365 (start of line 16) because + * line 16 is empty in the v=0 didOpen snapshot, even though the user had + * typed `$repo->` and PhpStorm had sent didChanges v1..v8. + * + * Fix: dispatch with `$contentChange->text` (property access). Kept an + * array-access fallback in case incremental sync ever lands and the + * deserialiser produces a different shape; the fallback isn't exercised in + * Full sync mode but it's a cheap safety net against future churn. + */ +final class XphpTextDocumentHandler implements Handler, CanRegisterCapabilities +{ + public function __construct( + private readonly EventDispatcherInterface $dispatcher, + ) { + } + + public function methods(): array + { + return [ + 'textDocument/didOpen' => 'didOpen', + 'textDocument/didChange' => 'didChange', + 'textDocument/didClose' => 'didClose', + 'textDocument/didSave' => 'didSave', + 'textDocument/willSave' => 'willSave', + 'textDocument/willSaveWaitUntil' => 'willSaveWaitUntil', + ]; + } + + public function didOpen(DidOpenTextDocumentParams $params): void + { + $this->dispatcher->dispatch(new TextDocumentOpened($params->textDocument)); + } + + public function didChange(DidChangeTextDocumentParams $params): void + { + foreach ($params->contentChanges as $contentChange) { + $text = is_object($contentChange) + ? $contentChange->text + : $contentChange['text']; + $this->dispatcher->dispatch(new TextDocumentUpdated($params->textDocument, $text)); + } + } + + public function didClose(DidCloseTextDocumentParams $params): void + { + $this->dispatcher->dispatch(new TextDocumentClosed($params->textDocument)); + } + + public function didSave(DidSaveTextDocumentParams $params): void + { + $this->dispatcher->dispatch(new TextDocumentSaved($params->textDocument, $params->text)); + } + + public function willSave(WillSaveTextDocumentParams $params): void + { + } + + public function willSaveWaitUntil(WillSaveTextDocumentParams $params): void + { + } + + // `registerCapabiltiies` is misspelled in phpactor's Handler interface (sic). + // We match the typo deliberately -- overriding requires the same name. + public function registerCapabiltiies(ServerCapabilities $capabilities): void + { + $capabilities->textDocumentSync = TextDocumentSyncKind::FULL; + } +} diff --git a/tools/lsp/src/Handler/XphpWorkspaceSymbolHandler.php b/tools/lsp/src/Handler/XphpWorkspaceSymbolHandler.php new file mode 100644 index 00000000..7caf9c78 --- /dev/null +++ b/tools/lsp/src/Handler/XphpWorkspaceSymbolHandler.php @@ -0,0 +1,149 @@ + 'symbol', + ]; + } + + public function registerCapabiltiies(ServerCapabilities $capabilities): void + { + $capabilities->workspaceSymbolProvider = true; + } + + /** + * @return Promise> + */ + public function symbol(WorkspaceSymbolParams $params): Promise + { + $query = strtolower(self::stripMemberSuffix($params->query)); + $results = []; + foreach ($this->fqnIndex->allDeclarations() as $hit) { + if ($query !== '' && !self::matches($hit['fqn'], $query)) { + continue; + } + $results[] = self::toSymbolInformation($hit); + if (count($results) >= self::RESULT_CAP) { + break; + } + } + return new Success($results); + } + + /** + * PhpStorm's symbol popup sends `Class::method` (or `Class::`) when the + * user types the class-qualified form -- our handler doesn't index + * methods at workspace level, so we strip the `::...` suffix and treat + * it as a class query. The client surfaces the class hit; user can + * navigate, then use file-local document-symbols / GTD for the member. + */ + private static function stripMemberSuffix(string $query): string + { + $idx = strpos($query, '::'); + return $idx === false ? $query : substr($query, 0, $idx); + } + + private static function matches(string $fqn, string $lcQuery): bool + { + $short = strrchr($fqn, '\\'); + $short = $short !== false ? substr($short, 1) : $fqn; + return str_contains(strtolower($short), $lcQuery); + } + + /** + * @param array{fqn: string, kind: string, uri: string, line: int, char: int} $hit + */ + private static function toSymbolInformation(array $hit): SymbolInformation + { + $shortAndContainer = self::splitFqn($hit['fqn']); + $location = new Location( + $hit['uri'], + new Range( + new Position($hit['line'], $hit['char']), + new Position($hit['line'], $hit['char'] + strlen($shortAndContainer[0])), + ), + ); + return new SymbolInformation( + name: $shortAndContainer[0], + kind: self::kindToLsp($hit['kind']), + location: $location, + containerName: $shortAndContainer[1], + ); + } + + /** + * @return array{0: string, 1: ?string} [shortName, containerName-or-null] + */ + private static function splitFqn(string $fqn): array + { + $pos = strrpos($fqn, '\\'); + if ($pos === false) { + return [$fqn, null]; + } + return [substr($fqn, $pos + 1), substr($fqn, 0, $pos)]; + } + + /** + * @return SymbolKind::* + */ + private static function kindToLsp(string $kind): int + { + return match ($kind) { + 'interface' => SymbolKind::INTERFACE, + 'enum' => SymbolKind::ENUM, + 'function' => SymbolKind::FUNCTION, + // LSP has no TRAIT kind -- CLASS_ renders with the class icon + // in every client we care about. + 'trait', 'class' => SymbolKind::CLASS_, + default => SymbolKind::CLASS_, + }; + } +} diff --git a/tools/lsp/src/LspDispatcherFactory.php b/tools/lsp/src/LspDispatcherFactory.php index 2ec2a639..abf3e181 100644 --- a/tools/lsp/src/LspDispatcherFactory.php +++ b/tools/lsp/src/LspDispatcherFactory.php @@ -25,8 +25,9 @@ use Phpactor\LanguageServer\Core\Workspace\Workspace as PhpactorWorkspace; use Phpactor\LanguageServer\Handler\System\ExitHandler; use Phpactor\LanguageServer\Handler\System\ServiceHandler; -use Phpactor\LanguageServer\Handler\TextDocument\TextDocumentHandler; +use XPHP\Lsp\Handler\XphpTextDocumentHandler; use Phpactor\LanguageServer\Handler\Workspace\CommandHandler; +use Phpactor\LanguageServer\Listener\DidChangeWatchedFilesListener; use Phpactor\LanguageServer\Listener\ServiceListener; use Phpactor\LanguageServer\Listener\WorkspaceListener; use Phpactor\LanguageServer\Middleware\CancellationMiddleware; @@ -46,7 +47,25 @@ use XPHP\Lsp\Handler\WorkspaceSymbols; use XPHP\Lsp\Handler\XphpCompletionHandler; use XPHP\Lsp\Handler\XphpDefinitionHandler; +use XPHP\Lsp\Handler\XphpDocumentSymbolHandler; +use XPHP\Lsp\Handler\XphpFileWatcherHandler; use XPHP\Lsp\Handler\XphpHoverHandler; +use XPHP\Lsp\Handler\XphpReferencesHandler; +use XPHP\Lsp\Handler\XphpRenameHandler; +use XPHP\Lsp\Handler\XphpWorkspaceSymbolHandler; +use XPHP\Lsp\Reflection\ReflectorFactory; +use XPHP\Lsp\Reflection\FqnIndex; +use XPHP\Lsp\Resolver\CompletionIndex; +use XPHP\Lsp\Resolver\CompositeClassLikeLookup; +use XPHP\Lsp\Resolver\FilesystemClassLikeLookup; +use XPHP\Lsp\Resolver\GenericParamRegistry; +use XPHP\Lsp\Resolver\GenericResolver; +use XPHP\Lsp\Resolver\PhpCompletionResolver; +use XPHP\Lsp\Resolver\ReferenceFinder; +use XPHP\Lsp\Resolver\RenameProvider; +use XPHP\Lsp\Resolver\WorkspaceClassLikeLookup; +use XPHP\Lsp\Resolver\PhpDefinitionResolver; +use XPHP\Lsp\Resolver\PhpHoverResolver; use XPHP\Transpiler\Monomorphize\XphpSourceParser; /** @@ -84,9 +103,61 @@ public function create(MessageTransmitter $transmitter, InitializeParams $initia // Every handler (hover, definition, completion, diagnostics) reads // through the cache so a workspace pass costs O(unchanged docs serves // from cache) rather than O(N parses per keystroke). - $analyzer = new Analyzer(new XphpSourceParser((new ParserFactory())->createForHostVersion())); + $xphpParser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $analyzer = new Analyzer($xphpParser); $cache = new ParsedDocumentCache($analyzer); + // Worse-reflection-backed engine for PHP-semantic GTD/hover/completion + // -- everything beyond xphp generics. Built once per LSP session and + // shared across resolvers. `rootPath` is what `InitializeParams` + // hands us as the project workspace root; an empty string => no + // filesystem walking (workspace + stubs only). + $rootPath = $initializeParams->rootPath ?? ''; + // FqnIndex is the single workspace-wide FQN -> declaration map. + // Replaces three parallel walks (FilesystemSourceLocator's private + // map, WorkspaceSymbols' open-doc walk, WorkspaceClassLikeLookup's + // open-doc walk). Phase-0 of the LSP follow-up roadmap. + $fqnIndex = new FqnIndex($workspace, $cache, $xphpParser, $rootPath); + $reflector = (new ReflectorFactory( + $workspace, + $cache, + $xphpParser, + $rootPath, + ReflectorFactory::defaultStubPath(), + ReflectorFactory::defaultCacheDir(), + $fqnIndex, + ))->build(); + // Per-session registry of (namespace, paramName) pairs harvested from + // generic ClassLike declarations in open documents. Resolvers query + // this when formatting type names so a post-strip placeholder + // reference like `App\Containers\T` renders as `T` in hover/completion + // detail, matching what the user wrote in the original `` source. + // Phase 0.5: GenericParamRegistry now consumes FqnIndex (open + filesystem) + // so the prettify pass sees placeholder names from filesystem-only + // classes too -- not just open-doc declarations. + $genericParams = new GenericParamRegistry($fqnIndex); + // GenericResolver is a stronger pass that does actual type-arg + // substitution: `$user = $users->first()` where `$users = new + // Collection(...)` resolves to `?App\Models\User` rather than + // the unresolved `?T` the prettify path can produce. Consulted + // first in renderVariable; falls back to prettify on misses. + // Composite chain: workspace (live) -> filesystem (on-disk). Open-doc + // declarations win; closed-file declarations fall through to the + // filesystem-backed FilesystemClassLikeLookup, which re-parses on + // demand via FqnIndex and returns the ClassLike with xphp attributes + // intact -- exactly what GenericResolver needs to substitute type-args + // when Collection.xphp isn't open in the editor. + $classLikeLookup = new CompositeClassLikeLookup( + new WorkspaceClassLikeLookup($workspace, $cache), + new FilesystemClassLikeLookup($fqnIndex), + ); + $genericResolver = new GenericResolver($workspace, $cache, $classLikeLookup, $xphpParser, $fqnIndex); + // PhpDefinitionResolver takes GenericResolver too (Phase 0.7) so GTD + // on property access through a generic method's return type can + // resolve via the substituted receiver class. + $phpDefinitionResolver = new PhpDefinitionResolver($workspace, $xphpParser, $reflector, $cache, $genericResolver); + $phpHoverResolver = new PhpHoverResolver($workspace, $xphpParser, $reflector, $genericParams, $genericResolver); + $diagnosticsProvider = new XphpDiagnosticsProvider( $cache, new WorkspaceAnalyzer(), @@ -108,20 +179,76 @@ public function create(MessageTransmitter $transmitter, InitializeParams $initia // DiagnosticsService is both a ServiceProvider AND a ListenerProviderInterface — // registering it directly on the event dispatcher is what subscribes // provideDiagnostics() to didOpen / didChange / didSave events. + // + // Phase 2.4: DidChangeWatchedFilesListener (phpactor-shipped) listens + // for the `initialized` event and sends `client/registerCapability` + // back to the client to subscribe to fs-watch notifications for + // **/*.xphp and **/*.php. PhpStorm + VS Code both advertise + // `dynamicRegistration: true` for this; on clients that don't, the + // listener silently no-ops and the filesystem index stays + // one-shot-at-first-query (the pre-2.4 behaviour). $eventDispatcher = new AggregateEventDispatcher( new ServiceListener($serviceManager), new WorkspaceListener($workspace), + new DidChangeWatchedFilesListener( + $clientApi, + ['**/*.xphp', '**/*.php'], + $initializeParams->capabilities, + ), $diagnosticsService, ); + // Single WorkspaceSymbols shared across the two handlers that need it + // (completion + definition). Both call the same in-memory AST cache, + // so reusing the helper avoids constructing parallel collectors. + $workspaceSymbols = new WorkspaceSymbols($workspace, $cache); + + // CompletionIndex unifies workspace + stubs FQNs. Stubs are loaded + // from the same path we already extracted to in `ReflectorFactory`, + // so the index's stubs portion is a one-time JSON read on first use + // (built on demand if missing). + $completionIndex = new CompletionIndex($workspaceSymbols, ReflectorFactory::defaultStubPath()); + $phpCompletionResolver = new PhpCompletionResolver( + $workspace, + $xphpParser, + $reflector, + $completionIndex, + $cache, + $genericParams, + $genericResolver, + ); + $handlers = new Handlers( - new TextDocumentHandler($eventDispatcher), + new XphpTextDocumentHandler($eventDispatcher), new ServiceHandler($serviceManager, $clientApi), new CommandHandler(new CommandDispatcher([])), new ExitHandler(), - new XphpHoverHandler($workspace, $cache), - new XphpDefinitionHandler($workspace, $cache), - new XphpCompletionHandler($workspace, new WorkspaceSymbols($workspace, $cache)), + new XphpHoverHandler($workspace, $cache, $phpHoverResolver), + new XphpDefinitionHandler( + $workspace, + $cache, + $workspaceSymbols, + $fqnIndex, + new ReferenceFinder($workspace, $cache, $fqnIndex, $xphpParser, $reflector, $genericResolver), + $phpDefinitionResolver, + ), + new XphpCompletionHandler($workspace, $workspaceSymbols, $phpCompletionResolver, $fqnIndex, $reflector), + new XphpDocumentSymbolHandler($workspace, $cache), + new XphpWorkspaceSymbolHandler($fqnIndex), + new XphpFileWatcherHandler($fqnIndex), + new XphpReferencesHandler( + $workspace, + new ReferenceFinder($workspace, $cache, $fqnIndex, $xphpParser, $reflector, $genericResolver), + ), + new XphpRenameHandler( + $workspace, + new RenameProvider( + $workspace, + new ReferenceFinder($workspace, $cache, $fqnIndex, $xphpParser, $reflector, $genericResolver), + $fqnIndex, + self::clientSupportsRenameFileOp($initializeParams), + ), + ), ); $runner = new HandlerMethodRunner( @@ -144,4 +271,23 @@ public function create(MessageTransmitter $transmitter, InitializeParams $initia new HandlerMiddleware($runner), ); } + + /** + * Per LSP spec: when the client advertises + * `workspace.workspaceEdit.resourceOperations`, the server must + * only emit ops in that list. PhpStorm currently lists `["create"]` + * only (no `rename`/`delete`), so any `RenameFile` we send is + * silently dropped on the client side and the user sees a partial + * apply. We detect support up-front and elide RenameFile when the + * client doesn't claim it. VS Code advertises all three and gets + * the full behavior. + */ + private static function clientSupportsRenameFileOp(InitializeParams $initializeParams): bool + { + $ops = $initializeParams->capabilities?->workspace?->workspaceEdit?->resourceOperations ?? null; + if (!is_array($ops)) { + return false; + } + return in_array('rename', $ops, true); + } } diff --git a/tools/lsp/src/PositionMap.php b/tools/lsp/src/PositionMap.php index 6920fff5..8e138e35 100644 --- a/tools/lsp/src/PositionMap.php +++ b/tools/lsp/src/PositionMap.php @@ -13,10 +13,13 @@ * and 1-based line numbers. LSP wants 0-based lines and 0-based UTF-16 code-unit * columns within each line. This class owns the conversion for one source string. * - * MVP simplification: column = UTF-8 character index, not UTF-16 code-unit index. - * For pure ASCII (which describes basically all .xphp source we'll see in practice) - * these are identical. A real UTF-16 implementation is a small follow-up — see - * `toLspCharacter()` for the seam. + * Character index = UTF-16 code units, per the LSP spec. Every Unicode + * scalar in the Basic Multilingual Plane (UTF-8 1/2/3-byte sequences) + * counts as one code unit; supplementary-plane scalars (UTF-8 4-byte, + * codepoint > U+FFFF -- emoji, less-common CJK extensions) count as two + * because UTF-16 represents them as surrogate pairs. For ASCII source + * this collapses to byte counts; for documents with a `🚀` in a comment + * the columns past that character still match what the editor reports. */ final readonly class PositionMap { @@ -71,14 +74,22 @@ public function positionToOffset(int $line, int $character): int ? $this->lineOffsets[$line + 1] - 1 : $length; $lineText = substr($this->source, $lineStart, $lineEnd - $lineStart); - // Walk characters until we've consumed `$character` of them — handles - // multibyte UTF-8 the same way offsetToPosition does (one Unicode char - // = one column in our MVP UTF-16 stand-in). + // Walk UTF-16 code units until we've consumed `$character` of them. + // BMP chars (UTF-8 1/2/3-byte) count as 1 unit each; supplementary- + // plane chars (UTF-8 4-byte) count as 2. Stop short if the next + // char would land us inside a surrogate pair -- editors usually + // clamp to the codepoint boundary, mirroring that here. $consumed = 0; $bytes = 0; - while ($consumed < $character && $bytes < strlen($lineText)) { - $bytes += self::utf8CharLength($lineText[$bytes]); - $consumed++; + $lineBytes = strlen($lineText); + while ($consumed < $character && $bytes < $lineBytes) { + $byteLen = self::utf8CharLength($lineText[$bytes]); + $units = $byteLen === 4 ? 2 : 1; + if ($consumed + $units > $character) { + break; + } + $bytes += $byteLen; + $consumed += $units; } return $lineStart + $bytes; } @@ -159,12 +170,21 @@ public function fullLineRangeFromNikic(int $nikicLine): array } /** - * Character count as the LSP client sees it. See class doc — MVP: UTF-8 chars. + * UTF-16 code-unit count -- the LSP wire encoding for column offsets. + * BMP characters (UTF-8 1/2/3-byte) contribute 1 unit each; + * supplementary-plane characters (UTF-8 4-byte) contribute 2 because + * UTF-16 represents them as a surrogate pair. */ private static function toLspCharacter(string $text): int { - // mb_strlen falls back to byte-length on missing extension; explicit utf-8 is safer. - return function_exists('mb_strlen') ? mb_strlen($text, 'UTF-8') : strlen($text); + $units = 0; + $len = strlen($text); + for ($i = 0; $i < $len;) { + $byteLen = self::utf8CharLength($text[$i]); + $units += $byteLen === 4 ? 2 : 1; + $i += $byteLen; + } + return $units; } /** diff --git a/tools/lsp/src/Reflection/FilesystemSourceLocator.php b/tools/lsp/src/Reflection/FilesystemSourceLocator.php new file mode 100644 index 00000000..1b320e14 --- /dev/null +++ b/tools/lsp/src/Reflection/FilesystemSourceLocator.php @@ -0,0 +1,93 @@ + path walk + + * map, parallel to the workspace-side walk in `WorkspaceSymbols` / the + * AST-attribute walk in `WorkspaceClassLikeLookup`. All three are now + * unified behind `FqnIndex`, with this locator a thin adapter that + * (a) asks for a path, (b) reads + strips the file content, + * (c) hands the result back to worse-reflection's locator chain. + * + * Behaviour preserved: + * - `.xphp` files go through `XphpSourceParser::strip()` so generic + * `<...>` clauses become equal-length whitespace and worse-reflection's + * tolerant parser ingests them cleanly. + * - `.php` files are returned as-is. + * - `SourceNotFound` surfaces when the FQN isn't indexed OR the indexed + * file disappeared from disk between map build and lookup. + * - `[xphp-lsp locator]` stderr traces still fire so production + * diagnostics from prior sessions are still grep-able. + */ +final class FilesystemSourceLocator implements SourceCodeLocator +{ + public function __construct( + private readonly FqnIndex $index, + private readonly XphpSourceParser $parser, + private readonly string $rootPath, + ) { + } + + public function locate(Name $name): TextDocument + { + $needle = ltrim((string) $name, '\\'); + $path = $this->index->pathFor($needle); + + if ($path === null) { + @fwrite(STDERR, sprintf( + "[xphp-lsp locator] miss %s (no declaration indexed under %s)\n", + $needle, + $this->rootPath, + )); + throw new SourceNotFound(sprintf( + 'No file under "%s" declares "%s"', + $this->rootPath, + $needle, + )); + } + + // Filesystem paths only -- open-doc URIs (file:// or otherwise) + // route through the `WorkspaceSourceLocator` upstream in the + // chain. pathFor() may return an open-doc URI; in that case + // bail and let worse-reflection's chain fall through. + if (str_contains($path, '://')) { + throw new SourceNotFound(sprintf( + 'FQN "%s" is open in workspace; defer to WorkspaceSourceLocator', + $needle, + )); + } + + $source = @file_get_contents($path); + if ($source === false) { + throw new SourceNotFound(sprintf( + 'Indexed source disappeared from disk: "%s"', + $path, + )); + } + + $stripped = self::shouldStrip($path) ? $this->parser->strip($source) : $source; + + return TextDocumentBuilder::create($stripped) + ->uri($path) + ->language('php') + ->build(); + } + + private static function shouldStrip(string $path): bool + { + return str_ends_with($path, '.xphp'); + } +} diff --git a/tools/lsp/src/Reflection/FqnIndex.php b/tools/lsp/src/Reflection/FqnIndex.php new file mode 100644 index 00000000..967ff350 --- /dev/null +++ b/tools/lsp/src/Reflection/FqnIndex.php @@ -0,0 +1,1378 @@ + declaration index, covering BOTH open documents + * (live via the workspace + cache) and on-disk files under rootPath + * (lazy filesystem walk). Single source of truth for every "where is + * `App\Containers\Collection` declared?" query in the LSP. + * + * Two consumers exist today: + * - `FilesystemSourceLocator` (worse-reflection adapter, wants stripped + * source by FQN) -- thin adapter on top of pathFor() + a file_get_contents. + * - `FilesystemClassLikeLookup` (GenericResolver dependency, wants the + * parsed `ClassLike` AST with xphp attributes intact) -- thin adapter + * on top of parsedFor(). + * + * Open-doc URIs win over filesystem paths when they collide on the same + * FQN: a generic class with unsaved edits in the editor reflects the + * editor state, not the on-disk state. + * + * Filesystem walk lifecycle: built lazily on first call, retained for the + * LSP session. A file watcher (`workspace/didChangeWatchedFiles`) is the + * follow-up (Phase 2.4 in the roadmap); for now an LSP restart picks up + * new files added to disk after session start. + * + * Skipped paths in the filesystem walk: + * - `.git`, `vendor`, `node_modules`, `var`, `build` + * + * Inherited from FilesystemSourceLocator's prior behaviour; same + * rationale (build artifacts, third-party trees, irrelevant binaries). + */ +final class FqnIndex +{ + private const SKIP_DIRS = ['.git', 'vendor', 'node_modules', 'var', 'build']; + + /** + * Path components skipped when their immediate parent matches. Captures + * test-fixture trees that declare classes with the same FQN as real + * source -- a common Composer-style layout where running the LSP over + * the xphp repo itself surfaced wrong-file GTD targets in prod traces + * (Phase 2.2 / 2.3). + * + * Format: `['parentBasename' => ['skippedChild', ...]]` + */ + private const SKIP_NESTED = [ + 'test' => ['fixture', 'fixtures'], + 'tests' => ['fixture', 'fixtures'], + ]; + + /** + * @var array|null FQN -> absolute filesystem path; null until first build. + */ + private ?array $filesystemMap = null; + + /** + * @var array|null FQN -> "class" or "function" kind for the filesystem entries. + */ + private ?array $filesystemKinds = null; + + /** + * @var array|null + * FQN -> declaration metadata for the filesystem entries. Carries the + * finer ClassLike kind (interface/trait/enum/class) + the identifier + * token's (line, char) so `allDeclarations()` can build a Location + * without re-parsing. Open-doc declarations are walked on demand and + * not stored here. + */ + private ?array $filesystemSymbols = null; + + /** + * @var list|null Absolute paths of every .xphp/.php file the + * walk visited, regardless of whether the file declared any FQNs. + * Used by `indexedFilesystemPaths()` -- find-references needs to + * see files that USE classes without declaring them (Consumer.xphp + * does `new App\User()` but defines nothing of its own). + */ + private ?array $filesystemWalkedPaths = null; + + /** + * @var array>|null FQN -> ordered list of generic-param names; null until first build. + * Populated alongside the filesystem walk so consumers like `GenericParamRegistry::prettify` + * don't trigger N additional parses to read `ATTR_GENERIC_PARAMS` per class. + */ + private ?array $filesystemGenericParams = null; + + /** + * @var array>|null synthetic-FQN -> param names for + * function- and method-scope generics (`function identity(...)`, + * `Util::identity(...)`). Same role as `filesystemGenericParams` + * but keyed under `Namespace\funcName` / `Namespace\Class::method` so + * it never collides with class entries. The registry only reads the + * namespace prefix to build its pair set, so the leaf shape doesn't + * matter beyond uniqueness. + */ + private ?array $filesystemFuncMethodGenericParams = null; + + /** + * @var array>|null fqn -> ordered bound FQNs per + * generic-param slot (null entries for unbounded params). Phase 3 + * bound-aware type-arg completion reads this to filter candidates + * against the slot's declared upper bound. + */ + private ?array $filesystemGenericBounds = null; + + public function __construct( + private readonly PhpactorWorkspace $workspace, + private readonly ParsedDocumentCache $cache, + private readonly XphpSourceParser $parser, + private readonly string $rootPath, + ) { + } + + /** + * Path to the declaration site for `$fqn`, or null if no declaration + * is known. Open-doc URIs win over filesystem paths. + */ + public function pathFor(string $fqn): ?string + { + $needle = ltrim($fqn, '\\'); + if ($needle === '') { + return null; + } + $uri = $this->openDocUriFor($needle); + if ($uri !== null) { + return $uri; + } + $filesystemMap = $this->filesystemMap(); + return $filesystemMap[$needle] ?? null; + } + + /** + * Locate the `Function_` AST for `$fqn` (free function, not method). + * Open-doc declarations win. Filesystem-only declarations parse on + * demand via `XphpSourceParser::parseTolerant()` so the function's + * `ATTR_METHOD_GENERIC_PARAMS` survives even for mid-edit sources. + * + * Used by `GenericResolver` to substitute type-args on generic- + * function call sites (`identity(...)`). Methods reuse + * `classLikeFor()` -> `findMethod` instead. + */ + public function functionFor(string $fqn): ?Function_ + { + $needle = ltrim($fqn, '\\'); + if ($needle === '') { + return null; + } + $hit = $this->openDocFunction($needle); + if ($hit !== null) { + return $hit; + } + $filesystemMap = $this->filesystemMap(); + if (!isset($filesystemMap[$needle])) { + return null; + } + return $this->functionFromFile($filesystemMap[$needle], $needle); + } + + /** + * Locate the `ClassLike` AST for `$fqn` with xphp attributes + * (`ATTR_GENERIC_PARAMS`, `ATTR_TEMPLATE_FQN`, ...) intact. Open-doc + * declarations short-circuit through the shared `ParsedDocumentCache`; + * filesystem-only declarations parse on demand via `XphpSourceParser` + * (tolerant) so attributes are still attached even on partially + * malformed sources. + */ + public function classLikeFor(string $fqn): ?ClassLike + { + $needle = ltrim($fqn, '\\'); + if ($needle === '') { + return null; + } + + // Open-doc first: live view of unsaved edits beats the on-disk copy. + $hit = $this->openDocClassLike($needle); + if ($hit !== null) { + return $hit; + } + + $filesystemMap = $this->filesystemMap(); + if (!isset($filesystemMap[$needle])) { + return null; + } + return $this->classLikeFromFile($filesystemMap[$needle], $needle); + } + + /** + * Every class/interface/trait FQN known to the index, from both open + * docs and the filesystem. De-duplicated; ordering is insertion-stable + * (open docs first, then filesystem in walk order). + * + * @return list + */ + public function allClassFqns(): array + { + $fqns = []; + foreach ($this->openDocClassFqns() as $fqn) { + $fqns[$fqn] = true; + } + foreach ($this->filesystemKinds() as $fqn => $kind) { + if ($kind === 'class') { + $fqns[$fqn] = true; + } + } + return array_keys($fqns); + } + + /** + * Yield every generic ClassLike's `(fqn, paramNames)` pair across both + * sources. Used by `GenericParamRegistry` to populate its + * placeholder lookup without doing its own workspace walk. + * + * Open-doc declarations are walked through `ParsedDocumentCache` so + * unsaved edits reflect immediately; filesystem-only declarations + * come from the pre-built generic-params map populated during + * `buildFilesystemIndex` (no extra parses). + * + * Open-doc wins on FQN collisions: a class declared both on disk and + * in an open buffer yields once with the open-doc's param list. + * + * @return iterable> yields `fqn => paramNames` + */ + public function iterGenericClasses(): iterable + { + $seen = []; + // Open docs first so their data wins on collision. + foreach ($this->workspace as $uri => $item) { + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + continue; + } + foreach (self::collectGenericClasses($result->ast) as $fqn => $paramNames) { + if (isset($seen[$fqn])) { + continue; + } + $seen[$fqn] = true; + yield $fqn => $paramNames; + } + } + foreach ($this->filesystemGenericParams() as $fqn => $paramNames) { + if (isset($seen[$fqn])) { + continue; + } + $seen[$fqn] = true; + yield $fqn => $paramNames; + } + } + + /** + * Like `iterGenericClasses`, but yields function- and method-scope + * generic placeholders -- the ones declared with + * `ATTR_METHOD_GENERIC_PARAMS` on `Function_` and `ClassMethod` nodes. + * + * Same shape (`syntheticFqn => paramNames`) so `GenericParamRegistry` + * can iterate without caring about the source: it derives + * `(namespace, paramName)` from the FQN's last-backslash split, and a + * synthetic key like `App\Demos\identity` or `App\Containers\Util::identity` + * yields the right namespace either way. + * + * Why this is separate from `iterGenericClasses`: class generics carry + * `ATTR_GENERIC_PARAMS`; function/method generics carry the distinct + * `ATTR_METHOD_GENERIC_PARAMS`. Different attributes, different walks -- + * keeping them as sibling iterators avoids overloading a single + * collector with two unrelated AST shapes. + * + * @return iterable> + */ + public function iterGenericFunctionsAndMethods(): iterable + { + $seen = []; + foreach ($this->workspace as $uri => $item) { + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + continue; + } + foreach (self::collectGenericFunctionsAndMethods($result->ast) as $fqn => $paramNames) { + if (isset($seen[$fqn])) { + continue; + } + $seen[$fqn] = true; + yield $fqn => $paramNames; + } + } + foreach ($this->filesystemFuncMethodGenericParams() as $fqn => $paramNames) { + if (isset($seen[$fqn])) { + continue; + } + $seen[$fqn] = true; + yield $fqn => $paramNames; + } + } + + /** + * Discard the cached filesystem index so the next query rebuilds it + * from a fresh walk under rootPath. Open-doc state is unaffected + * (it's already version-keyed through `ParsedDocumentCache`). + * + * Called by the file-watcher handler (Phase 2.4) on + * `workspace/didChangeWatchedFiles` events -- bulk invalidation is + * cheaper than surgical per-file updates given how fast the walk is + * (~100ms across the playground), and the next FqnIndex query is + * usually one keystroke away anyway. + */ + public function invalidateFilesystem(): void + { + $this->filesystemMap = null; + $this->filesystemKinds = null; + $this->filesystemGenericParams = null; + $this->filesystemFuncMethodGenericParams = null; + $this->filesystemGenericBounds = null; + $this->filesystemSymbols = null; + $this->filesystemWalkedPaths = null; + } + + /** + * Look up the bound FQN list for a generic class. Each entry is the + * declared upper bound for that slot (e.g. `Stringable` for + * `Box`), or `null` when the slot is unbounded. + * + * Open-doc declarations win over filesystem copies, matching the rest + * of the index. Returns `null` when no generic-class declaration is + * known for `$fqn` (non-generic class, unknown FQN, or parse failure). + * + * @return list|null + */ + public function boundsForGenericClass(string $fqn): ?array + { + $needle = ltrim($fqn, '\\'); + if ($needle === '') { + return null; + } + foreach ($this->workspace as $uri => $item) { + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + continue; + } + foreach (self::collectGenericClassBounds($result->ast) as $boundFqn => $bounds) { + if ($boundFqn === $needle) { + return $bounds; + } + } + } + return $this->filesystemGenericBoundsMap()[$needle] ?? null; + } + + /** + * @return array> + */ + private function filesystemGenericBoundsMap(): array + { + if ($this->filesystemGenericBounds === null) { + $this->buildFilesystemIndex(); + } + return $this->filesystemGenericBounds ?? []; + } + + /** + * Yield every declaration the index knows about, from both open docs and + * the filesystem. Used by `workspace/symbol` to filter and emit + * SymbolInformation across the workspace without each handler doing its + * own walk. + * + * Each entry carries: + * - `fqn` full backslash-qualified name (no leading `\`) + * - `kind` "class" | "interface" | "trait" | "enum" | "function" + * - `uri` file URI (open docs return the workspace URI; + * filesystem-only files return `file://` + absolute path) + * - `line` LSP-coords line (0-based) of the identifier token + * - `char` LSP-coords character offset of the identifier token + * + * Open-doc URIs win over filesystem paths when the same FQN is declared + * in both -- the editor view always reflects unsaved edits. + * + * @return iterable + */ + public function allDeclarations(): iterable + { + $seen = []; + foreach ($this->workspace as $uri => $item) { + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + continue; + } + $offsets = $result->byteOffsetMap; + foreach (self::collectSymbolHits($result->ast) as $hit) { + if (isset($seen[$hit['fqn']])) { + continue; + } + $seen[$hit['fqn']] = true; + $origByte = $offsets->toOriginal($hit['startByte']); + [$line, $char] = self::byteToLineChar($item->text, $origByte); + yield [ + 'fqn' => $hit['fqn'], + 'kind' => $hit['kind'], + 'uri' => (string) $uri, + 'line' => $line, + 'char' => $char, + ]; + } + } + foreach ($this->filesystemSymbols() as $fqn => $meta) { + if (isset($seen[$fqn])) { + continue; + } + $seen[$fqn] = true; + yield [ + 'fqn' => $fqn, + 'kind' => $meta['kind'], + 'uri' => 'file://' . ($this->filesystemMap()[$fqn] ?? ''), + 'line' => $meta['line'], + 'char' => $meta['char'], + ]; + } + } + + /** + * Locate the declaration of `$fqn` and return its identifier-token + * position. Open-doc declarations win; filesystem-only declarations + * fall through using the pre-built filesystem-symbols map. Returns + * null when no declaration is known. + * + * Used by the definition handler's Path 1 (generic-instantiation + * Name with ATTR_TEMPLATE_FQN) so GTD on `new Box<...>()` jumps to + * the `Box` template whether it's open or on disk. + * + * @return array{uri: string, line: int, char: int, short: string}|null + */ + public function locationForFqn(string $fqn): ?array + { + $needle = ltrim($fqn, '\\'); + if ($needle === '') { + return null; + } + foreach ($this->workspace as $uri => $item) { + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + continue; + } + $offsets = $result->byteOffsetMap; + foreach (self::collectSymbolHits($result->ast) as $hit) { + if ($hit['fqn'] !== $needle) { + continue; + } + $origByte = $offsets->toOriginal($hit['startByte']); + [$line, $char] = self::byteToLineChar($item->text, $origByte); + return [ + 'uri' => (string) $uri, + 'line' => $line, + 'char' => $char, + 'short' => self::shortOf($hit['fqn']), + ]; + } + } + $symbols = $this->filesystemSymbols(); + if (!isset($symbols[$needle])) { + return null; + } + $path = $this->filesystemMap()[$needle] ?? null; + if ($path === null) { + return null; + } + return [ + 'uri' => 'file://' . $path, + 'line' => $symbols[$needle]['line'], + 'char' => $symbols[$needle]['char'], + 'short' => self::shortOf($needle), + ]; + } + + /** + * Locate ANY declaration whose short name matches `$shortName`. Used + * by the definition handler's Path 2 (type-arg identifier inside a + * `<...>` clause -- the `User` of `identity(...)`) which the + * parser strips before the post-strip parser ever sees it, so we only + * have the short identifier source-text and need to resolve via + * workspace + filesystem lookup. + * + * Resolution order (Phase 3 polish): + * 1. Open-doc match wins. The editor's unsaved buffer beats any + * on-disk copy, full stop. + * 2. Among filesystem matches, shortest URI wins -- proxy for + * "closer to rootPath" since fs-walk emits absolute paths. + * Alphabetical tiebreak after length for determinism. + * + * @return array{uri: string, line: int, char: int, short: string}|null + */ + public function locationByShortName(string $shortName): ?array + { + if ($shortName === '') { + return null; + } + $tailSuffix = '\\' . $shortName; + $tailLen = strlen($tailSuffix); + $openDocHit = null; + $fsCandidates = []; + foreach ($this->allDeclarations() as $hit) { + $fqn = $hit['fqn']; + $matches = $fqn === $shortName + || (strlen($fqn) > $tailLen && substr($fqn, -$tailLen) === $tailSuffix); + if (!$matches) { + continue; + } + if (str_starts_with($hit['uri'], 'file://')) { + $fsCandidates[] = $hit; + } elseif ($openDocHit === null) { + $openDocHit = $hit; + } + } + if ($openDocHit !== null) { + return [ + 'uri' => $openDocHit['uri'], + 'line' => $openDocHit['line'], + 'char' => $openDocHit['char'], + 'short' => $shortName, + ]; + } + if ($fsCandidates === []) { + return null; + } + usort($fsCandidates, static function (array $a, array $b): int { + $byLength = strlen($a['uri']) <=> strlen($b['uri']); + return $byLength !== 0 ? $byLength : strcmp($a['uri'], $b['uri']); + }); + $best = $fsCandidates[0]; + return [ + 'uri' => $best['uri'], + 'line' => $best['line'], + 'char' => $best['char'], + 'short' => $shortName, + ]; + } + + /** + * Absolute paths of every .xphp/.php file the filesystem walk visited, + * INCLUDING files that didn't declare any FQNs (consumer-only files + * that reference classes without defining one). Open-doc URIs are + * NOT included -- callers needing both sources iterate `$workspace` + * separately first, then walk these paths skipping any already + * covered. + * + * Used by the find-references finder (Phase 4.1) to enumerate the + * cross-workspace search space without each consumer doing its own + * walk. + * + * @return list + */ + public function indexedFilesystemPaths(): array + { + if ($this->filesystemWalkedPaths === null) { + $this->buildFilesystemIndex(); + } + return $this->filesystemWalkedPaths ?? []; + } + + /** + * Every top-level function FQN known to the index, both sources. + * + * @return list + */ + public function allFunctionFqns(): array + { + $fqns = []; + foreach ($this->openDocFunctionFqns() as $fqn) { + $fqns[$fqn] = true; + } + foreach ($this->filesystemKinds() as $fqn => $kind) { + if ($kind === 'function') { + $fqns[$fqn] = true; + } + } + return array_keys($fqns); + } + + // -- open-doc side (cheap; re-walked per call, ParsedDocumentCache memoizes) ------- + + private function openDocUriFor(string $fqn): ?string + { + foreach ($this->workspace as $uri => $item) { + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + continue; + } + foreach (self::collectDeclarations($result->ast) as $declared => $_kind) { + if ($declared === $fqn) { + return (string) $uri; + } + } + } + return null; + } + + private function openDocFunction(string $fqn): ?Function_ + { + foreach ($this->workspace as $uri => $item) { + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + continue; + } + $hit = self::findFunctionInAst($result->ast, $fqn); + if ($hit !== null) { + return $hit; + } + } + return null; + } + + private function functionFromFile(string $path, string $needle): ?Function_ + { + $source = @file_get_contents($path); + if ($source === false) { + return null; + } + try { + $ast = $this->parser->parseTolerant($source); + } catch (\Throwable) { + return null; + } + if ($ast === null) { + return null; + } + return self::findFunctionInAst($ast, $needle); + } + + /** + * @param list $ast + */ + private static function findFunctionInAst(array $ast, string $needle): ?Function_ + { + $visitor = new class($needle) extends NodeVisitorAbstract { + public ?Function_ $found = null; + private string $currentNamespace = ''; + + public function __construct(private readonly string $needle) + { + } + + public function enterNode(Node $node): null + { + if ($this->found !== null) { + return null; + } + if ($node instanceof Namespace_) { + $this->currentNamespace = $node->name?->toString() ?? ''; + return null; + } + if (!$node instanceof Function_) { + return null; + } + $short = $node->name->toString(); + $fqn = $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $short + : $short; + if ($fqn === $this->needle) { + $this->found = $node; + } + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return $visitor->found; + } + + private function openDocClassLike(string $fqn): ?ClassLike + { + foreach ($this->workspace as $uri => $item) { + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + continue; + } + $hit = self::findClassLikeInAst($result->ast, $fqn); + if ($hit !== null) { + return $hit; + } + } + return null; + } + + /** + * @return list + */ + private function openDocClassFqns(): array + { + $fqns = []; + foreach ($this->workspace as $uri => $item) { + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + continue; + } + foreach (self::collectDeclarations($result->ast) as $fqn => $kind) { + if ($kind === 'class') { + $fqns[$fqn] = true; + } + } + } + return array_keys($fqns); + } + + /** + * @return list + */ + private function openDocFunctionFqns(): array + { + $fqns = []; + foreach ($this->workspace as $uri => $item) { + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + continue; + } + foreach (self::collectDeclarations($result->ast) as $fqn => $kind) { + if ($kind === 'function') { + $fqns[$fqn] = true; + } + } + } + return array_keys($fqns); + } + + // -- filesystem side (lazy, walked once per LSP session) --------------------------- + + /** + * @return array FQN -> path + */ + private function filesystemMap(): array + { + if ($this->filesystemMap === null) { + $this->buildFilesystemIndex(); + } + return $this->filesystemMap ?? []; + } + + /** + * @return array FQN -> "class" or "function" + */ + private function filesystemKinds(): array + { + if ($this->filesystemKinds === null) { + $this->buildFilesystemIndex(); + } + return $this->filesystemKinds ?? []; + } + + /** + * @return array> FQN -> ordered generic param names + */ + private function filesystemGenericParams(): array + { + if ($this->filesystemGenericParams === null) { + $this->buildFilesystemIndex(); + } + return $this->filesystemGenericParams ?? []; + } + + /** + * @return array> synthetic-FQN -> ordered param names + */ + private function filesystemFuncMethodGenericParams(): array + { + if ($this->filesystemFuncMethodGenericParams === null) { + $this->buildFilesystemIndex(); + } + return $this->filesystemFuncMethodGenericParams ?? []; + } + + /** + * @return array + */ + private function filesystemSymbols(): array + { + if ($this->filesystemSymbols === null) { + $this->buildFilesystemIndex(); + } + return $this->filesystemSymbols ?? []; + } + + private function buildFilesystemIndex(): void + { + $map = []; + $kinds = []; + $genericParams = []; + $funcMethodGenericParams = []; + $genericBounds = []; + $symbols = []; + $walkedPaths = []; + if (!is_dir($this->rootPath)) { + @fwrite(STDERR, sprintf( + "[xphp-lsp fqn-index] rootPath %s not a directory; filesystem index empty\n", + $this->rootPath, + )); + $this->filesystemMap = $map; + $this->filesystemKinds = $kinds; + $this->filesystemGenericParams = $genericParams; + $this->filesystemFuncMethodGenericParams = $funcMethodGenericParams; + $this->filesystemGenericBounds = $genericBounds; + $this->filesystemSymbols = $symbols; + $this->filesystemWalkedPaths = $walkedPaths; + return; + } + + $filesScanned = 0; + foreach ($this->iterator() as $file) { + /** @var SplFileInfo $file */ + $ext = $file->getExtension(); + if ($ext !== 'php' && $ext !== 'xphp') { + continue; + } + $filesScanned++; + $walkedPaths[] = $file->getPathname(); + + $source = @file_get_contents($file->getPathname()); + if ($source === false) { + continue; + } + + try { + $parsed = $this->parser->parseTolerantWithMap($source); + } catch (Throwable) { + continue; + } + if ($parsed === null) { + continue; + } + $ast = $parsed->ast; + $offsets = $parsed->byteOffsetMap; + + foreach (self::collectDeclarations($ast) as $fqn => $kind) { + $map[$fqn] = $file->getPathname(); + $kinds[$fqn] = $kind; + } + foreach (self::collectGenericClasses($ast) as $fqn => $paramNames) { + $genericParams[$fqn] = $paramNames; + } + foreach (self::collectGenericFunctionsAndMethods($ast) as $fqn => $paramNames) { + $funcMethodGenericParams[$fqn] = $paramNames; + } + foreach (self::collectGenericClassBounds($ast) as $fqn => $bounds) { + $genericBounds[$fqn] = $bounds; + } + foreach (self::collectSymbolHits($ast) as $hit) { + $origByte = $offsets->toOriginal($hit['startByte']); + [$line, $char] = self::byteToLineChar($source, $origByte); + $symbols[$hit['fqn']] = [ + 'kind' => $hit['kind'], + 'line' => $line, + 'char' => $char, + ]; + } + } + + @fwrite(STDERR, sprintf( + "[xphp-lsp fqn-index] indexed %d FQNs from %d files under %s (skipped: %s)\n", + count($map), + $filesScanned, + $this->rootPath, + implode(', ', self::SKIP_DIRS), + )); + + $this->filesystemMap = $map; + $this->filesystemKinds = $kinds; + $this->filesystemGenericParams = $genericParams; + $this->filesystemFuncMethodGenericParams = $funcMethodGenericParams; + $this->filesystemGenericBounds = $genericBounds; + $this->filesystemSymbols = $symbols; + $this->filesystemWalkedPaths = $walkedPaths; + } + + private function classLikeFromFile(string $path, string $needle): ?ClassLike + { + $source = @file_get_contents($path); + if ($source === false) { + return null; + } + try { + $ast = $this->parser->parseTolerant($source); + } catch (Throwable) { + return null; + } + if ($ast === null) { + return null; + } + return self::findClassLikeInAst($ast, $needle); + } + + private function iterator(): RecursiveIteratorIterator + { + $directoryIterator = new RecursiveDirectoryIterator( + $this->rootPath, + RecursiveDirectoryIterator::SKIP_DOTS, + ); + + $filter = new \RecursiveCallbackFilterIterator( + $directoryIterator, + static function (SplFileInfo $file): bool { + if (!$file->isDir()) { + return true; + } + $name = $file->getFilename(); + if (in_array($name, self::SKIP_DIRS, true)) { + return false; + } + $parent = basename($file->getPath()); + if (isset(self::SKIP_NESTED[$parent]) + && in_array($name, self::SKIP_NESTED[$parent], true) + ) { + return false; + } + return true; + }, + ); + + return new RecursiveIteratorIterator($filter); + } + + // -- AST helpers ------------------------------------------------------------------- + + /** + * Walk an AST collecting `FQN => "class"|"function"` for every ClassLike + * and top-level Function_ declaration. Methods and closures don't + * count -- only namespace-level functions. + * + * @param list $ast + * @return array + */ + private static function collectDeclarations(array $ast): array + { + $visitor = new class extends NodeVisitorAbstract { + /** @var array */ + public array $fqns = []; + + private string $currentNamespace = ''; + + public function enterNode(Node $node): null + { + if ($node instanceof Node\Stmt\Namespace_) { + $this->currentNamespace = $node->name?->toString() ?? ''; + return null; + } + $short = null; + $kind = null; + if ($node instanceof ClassLike && $node->name !== null) { + $short = $node->name->toString(); + $kind = 'class'; + } elseif ($node instanceof Function_) { + $short = $node->name->toString(); + $kind = 'function'; + } + if ($short !== null) { + $fqn = $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $short + : $short; + $this->fqns[$fqn] = $kind; + } + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return $visitor->fqns; + } + + /** + * Walk an AST collecting one entry per ClassLike / Function_ declaration + * with the finer ClassLike kind preserved (interface / trait / enum / + * class) and the identifier token's start byte (stripped-source coords -- + * caller back-translates through the byte-offset map before mapping to + * line/character). + * + * Used by `allDeclarations()` to power workspace/symbol search; the + * coarser `collectDeclarations()` path is kept for the existing FQN-only + * consumers. + * + * @param list $ast + * @return list + */ + private static function collectSymbolHits(array $ast): array + { + $visitor = new class extends NodeVisitorAbstract { + /** @var list */ + public array $hits = []; + + private string $currentNamespace = ''; + + public function enterNode(Node $node): null + { + if ($node instanceof Namespace_) { + $this->currentNamespace = $node->name?->toString() ?? ''; + return null; + } + $short = null; + $kind = null; + $startByte = null; + if ($node instanceof ClassLike && $node->name !== null) { + $short = $node->name->toString(); + $kind = match (true) { + $node instanceof Interface_ => 'interface', + $node instanceof Trait_ => 'trait', + $node instanceof Enum_ => 'enum', + default => 'class', + }; + $startByte = $node->name->getStartFilePos(); + } elseif ($node instanceof Function_) { + $short = $node->name->toString(); + $kind = 'function'; + $startByte = $node->name->getStartFilePos(); + } + if ($short !== null && $startByte !== null && $startByte >= 0) { + $fqn = $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $short + : $short; + $this->hits[] = [ + 'fqn' => $fqn, + 'kind' => $kind, + 'startByte' => $startByte, + ]; + } + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return $visitor->hits; + } + + private static function shortOf(string $fqn): string + { + $idx = strrpos($fqn, '\\'); + return $idx === false ? $fqn : substr($fqn, $idx + 1); + } + + /** + * Map a byte offset within `$source` to (line, character) in LSP + * 0-based coordinates. Simple O(byteOffset) scan -- we only call this + * for the small set of declaration identifiers, so a full PositionMap + * (which precomputes line starts up front) would be overkill. + * + * @return array{0: int, 1: int} + */ + private static function byteToLineChar(string $source, int $byteOffset): array + { + if ($byteOffset <= 0) { + return [0, 0]; + } + $clamped = min($byteOffset, strlen($source)); + $line = 0; + $lineStart = 0; + for ($i = 0; $i < $clamped; $i++) { + if ($source[$i] === "\n") { + $line++; + $lineStart = $i + 1; + } + } + return [$line, $clamped - $lineStart]; + } + + /** + * Walk an AST collecting `FQN => paramNames` for every generic ClassLike. + * Non-generic classes are skipped. Methods on generic classes don't + * count (method-scoped generics use a separate attribute path that this + * index doesn't surface). + * + * @param list $ast + * @return array> + */ + private static function collectGenericClasses(array $ast): array + { + $visitor = new class extends NodeVisitorAbstract { + /** @var array> */ + public array $fqns = []; + + private string $currentNamespace = ''; + + public function enterNode(Node $node): null + { + if ($node instanceof Namespace_) { + $this->currentNamespace = $node->name?->toString() ?? ''; + return null; + } + if (!$node instanceof ClassLike || $node->name === null) { + return null; + } + $params = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (!is_array($params) || $params === []) { + return null; + } + $paramNames = []; + foreach ($params as $p) { + if ($p instanceof TypeParam) { + $paramNames[] = $p->name; + } + } + if ($paramNames === []) { + return null; + } + $fqn = $node->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + if (!is_string($fqn)) { + // Generic classes always have ATTR_TEMPLATE_FQN stamped + // (set alongside ATTR_GENERIC_PARAMS), but fall back to + // namespace + short name just in case the parser ever + // diverges. + $short = $node->name->toString(); + $fqn = $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $short + : $short; + } + $this->fqns[$fqn] = $paramNames; + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return $visitor->fqns; + } + + /** + * Collect generic-class bound FQNs per slot. Mirrors + * `collectGenericClasses` but yields the bound side of each + * TypeParam (or null when the slot is unbounded) instead of the + * placeholder names. + * + * @param list $ast + * @return array> + */ + private static function collectGenericClassBounds(array $ast): array + { + $visitor = new class extends NodeVisitorAbstract { + /** @var array> */ + public array $fqns = []; + + private string $currentNamespace = ''; + + public function enterNode(Node $node): null + { + if ($node instanceof Namespace_) { + $this->currentNamespace = $node->name?->toString() ?? ''; + return null; + } + if (!$node instanceof ClassLike || $node->name === null) { + return null; + } + $params = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (!is_array($params) || $params === []) { + return null; + } + $bounds = []; + foreach ($params as $p) { + if ($p instanceof TypeParam) { + $bounds[] = $p->boundFqn !== null ? ltrim($p->boundFqn, '\\') : null; + } + } + if ($bounds === []) { + return null; + } + $fqn = $node->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + if (!is_string($fqn)) { + $short = $node->name->toString(); + $fqn = $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $short + : $short; + } + $this->fqns[$fqn] = $bounds; + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return $visitor->fqns; + } + + /** + * Walk an AST for function- and method-scope generic placeholders. + * + * Both `function identity(...)` and `Util::identity(...)` are + * stamped with `ATTR_METHOD_GENERIC_PARAMS` by `XphpSourceParser` + * (a single attribute for both shapes, since methods are functions + * with a receiver from the parser's POV). We surface them so + * `GenericParamRegistry::prettify` can strip the namespace prefix + * worse-reflection attaches to bare `T` references inside the body + * (`App\Demos\T` -> `T` for a function in `namespace App\Demos`). + * + * Key shape: `Namespace\funcName` for free functions, + * `Namespace\ClassName::methodName` for class methods -- the registry + * only needs the namespace prefix, but a unique-per-decl key keeps + * subsequent overwrites from losing data. + * + * @param list $ast + * @return array> + */ + private static function collectGenericFunctionsAndMethods(array $ast): array + { + $visitor = new class extends NodeVisitorAbstract { + /** @var array> */ + public array $fqns = []; + + private string $currentNamespace = ''; + + /** @var list enclosing class short-name stack (top = innermost) */ + private array $classStack = []; + + public function enterNode(Node $node): null + { + if ($node instanceof Namespace_) { + $this->currentNamespace = $node->name?->toString() ?? ''; + return null; + } + if ($node instanceof ClassLike) { + $this->classStack[] = $node->name?->toString() ?? ''; + return null; + } + if (!$node instanceof Function_ && !$node instanceof ClassMethod) { + return null; + } + $params = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + if (!is_array($params) || $params === []) { + return null; + } + $paramNames = []; + foreach ($params as $p) { + if ($p instanceof TypeParam) { + $paramNames[] = $p->name; + } + } + if ($paramNames === []) { + return null; + } + $declName = $node->name->toString(); + if ($node instanceof ClassMethod) { + $className = end($this->classStack); + if ($className === false || $className === '') { + return null; + } + $key = $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $className . '::' . $declName + : $className . '::' . $declName; + } else { + $key = $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $declName + : $declName; + } + $this->fqns[$key] = $paramNames; + return null; + } + + public function leaveNode(Node $node): null + { + if ($node instanceof ClassLike && $this->classStack !== []) { + array_pop($this->classStack); + } + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return $visitor->fqns; + } + + /** + * @param list $ast + */ + private static function findClassLikeInAst(array $ast, string $needle): ?ClassLike + { + $visitor = new class($needle) extends NodeVisitorAbstract { + public ?ClassLike $found = null; + + public function __construct(private readonly string $needle) + { + } + + public function enterNode(Node $node): null + { + if ($this->found !== null) { + return null; + } + if (!$node instanceof ClassLike) { + return null; + } + $fqn = $node->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + if (is_string($fqn) && $fqn === $this->needle) { + $this->found = $node; + return null; + } + // Fallback for non-generic classes that don't carry + // ATTR_TEMPLATE_FQN: reconstruct from namespace + short + // name walked from the AST itself. + $current = $node->name?->toString(); + if ($current !== null) { + $ns = self::namespaceOf($node); + $built = $ns !== '' ? $ns . '\\' . $current : $current; + if ($built === $this->needle) { + $this->found = $node; + } + } + return null; + } + + private static function namespaceOf(Node $node): string + { + $parent = $node->getAttribute('parent'); + while ($parent instanceof Node) { + if ($parent instanceof Node\Stmt\Namespace_) { + return $parent->name?->toString() ?? ''; + } + $parent = $parent->getAttribute('parent'); + } + return ''; + } + }; + + // Wrap the traversal so namespace context is tracked manually + // (NameResolver would be heavier than we need; ATTR_TEMPLATE_FQN + // covers the generic-class case and we only need the fallback for + // non-generic classes). + $tracker = new class($visitor) extends NodeVisitorAbstract { + private string $currentNamespace = ''; + + public function __construct(private readonly NodeVisitorAbstract $inner) + { + } + + public function enterNode(Node $node): null + { + if ($node instanceof Node\Stmt\Namespace_) { + $this->currentNamespace = $node->name?->toString() ?? ''; + return null; + } + if ($node instanceof ClassLike && $node->name !== null) { + if ($node->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN) === null) { + $short = $node->name->toString(); + $fqn = $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $short + : $short; + $node->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, $fqn); + } + } + $this->inner->enterNode($node); + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($tracker); + $traverser->traverse($ast); + return $visitor->found; + } +} diff --git a/tools/lsp/src/Reflection/ReflectorFactory.php b/tools/lsp/src/Reflection/ReflectorFactory.php new file mode 100644 index 00000000..6c35bced --- /dev/null +++ b/tools/lsp/src/Reflection/ReflectorFactory.php @@ -0,0 +1,245 @@ +|` queries the class repeatedly). For GTD / hover (one + * lookup per action) it's wasted complexity. Revisit if a profile shows + * repeated lookups in completion. + * + * Bootstrap caveat: `StubSourceLocator` needs a `Reflector` in its + * constructor (it uses it to discover FQNs in stub files when building + * the on-disk cache). This is a chicken-and-egg setup -- the obvious + * "single builder + addLocator(stub)" doesn't work. We resolve it by + * building a transient bootstrap reflector without stubs first, then + * using that to instantiate the stub locator, then re-building the + * final reflector with all three locators. The bootstrap reflector is + * thrown away. + */ +final class ReflectorFactory +{ + /** + * @param string $stubPath Path to `phpstorm-stubs/` root (the directory + * containing `Core/`, `standard/`, etc.). + * If empty or non-existent, the stub locator + * is omitted from the chain -- native + * function GTD will not resolve, but + * workspace / filesystem lookup still works. + * @param string $cacheDir Writable dir for the stub map cache. + */ + public function __construct( + private readonly PhpactorWorkspace $workspace, + private readonly ParsedDocumentCache $cache, + private readonly XphpSourceParser $parser, + private readonly string $rootPath, + private readonly string $stubPath, + private readonly string $cacheDir, + private readonly FqnIndex $fqnIndex, + ) { + } + + public function build(): Reflector + { + $workspaceLocator = new WorkspaceSourceLocator($this->workspace, $this->cache, $this->parser); + $filesystemLocator = new FilesystemSourceLocator($this->fqnIndex, $this->parser, $this->rootPath); + + $stubsAvailable = $this->stubPath !== '' && is_dir($this->stubPath); + + if (!$stubsAvailable) { + // No stubs available -- short-circuit, return a stubs-less reflector. + return ReflectorBuilder::create() + ->addLocator($workspaceLocator, priority: 100) + ->addLocator($filesystemLocator, priority: 50) + ->build(); + } + + // Bootstrap reflector: needed because StubSourceLocator's constructor + // accepts a Reflector to walk stub files during cache build. This + // throwaway reflector covers workspace + filesystem only; once the + // stub cache exists on disk (one-time build keyed by md5(stubPath)), + // subsequent runs hit the serialized map directly without touching + // this bootstrap. + $bootstrap = ReflectorBuilder::create() + ->addLocator($workspaceLocator, priority: 100) + ->addLocator($filesystemLocator, priority: 50) + ->build(); + + $this->ensureCacheDir(); + $stubLocator = new StubSourceLocator($bootstrap, $this->stubPath, $this->cacheDir); + + return ReflectorBuilder::create() + ->addLocator($workspaceLocator, priority: 100) + ->addLocator($filesystemLocator, priority: 50) + ->addLocator($stubLocator, priority: 25) + ->build(); + } + + private function ensureCacheDir(): void + { + if (is_dir($this->cacheDir)) { + return; + } + if (!@mkdir($this->cacheDir, 0o755, true) && !is_dir($this->cacheDir)) { + throw new RuntimeException(sprintf( + 'Could not create stub-cache directory at "%s"', + $this->cacheDir, + )); + } + } + + /** + * Default stubs path: `vendor/jetbrains/phpstorm-stubs/` relative to + * this file's location, which resolves correctly both in dev (file + * tree) and inside the built PHAR (composer's vendor dir is bundled). + * + * PHAR awareness: when running from a PHAR (the typical PhpStorm + * plugin install), `__DIR__` resolves to `phar://.../src/Reflection` + * and the stub directory beneath it is reachable via PHP's + * phar:// stream wrapper -- but Locations the LSP returns to the + * client carry that same phar:// URI, and PhpStorm's LSP framework + * warns `Unexpected URI scheme: phar://...` and refuses to open + * the file (no client-side navigation). To make native-function + * GTD usable, we extract the stub tree to a stable on-disk cache + * the first time we're called from inside a PHAR and return that + * file:// path instead. + */ + public static function defaultStubPath(): string + { + $candidate = __DIR__ . '/../../vendor/jetbrains/phpstorm-stubs'; + + // PHP's __DIR__ inside a PHAR begins with `phar://`. Detect that + // and route through the extractor so worse-reflection sees a real + // filesystem path. + if (str_starts_with($candidate, 'phar://')) { + return self::extractStubsCache($candidate); + } + + $real = realpath($candidate); + return $real !== false ? $real : $candidate; + } + + /** + * Recursively copy a source directory (typically a `phar://` URI) + * to a stable on-disk cache and return the cache path. Idempotent: + * once the extraction completes successfully, subsequent calls + * detect the sentinel marker and short-circuit. + * + * Cache layout: `/xphp-lsp-extracted-stubs//`. + * Keying by `sha-of-source` means a plugin upgrade (different PHAR + * path) gets a fresh cache without stepping on the prior install's + * extraction; orphaned caches from older installs are harmless. + * + * Extracted file count: phpstorm-stubs is ~3000 .php files, ~30 MB. + * Copy time on a warm SSD is sub-second. We accept the disk cost + * once per install because the alternative -- teaching IntelliJ's + * LSP client to read phar:// URIs -- requires either custom plugin + * code we can't unit-test or a JarFileSystem mount that doesn't + * work on PHARs (PHARs aren't ZIPs at the byte level despite the + * superficial format similarity). + */ + public static function extractStubsCache(string $sourceDir): string + { + $cacheRoot = sys_get_temp_dir() . '/xphp-lsp-extracted-stubs'; + $sourceHash = substr(sha1($sourceDir), 0, 16); + $cacheDir = $cacheRoot . '/' . $sourceHash; + + // Sentinel file marks a complete extraction. If interrupted + // mid-copy (process killed, disk full, ...) the sentinel won't + // be written and the next invocation re-extracts. + $sentinel = $cacheDir . '/.complete'; + if (is_file($sentinel)) { + return $cacheDir; + } + + if (!is_dir($cacheDir) && !@mkdir($cacheDir, 0o755, true) && !is_dir($cacheDir)) { + throw new RuntimeException(sprintf( + 'Could not create stub-extraction directory at "%s"', + $cacheDir, + )); + } + + self::copyDirRecursive($sourceDir, $cacheDir); + file_put_contents($sentinel, (string) time()); + + return $cacheDir; + } + + /** + * Recursive copy. Source MAY be a `phar://` URI -- PHP's stream + * wrapper handles iteration and read transparently. + */ + private static function copyDirRecursive(string $source, string $dest): void + { + if (!is_dir($source)) { + return; + } + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, + ); + $sourceLen = strlen($source); + foreach ($iterator as $item) { + /** @var SplFileInfo $item */ + $relative = substr($item->getPathname(), $sourceLen); + $target = $dest . $relative; + if ($item->isDir()) { + if (!is_dir($target) && !@mkdir($target, 0o755, true) && !is_dir($target)) { + throw new RuntimeException("Could not create directory: $target"); + } + continue; + } + if (@copy($item->getPathname(), $target) === false) { + throw new RuntimeException(sprintf( + 'Could not copy "%s" -> "%s"', + $item->getPathname(), + $target, + )); + } + } + } + + /** + * Default stub-map cache dir: a stable per-user temp directory. + * The map file inside is keyed by md5 of the stubs path, so multiple + * LSP versions / installs coexist cleanly. + */ + public static function defaultCacheDir(): string + { + return sys_get_temp_dir() . '/xphp-lsp-stub-cache'; + } +} diff --git a/tools/lsp/src/Reflection/WorkspaceSourceLocator.php b/tools/lsp/src/Reflection/WorkspaceSourceLocator.php new file mode 100644 index 00000000..1c1325b7 --- /dev/null +++ b/tools/lsp/src/Reflection/WorkspaceSourceLocator.php @@ -0,0 +1,129 @@ +` generic clauses via + * `XphpSourceParser::strip()`, and hand back a `TextDocument` with the + * stripped source. + * + * Byte offsets in the stripped source are identical to the original .xphp + * bytes (equal-length whitespace replacement), so any `Location` worse-reflection + * derives from the parsed source maps cleanly back onto the editor's view. + * + * Index strategy: lazy + per-version-keyed. We re-walk the workspace on each + * `locate()` call, but every `$cache->getOrParse()` short-circuits to the cached + * AST when the document version hasn't changed -- so the steady state is "N + * cheap dict lookups per locate" for N open documents. Building a persistent + * inverted index (FQN -> URI) is a follow-up if profiling shows this is hot. + * + * Functions and ClassLike-s are both indexed; constants are not (constant FQNs + * use a separate worse-reflection lookup path that we don't wire yet -- the + * MVP GTD scope doesn't include `const`). + */ +final class WorkspaceSourceLocator implements SourceCodeLocator +{ + public function __construct( + private readonly PhpactorWorkspace $workspace, + private readonly ParsedDocumentCache $cache, + private readonly XphpSourceParser $parser, + ) { + } + + public function locate(Name $name): TextDocument + { + $needle = ltrim((string) $name, '\\'); + if ($needle === '') { + throw new SourceNotFound('Empty name'); + } + + foreach ($this->workspace as $uri => $item) { + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + continue; + } + if (!self::declares($result->ast, $needle)) { + continue; + } + return TextDocumentBuilder::create($this->parser->strip($item->text)) + ->uri((string) $uri) + ->language('php') + ->build(); + } + + throw new SourceNotFound(sprintf( + 'No open workspace document declares "%s"', + $needle, + )); + } + + /** + * @param list $ast + */ + private static function declares(array $ast, string $fqn): bool + { + $visitor = new class($fqn) extends NodeVisitorAbstract { + public bool $found = false; + private string $currentNamespace = ''; + + public function __construct(private readonly string $needle) + { + } + + public function enterNode(Node $node): null + { + if ($this->found) { + return null; + } + if ($node instanceof Namespace_) { + $this->currentNamespace = $node->name?->toString() ?? ''; + return null; + } + if ($node instanceof ClassLike && $node->name !== null) { + if ($this->fqn($node->name->toString()) === $this->needle) { + $this->found = true; + } + return null; + } + if ($node instanceof Function_) { + if ($this->fqn($node->name->toString()) === $this->needle) { + $this->found = true; + } + } + return null; + } + + private function fqn(string $short): string + { + return $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $short + : $short; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return $visitor->found; + } +} diff --git a/tools/lsp/src/Resolver/ClassLikeLookup.php b/tools/lsp/src/Resolver/ClassLikeLookup.php new file mode 100644 index 00000000..2b5b03bd --- /dev/null +++ b/tools/lsp/src/Resolver/ClassLikeLookup.php @@ -0,0 +1,32 @@ + De-duplicated class FQNs from workspace + stubs. + */ + public function classFqns(): array + { + /** @var array $fqns */ + $fqns = []; + foreach ($this->workspaceSymbols->allClassFqns() as $fqn) { + $fqns[$fqn] = true; + } + foreach ($this->stubsIndex()->classes as $fqn) { + $fqns[$fqn] = true; + } + return array_keys($fqns); + } + + /** + * @return list De-duplicated function FQNs from workspace + stubs. + */ + public function functionFqns(): array + { + /** @var array $fqns */ + $fqns = []; + foreach ($this->workspaceSymbols->allFunctionFqns() as $fqn) { + $fqns[$fqn] = true; + } + foreach ($this->stubsIndex()->functions as $fqn) { + $fqns[$fqn] = true; + } + return array_keys($fqns); + } + + private function stubsIndex(): StubsIndex + { + if ($this->stubsCache === null) { + $this->stubsCache = StubsIndex::loadOrBuild($this->stubsDir); + } + return $this->stubsCache; + } +} diff --git a/tools/lsp/src/Resolver/CompositeClassLikeLookup.php b/tools/lsp/src/Resolver/CompositeClassLikeLookup.php new file mode 100644 index 00000000..cd8c9875 --- /dev/null +++ b/tools/lsp/src/Resolver/CompositeClassLikeLookup.php @@ -0,0 +1,41 @@ + */ + private array $lookups; + + public function __construct(ClassLikeLookup ...$lookups) + { + $this->lookups = array_values($lookups); + } + + public function find(string $fqn): ?ClassLike + { + foreach ($this->lookups as $lookup) { + $found = $lookup->find($fqn); + if ($found !== null) { + return $found; + } + } + return null; + } +} diff --git a/tools/lsp/src/Resolver/FilesystemClassLikeLookup.php b/tools/lsp/src/Resolver/FilesystemClassLikeLookup.php new file mode 100644 index 00000000..b76a85f4 --- /dev/null +++ b/tools/lsp/src/Resolver/FilesystemClassLikeLookup.php @@ -0,0 +1,34 @@ +index->classLikeFor($fqn); + } +} diff --git a/tools/lsp/src/Resolver/GenericParamRegistry.php b/tools/lsp/src/Resolver/GenericParamRegistry.php new file mode 100644 index 00000000..6d11ef5f --- /dev/null +++ b/tools/lsp/src/Resolver/GenericParamRegistry.php @@ -0,0 +1,133 @@ +`, so the LSP can render them + * sensibly in hover tooltips and completion details. + * + * Background: xphp's compile pipeline strips `` clauses to whitespace before + * worse-reflection sees the source. After strip, `function first(): ?T { ... }` + * leaves `T` standing as a normal class reference; worse-reflection resolves it + * against the surrounding namespace (`App\Containers`) -> `?App\Containers\T`. + * For the compiler that's fine -- monomorphization replaces these references + * at instantiation time -- but the LSP shows the unresolved form to the user. + * + * Phase 0.5: the registry now consumes `FqnIndex` so it sees both open-doc + * AND filesystem-only generic classes. Before this migration the registry + * walked the workspace itself and missed any class whose declaration wasn't + * currently open in the editor -- a real-world hit recorded in + * xphp-20260524-231944-855.log where `?App\Containers\T` leaked into hover + * because Collection.xphp wasn't open. + * + * The pair set ((namespace, paramName) tuples) is built once per (workspace + * state, filesystem state) and cached. Filesystem-side data persists for + * the LSP session (the index doesn't watch for new files); open-doc data + * picks up changes through `ParsedDocumentCache`'s version keying. + */ +final class GenericParamRegistry +{ + /** + * @var array|null cache of "Namespace|ParamName" keys; null until first build. + */ + private ?array $pairs = null; + + public function __construct( + private readonly FqnIndex $fqnIndex, + ) { + } + + /** + * Rewrite a type string so generic-placeholder references display + * without their namespace prefix. Idempotent and side-effect-free. + * + * `?App\Containers\T` -> `?T` + * `App\Containers\T` -> `T` + * `App\Models\User` -> `App\Models\User` (User isn't a known placeholder) + * `array` -> `array` (when both are placeholders) + * + * Operates on each `\`-qualified name inside the input independently; + * non-name characters (`?`, `<`, `>`, `,`, whitespace) act as + * delimiters. Match is case-sensitive (PHP class names are + * case-insensitive in practice, but in xphp source the convention is + * single-uppercase placeholders, and we never want to mangle a + * non-placeholder by accident). + */ + public function prettify(string $type): string + { + if ($type === '' || $type === '') { + return $type; + } + $pairs = $this->pairs(); + if ($pairs === []) { + return $type; + } + // Match any sequence of `\`-separated identifier chars. Identifier + // chars match PHP's namespace grammar: letters, digits, underscore. + return preg_replace_callback( + '/[A-Za-z_][A-Za-z0-9_]*(?:\\\\[A-Za-z_][A-Za-z0-9_]*)+/', + function (array $m) use ($pairs): string { + $name = $m[0]; + $sep = strrpos($name, '\\'); + if ($sep === false) { + return $name; + } + $namespace = substr($name, 0, $sep); + $leaf = substr($name, $sep + 1); + return isset($pairs[$namespace . '|' . $leaf]) ? $leaf : $name; + }, + $type, + ) ?? $type; + } + + /** + * @return array "Namespace|ParamName" -> true + */ + private function pairs(): array + { + if ($this->pairs !== null) { + return $this->pairs; + } + $pairs = []; + // Class-level placeholders (Collection): T inside method bodies + // resolves to `App\Containers\T`; pair set strips back to `T`. + foreach ($this->fqnIndex->iterGenericClasses() as $fqn => $paramNames) { + $sep = strrpos($fqn, '\\'); + $namespace = $sep === false ? '' : substr($fqn, 0, $sep); + foreach ($paramNames as $paramName) { + $pairs[$namespace . '|' . $paramName] = true; + } + } + // Function- and method-scope placeholders (identity(...), + // Util::identity(...)): T inside the body resolves to the + // ENCLOSING namespace's `T`, distinct from any class-level T. + // Without this entry, hover on the function/method declaration + // line leaks the namespace-doubled placeholder (App\Demos\T). + foreach ($this->fqnIndex->iterGenericFunctionsAndMethods() as $fqn => $paramNames) { + $sep = strrpos($fqn, '\\'); + $namespace = $sep === false ? '' : substr($fqn, 0, $sep); + foreach ($paramNames as $paramName) { + $pairs[$namespace . '|' . $paramName] = true; + } + } + $this->pairs = $pairs; + return $pairs; + } + + /** + * Drop the cached pair set so the next call re-walks the index. + * Useful when tests want to add a new generic class after construction. + * In production the cache is rebuilt naturally because FqnIndex's + * open-doc walk uses version-keyed `ParsedDocumentCache`, and + * filesystem-side data is session-scoped. + */ + public function invalidate(): void + { + $this->pairs = null; + } +} diff --git a/tools/lsp/src/Resolver/GenericResolver.php b/tools/lsp/src/Resolver/GenericResolver.php new file mode 100644 index 00000000..28ab4608 --- /dev/null +++ b/tools/lsp/src/Resolver/GenericResolver.php @@ -0,0 +1,1525 @@ +` clauses are + * blanked to whitespace -- so the type-arg context (`T = User`) baked into + * `new Collection(...)` is gone by the time it computes the inferred + * type of a downstream variable. Result: `$user = $users->first()` shows + * `?T` (the unresolved placeholder) instead of `?User` (the substituted + * concrete). + * + * This resolver walks the *unstripped* AST that `XphpSourceParser` cached + * via `ParsedDocumentCache` -- which still carries `ATTR_GENERIC_ARGS` on + * `new` Name nodes and `ATTR_GENERIC_PARAMS` on ClassLike templates -- + * tracks per-variable bindings, and substitutes via the same + * `Specializer::substituteTypeRef` the compile-time monomorphizer uses. + * That keeps LSP-time and compile-time substitution semantics in lockstep + * (a divergence here would be a bug factory). + * + * Scope model (Phase 1.1): + * + * Bindings live in a list of `Scope`s -- one top-level scope covering + * the whole document, plus one nested scope per `function` / class + * method body. At lookup time the resolver picks the innermost scope + * containing the cursor offset and reads bindings from there. No + * parent-chain yet: two functions with conflicting `$x` types don't + * leak across each other, but a closure can't see its outer scope + * either (that's Phase 1.5's job). + * + * Supported shapes (each contributes a binding into the current scope): + * + * - `$x = new Generic(...);` (top-level or in-function) + * - `$y = $x->method(...);` (in-scope receiver) + * - `function f(Generic $param)` (Phase 1.1) + * - `$y = Cls::method(...);` (Phase 1.2) + * - `$y = generic_fn(...);` (Phase 1.3) + * - `$y = $x->a()->b()->...->c();` (Phase 1.4: chained calls) + * - `function () use ($captured) { ... }` (Phase 1.5: closure capture) + * + * Internally, Phase 1.4 introduced a recursive `inferType(Node, Env)` + * that types arbitrary expressions -- variables, `new`, method calls, + * static calls, generic functions -- so chained calls of any depth + * compose cleanly. Each call site builds an Env from the current scope's + * bindings + lookups; the recursion handles n-step chains identically + * to the 1-step case. + * + * Out of scope (returns null, fallback to GenericParamRegistry::prettify): + * - arrow functions `fn ($x) => $x->method()` -- different scoping rules + * - `$this` binding inside non-static methods + */ +final class GenericResolver +{ + /** + * @var array}>}> + */ + private array $cache = []; + + public function __construct( + private readonly PhpactorWorkspace $workspace, + private readonly ParsedDocumentCache $documents, + private readonly ClassLikeLookup $classes, + private readonly XphpSourceParser $parser, + private readonly FqnIndex $fqnIndex, + ) { + } + + /** + * Return the substituted display string for `$varName` at byte offset + * `$byteOffset`, or null when the resolver can't model the variable's + * binding (caller falls back to its existing path). + */ + public function resolveVariable(string $uri, string $varName, int $byteOffset): ?string + { + $binding = $this->bindingAt($uri, $varName, $byteOffset); + if ($binding === null) { + return null; + } + // A direct `$x = new Generic<...>(...)` produces a VarBinding -- we + // can render the receiver type with its type-arg list spelt out, + // e.g. "App\Containers\Collection". The existing + // path renders just `App\Containers\Collection` (the worse-reflection + // view); ours adds the type-arg context. + if ($binding instanceof VarBinding) { + return $this->renderBinding($binding); + } + return $binding->render(); + } + + /** + * Resolve the substituted concrete type of `$varName` at `$byteOffset` + * as a `ResolvedType`. Mirrors `resolveVariable` but returns the + * underlying `{TypeRef, nullable}` so the completion path can read + * `ref->name` directly for `reflectClassLike` without parsing the + * display string back. + */ + public function resolveVariableTypeRef(string $uri, string $varName, int $byteOffset): ?ResolvedType + { + $binding = $this->bindingAt($uri, $varName, $byteOffset); + if ($binding === null) { + return null; + } + // VarBinding: variable holds an instance of the generic class + // itself. Surface it as a non-nullable TypeRef of the class FQN + // so the completion path can reflect on the class directly. + if ($binding instanceof VarBinding) { + return new ResolvedType(new TypeRef($binding->classFqn), false); + } + // ResolvedType: variable holds the substituted result of a prior + // method call. Return as-is. + return $binding; + } + + /** + * Resolve the substituted-type view of the `MethodCall` enclosing + * `$byteOffset` -- both the return type AND each parameter type -- + * so hover can render the full signature with the call site's + * type-args baked in. Returns null when the cursor isn't on a + * method-call name, the receiver isn't a tracked variable, or the + * method can't be located. + * + * Parameter substitution mirrors return-type substitution: both + * read the method's source AST (xphp attributes intact via + * `ClassLikeLookup`) and substitute through + * `Specializer::substituteTypeRef`. Unlike the return type, params + * have names attached so they're returned as a `name -> type` map. + * Params whose declared type can't be modelled (e.g. union types) + * are omitted from the map; caller falls back to prettify for those. + */ + /** + * Same shape as `resolveMethodCallSubstitutionAt` but for static calls + * `Foo::method($x)`. The "receiver" type-args come from the + * call's own `ATTR_METHOD_GENERIC_ARGS` (method-scoped generics), not + * from a bound variable's type. Returns null when the cursor isn't + * on a static method-call's name token, when the call isn't generic + * (no `` annotation), or when class / method lookup fails. + */ + public function resolveStaticCallSubstitutionAt(string $uri, int $byteOffset): ?MethodCallSubstitution + { + if (!$this->workspace->has($uri)) { + return null; + } + $item = $this->workspace->get($uri); + $result = $this->documents->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + return null; + } + $call = self::findEnclosingStaticCallNameAt($result->ast, $byteOffset); + if ($call === null || !$call->class instanceof Name || !$call->name instanceof Identifier) { + return null; + } + $args = $call->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + if (!is_array($args) || $args === []) { + return null; + } + [$useMap, $namespace] = self::useMapAndNamespaceFor($result->ast); + $classFqn = self::resolveNameWithUseMap($call->class, $useMap, $namespace); + if ($classFqn === null) { + return null; + } + $classLike = $this->classes->find($classFqn); + if ($classLike === null) { + return null; + } + $method = self::findMethod($classLike, $call->name->toString()); + if ($method === null) { + return null; + } + $params = $method->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + if (!is_array($params) || count($params) !== count($args)) { + return null; + } + $paramMap = []; + $paramNames = []; + foreach ($params as $i => $param) { + if (!$param instanceof TypeParam || !($args[$i] instanceof TypeRef)) { + return null; + } + $paramMap[$param->name] = $args[$i]; + $paramNames[] = $param->name; + } + return self::buildSubstitutionFromMap($method, $paramMap, $paramNames); + } + + /** + * Same shape but for free-function calls `identity($x)`. Type-args + * come from the call's `ATTR_METHOD_GENERIC_ARGS`; the function's + * `ATTR_METHOD_GENERIC_PARAMS` provides the names. Function lookup + * uses `FqnIndex::functionFor` so filesystem-only declarations work. + */ + public function resolveFunctionCallSubstitutionAt(string $uri, int $byteOffset): ?MethodCallSubstitution + { + if (!$this->workspace->has($uri)) { + return null; + } + $item = $this->workspace->get($uri); + $result = $this->documents->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + return null; + } + $call = self::findEnclosingFuncCallNameAt($result->ast, $byteOffset); + if ($call === null || !$call->name instanceof Name) { + return null; + } + $args = $call->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + if (!is_array($args) || $args === []) { + return null; + } + [$useMap, $namespace] = self::useMapAndNamespaceFor($result->ast); + $fnFqn = self::resolveNameWithUseMap($call->name, $useMap, $namespace); + if ($fnFqn === null) { + return null; + } + $function = $this->fqnIndex->functionFor($fnFqn); + if ($function === null) { + return null; + } + $params = $function->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + if (!is_array($params) || count($params) !== count($args)) { + return null; + } + $paramMap = []; + $paramNames = []; + foreach ($params as $i => $param) { + if (!$param instanceof TypeParam || !($args[$i] instanceof TypeRef)) { + return null; + } + $paramMap[$param->name] = $args[$i]; + $paramNames[] = $param->name; + } + return self::buildSubstitutionFromMap($function, $paramMap, $paramNames); + } + + public function resolveMethodCallSubstitutionAt(string $uri, int $byteOffset): ?MethodCallSubstitution + { + if (!$this->workspace->has($uri)) { + return null; + } + $item = $this->workspace->get($uri); + $scopes = $this->scopesFor($uri, $item->version, $item->text); + $bindings = self::bindingsAt($scopes, $byteOffset); + + $result = $this->documents->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + return null; + } + $call = self::findEnclosingMethodCallNameAt($result->ast, $byteOffset); + if ($call === null) { + return null; + } + + // To substitute params (not just the return type) we need both + // the method's ClassMethod AST node AND the receiver's paramMap. + // resolveMethodCall encapsulates the receiver lookup; reuse its + // logic by infer-then-bind manually here. + $receiverType = self::inferType($call->var, $bindings, $this->classes, $this->fqnIndex, [], ''); + if ($receiverType === null) { + return null; + } + $classLike = $this->classes->find($receiverType->ref->name); + if ($classLike === null) { + return null; + } + if (!$call->name instanceof Identifier) { + return null; + } + $method = self::findMethod($classLike, $call->name->toString()); + if ($method === null) { + return null; + } + $paramMap = self::paramMapFromReceiver($classLike, $receiverType); + $paramNames = array_keys($paramMap); + + // Return type. + $returnTypeRendered = null; + if ($method->returnType !== null) { + $tuple = self::returnTypeToRef($method->returnType, $paramNames); + if ($tuple !== null) { + [$nullable, $ref] = $tuple; + $substituted = Specializer::substituteTypeRef($ref, $paramMap); + $returnTypeRendered = (new ResolvedType($substituted, $nullable))->render(); + } + } + + // Parameter types -- one entry per parameter that has a modelable + // type annotation. Params with omitted or unsupported types + // (union, intersection) get no entry; renderMethod falls back to + // prettify for them. + $paramTypes = []; + foreach ($method->params as $param) { + $name = $param->var instanceof Node\Expr\Variable && is_string($param->var->name) + ? $param->var->name + : null; + if ($name === null || $param->type === null) { + continue; + } + $tuple = self::returnTypeToRef($param->type, $paramNames); + if ($tuple === null) { + continue; + } + [$nullable, $ref] = $tuple; + $substituted = Specializer::substituteTypeRef($ref, $paramMap); + $paramTypes[$name] = (new ResolvedType($substituted, $nullable))->render(); + } + + return new MethodCallSubstitution($returnTypeRendered, $paramTypes); + } + + /** + * Resolve the FQN of the class that should host a member-access + * completion at `$byteOffset`. Generalises the existing + * `resolveVariableTypeRef` swap in `PhpCompletionResolver`: walks + * the AST for the innermost expression whose range covers the + * offset (Variable, MethodCall, StaticCall, FuncCall, New_, ...) + * and runs `inferType` on it. Returns the substituted class FQN + * or null when no usable type can be inferred. + * + * This is the completion-side parallel of Phase 0.7's hover/GTD + * `resolvePropertyReceiverClassAt`: same semantic question -- "what + * class are we calling/accessing through?" -- different consumer + * (completion sees mid-edit incomplete source where the property + * name token doesn't exist yet, so we can't walk for `PropertyFetch`; + * we walk for the expression whose end is just before the access + * operator instead). + */ + public function resolveMemberAccessReceiverClassAt(string $uri, int $byteOffset): ?string + { + if (!$this->workspace->has($uri)) { + return null; + } + $item = $this->workspace->get($uri); + $scopes = $this->scopesFor($uri, $item->version, $item->text); + $bindings = self::bindingsAt($scopes, $byteOffset); + + $result = $this->documents->getOrParse($uri, $item->version, $item->text); + $ast = $result->ast; + if ($ast === null) { + try { + $ast = $this->parser->parseTolerant($item->text); + } catch (\Throwable) { + $ast = null; + } + if ($ast === null) { + return null; + } + } + + $expr = self::findInnermostExprCovering($ast, $byteOffset); + if ($expr === null) { + return null; + } + // Completion fires mid-edit, so the innermost expression at the + // receiverProbe offset may be an incomplete PropertyFetch / + // NullsafePropertyFetch / MethodCall whose name token is missing + // (the prefix the user is about to type). In that case unwrap to + // the receiver -- the expression we actually want to type for the + // class lookup. `inferType` only knows about "complete" leaf + // shapes; this is the one place that handles the mid-edit + // partial-AST case. + if ($expr instanceof PropertyFetch || $expr instanceof NullsafePropertyFetch) { + $expr = $expr->var; + } elseif ($expr instanceof MethodCall || $expr instanceof Node\Expr\NullsafeMethodCall) { + // If the method-call's name token is missing OR the cursor sits + // past the call's closing paren (in a chained access shape), + // type the call itself. Otherwise (cursor inside the args) + // type the receiver. + if (!$expr->name instanceof Identifier) { + $expr = $expr->var; + } + } + $type = self::inferType($expr, $bindings, $this->classes, $this->fqnIndex, [], ''); + if ($type === null) { + return null; + } + $fqn = $type->ref->name; + if ($fqn === '' || $type->ref->isScalar || $type->ref->isTypeParam) { + return null; + } + return $fqn; + } + + /** + * Walk for the innermost Expression node whose `[startFilePos, + * endFilePos]` covers `$byteOffset`. Used by member-access + * completion to find the receiver expression when the cursor sits + * past the access operator. + * + * @param list $ast + */ + private static function findInnermostExprCovering(array $ast, int $byteOffset): ?Node\Expr + { + $visitor = new class($byteOffset) extends NodeVisitorAbstract { + public ?Node\Expr $best = null; + private int $bestRange = PHP_INT_MAX; + + public function __construct(private readonly int $offset) + { + } + + public function enterNode(Node $node): null + { + if (!$node instanceof Node\Expr) { + return null; + } + $start = $node->getStartFilePos(); + $end = $node->getEndFilePos(); + if ($start < 0 || $end < 0) { + return null; + } + if ($this->offset < $start || $this->offset > $end) { + return null; + } + $range = $end - $start; + if ($range < $this->bestRange) { + $this->best = $node; + $this->bestRange = $range; + } + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return $visitor->best; + } + + /** + * Resolve the FQN of the class hosting a property access at + * `$byteOffset`. For `$users->first()?->name` (where + * `$users: Collection` and `Collection::first(): ?T`), the + * receiver of `?->name` is the substituted method-call result + * `?User`; this method returns `App\Models\User` so the caller + * (hover / GTD) can look up the `name` property on the right class. + * + * Returns null when the cursor isn't on a property name token, when + * the receiver expression can't be typed, or when substitution + * doesn't yield a class (e.g. scalar receiver, or the receiver + * still resolves to a bare placeholder with no in-scope binding). + * + * Used by `PhpHoverResolver::renderProperty` and + * `PhpDefinitionResolver::locateProperty` to override + * worse-reflection's view of the receiver class when the resolver + * has more accurate information. + */ + public function resolvePropertyReceiverClassAt(string $uri, int $byteOffset): ?string + { + if (!$this->workspace->has($uri)) { + return null; + } + $item = $this->workspace->get($uri); + $scopes = $this->scopesFor($uri, $item->version, $item->text); + $bindings = self::bindingsAt($scopes, $byteOffset); + + $result = $this->documents->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + return null; + } + $fetch = self::findEnclosingPropertyFetchNameAt($result->ast, $byteOffset); + if ($fetch === null) { + return null; + } + $receiverType = self::inferType( + $fetch->var, + $bindings, + $this->classes, + $this->fqnIndex, + [], + '', + ); + if ($receiverType === null) { + return null; + } + // The class FQN is in `ref->name`; nullability and args don't + // affect the property's host class (a nullable receiver still + // accesses the same class's properties). Scalars and + // type-params with no concrete binding don't yield a usable + // FQN -- skip those. + $fqn = $receiverType->ref->name; + if ($fqn === '' || $receiverType->ref->isScalar || $receiverType->ref->isTypeParam) { + return null; + } + return $fqn; + } + + /** + * Walk the AST looking for a `PropertyFetch` or `NullsafePropertyFetch` + * whose property-name identifier covers `$byteOffset`. The cursor + * must land ON the name token; landing on the receiver expression + * returns null. Mirrors `findEnclosingMethodCallNameAt`'s shape. + * + * @param list $ast + */ + private static function findEnclosingPropertyFetchNameAt(array $ast, int $byteOffset): PropertyFetch|NullsafePropertyFetch|null + { + $visitor = new class($byteOffset) extends NodeVisitorAbstract { + public PropertyFetch|NullsafePropertyFetch|null $hit = null; + + public function __construct(private readonly int $offset) + { + } + + public function enterNode(Node $node): null + { + if ($this->hit !== null) { + return null; + } + if (!$node instanceof PropertyFetch && !$node instanceof NullsafePropertyFetch) { + return null; + } + $name = $node->name; + if (!$name instanceof Identifier) { + return null; + } + $start = $name->getStartFilePos(); + $end = $name->getEndFilePos(); + if ($start < 0 || $end < 0) { + return null; + } + if ($this->offset >= $start && $this->offset <= $end + 1) { + $this->hit = $node; + } + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return $visitor->hit; + } + + /** + * Lookup binding for `$varName` at `$byteOffset` (innermost scope wins). + */ + private function bindingAt(string $uri, string $varName, int $byteOffset): VarBinding|ResolvedType|null + { + if (!$this->workspace->has($uri)) { + return null; + } + $item = $this->workspace->get($uri); + $scopes = $this->scopesFor($uri, $item->version, $item->text); + $bindings = self::bindingsAt($scopes, $byteOffset); + return $bindings[$varName] ?? null; + } + + /** + * Walk the AST looking for a `MethodCall` whose method-name identifier + * covers `$byteOffset`. The cursor must land ON the name token -- + * landing on the receiver or the call's args returns null. Matches + * IDE behaviour where "hover the method" means hovering its identifier. + * + * @param list $ast + */ + private static function findEnclosingMethodCallNameAt(array $ast, int $byteOffset): ?MethodCall + { + $visitor = new class($byteOffset) extends NodeVisitorAbstract { + public ?MethodCall $hit = null; + + public function __construct(private readonly int $offset) + { + } + + public function enterNode(Node $node): null + { + if ($this->hit !== null) { + return null; + } + if (!$node instanceof MethodCall) { + return null; + } + $name = $node->name; + if (!$name instanceof Identifier) { + return null; + } + $start = $name->getStartFilePos(); + $end = $name->getEndFilePos(); + if ($start < 0 || $end < 0) { + return null; + } + // Inclusive end-of-token + 1 for cursor-just-past-end + // (LSP positions point between characters, not at them). + if ($this->offset >= $start && $this->offset <= $end + 1) { + $this->hit = $node; + } + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return $visitor->hit; + } + + /** + * Mirror of `findEnclosingMethodCallNameAt` for static calls + * (`Foo::method(...)`). + * + * @param list $ast + */ + private static function findEnclosingStaticCallNameAt(array $ast, int $byteOffset): ?StaticCall + { + $visitor = new class($byteOffset) extends NodeVisitorAbstract { + public ?StaticCall $hit = null; + + public function __construct(private readonly int $offset) + { + } + + public function enterNode(Node $node): null + { + if ($this->hit !== null || !$node instanceof StaticCall) { + return null; + } + $name = $node->name; + if (!$name instanceof Identifier) { + return null; + } + $start = $name->getStartFilePos(); + $end = $name->getEndFilePos(); + if ($start < 0 || $end < 0) { + return null; + } + if ($this->offset >= $start && $this->offset <= $end + 1) { + $this->hit = $node; + } + return null; + } + }; + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return $visitor->hit; + } + + /** + * Same shape but for free-function calls (`identity(...)`). + * + * @param list $ast + */ + private static function findEnclosingFuncCallNameAt(array $ast, int $byteOffset): ?Node\Expr\FuncCall + { + $visitor = new class($byteOffset) extends NodeVisitorAbstract { + public ?Node\Expr\FuncCall $hit = null; + + public function __construct(private readonly int $offset) + { + } + + public function enterNode(Node $node): null + { + if ($this->hit !== null || !$node instanceof Node\Expr\FuncCall) { + return null; + } + $name = $node->name; + if (!$name instanceof Name) { + return null; + } + $start = $name->getStartFilePos(); + $end = $name->getEndFilePos(); + if ($start < 0 || $end < 0) { + return null; + } + if ($this->offset >= $start && $this->offset <= $end + 1) { + $this->hit = $node; + } + return null; + } + }; + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return $visitor->hit; + } + + /** + * Walk the AST collecting namespace + use map at every position. + * Returns (useMap, currentNamespace) effective when the document is + * processed top-to-bottom -- which matches PHP's resolution rules. + * The maps are file-scoped: the same use stmt may not be in effect + * elsewhere, but for a single-file resolution this is enough. + * + * @param list $ast + * @return array{0: array, 1: string} + */ + private static function useMapAndNamespaceFor(array $ast): array + { + $useMap = []; + $namespace = ''; + $visitor = new class($useMap, $namespace) extends NodeVisitorAbstract { + /** @param array $useMap */ + public function __construct( + public array &$useMap, + public string &$namespace, + ) { + } + + public function enterNode(Node $node): null + { + if ($node instanceof Node\Stmt\Namespace_) { + $this->namespace = $node->name?->toString() ?? ''; + return null; + } + if ($node instanceof Node\Stmt\Use_) { + foreach ($node->uses as $useUse) { + $alias = $useUse->getAlias()->toString(); + $this->useMap[$alias] = $useUse->name->toString(); + } + return null; + } + if ($node instanceof Node\Stmt\GroupUse) { + $prefix = $node->prefix->toString(); + foreach ($node->uses as $useUse) { + $alias = $useUse->getAlias()->toString(); + $this->useMap[$alias] = $prefix . '\\' . $useUse->name->toString(); + } + } + return null; + } + }; + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return [$useMap, $namespace]; + } + + /** + * Shared body for `resolveStaticCallSubstitutionAt` / + * `resolveFunctionCallSubstitutionAt` (and conceptually the existing + * instance-method path). Given a ClassMethod or Function_ AST node + * with a precomputed `paramName -> TypeRef` map, render the + * substituted return type + each substituted parameter type into a + * `MethodCallSubstitution` value object. + * + * @param array $paramMap + * @param list $paramNames + */ + private static function buildSubstitutionFromMap( + Node\Stmt\ClassMethod|Node\Stmt\Function_ $method, + array $paramMap, + array $paramNames, + ): MethodCallSubstitution { + $returnTypeRendered = null; + if ($method->returnType !== null) { + $tuple = self::returnTypeToRef($method->returnType, $paramNames); + if ($tuple !== null) { + [$nullable, $ref] = $tuple; + $substituted = Specializer::substituteTypeRef($ref, $paramMap); + $returnTypeRendered = (new ResolvedType($substituted, $nullable))->render(); + } + } + $paramTypes = []; + foreach ($method->params as $param) { + $name = $param->var instanceof Node\Expr\Variable && is_string($param->var->name) + ? $param->var->name + : null; + if ($name === null || $param->type === null) { + continue; + } + $tuple = self::returnTypeToRef($param->type, $paramNames); + if ($tuple === null) { + continue; + } + [$nullable, $ref] = $tuple; + $substituted = Specializer::substituteTypeRef($ref, $paramMap); + $paramTypes[$name] = (new ResolvedType($substituted, $nullable))->render(); + } + return new MethodCallSubstitution($returnTypeRendered, $paramTypes); + } + + /** + * @return list}> + */ + private function scopesFor(string $uri, int $version, string $text): array + { + if (isset($this->cache[$uri]) && $this->cache[$uri]['version'] === $version) { + return $this->cache[$uri]['scopes']; + } + $result = $this->documents->getOrParse($uri, $version, $text); + $ast = $result->ast; + if ($ast === null) { + // The cache's strict parse failed -- typical when the user is + // mid-edit (e.g. cursor on `$u->` with no terminator). Fall + // back to tolerant parsing so we still see the completed + // statements ABOVE the broken line (which is where the `new + // Generic<...>(...)` binding usually lives). Without this + // fallback every completion the user triggers while typing + // resets bindings to empty. + try { + $ast = $this->parser->parseTolerant($text); + } catch (\Throwable) { + $ast = null; + } + } + $scopes = $ast === null ? self::emptyScopes() : $this->build($ast); + $this->cache[$uri] = ['version' => $version, 'scopes' => $scopes]; + return $scopes; + } + + /** + * @return list}> + */ + private static function emptyScopes(): array + { + return [['start' => 0, 'end' => PHP_INT_MAX, 'bindings' => []]]; + } + + /** + * Pick the innermost (narrowest-range) scope whose `[start, end]` + * contains `$offset`. Top-level scope is the catch-all -- it always + * matches and has the widest range, so any nested function scope + * wins on overlap. + * + * @param list}> $scopes + * @return array + */ + private static function bindingsAt(array $scopes, int $offset): array + { + $best = null; + foreach ($scopes as $scope) { + if ($offset < $scope['start'] || $offset > $scope['end']) { + continue; + } + if ($best === null || ($scope['end'] - $scope['start']) < ($best['end'] - $best['start'])) { + $best = $scope; + } + } + return $best === null ? [] : $best['bindings']; + } + + /** + * Build the list of scopes for a document. Always returns at least + * one (top-level) scope. + * + * @param list $ast + * @return list}> + */ + private function build(array $ast): array + { + // Pre-allocate the catch-all top-level scope. Function bodies + // get nested scopes appended during the walk. + $scopes = self::emptyScopes(); + $stack = [0]; // index into $scopes; top is the current scope + // Per-document use map: short-name alias -> fully-qualified name. + // Populated as we walk Use_ statements; consulted by static-call + // resolution when the cursor's `Cls::method<...>(...)` references + // a class by its imported short name (which nikic doesn't qualify + // for us -- the LSP doesn't run NameResolver to keep AST mutation + // cheap). + $useMap = []; + $currentNamespace = ''; + + $classes = $this->classes; + $fqnIndex = $this->fqnIndex; + + $visitor = new class($scopes, $stack, $useMap, $currentNamespace, $classes, $fqnIndex) extends NodeVisitorAbstract { + /** + * @param list}> $scopes + * @param list $stack + * @param array $useMap + */ + public function __construct( + public array &$scopes, + public array &$stack, + public array &$useMap, + public string &$currentNamespace, + private readonly ClassLikeLookup $classes, + private readonly FqnIndex $fqnIndex, + ) { + } + + public function enterNode(Node $node): null + { + if ($node instanceof Namespace_) { + $this->currentNamespace = $node->name?->toString() ?? ''; + return null; + } + if ($node instanceof Use_) { + foreach ($node->uses as $u) { + if (!$u instanceof UseItem) { + continue; + } + $fqn = $u->name->toString(); + $alias = $u->alias?->toString() ?? self::lastSegment($fqn); + $this->useMap[$alias] = $fqn; + } + return null; + } + + // Open a new scope for function-like declarations. Their + // parameters seed bindings; the body Assigns then go into + // the same scope. + if ($node instanceof Function_ || $node instanceof ClassMethod) { + $start = $node->getStartFilePos(); + $end = $node->getEndFilePos(); + if ($start < 0 || $end < 0) { + return null; + } + $this->scopes[] = [ + 'start' => $start, + 'end' => $end, + 'bindings' => self::seedFromParams($node->params, $this->classes), + ]; + $this->stack[] = count($this->scopes) - 1; + return null; + } + + // Closure: like a function, but also inherits explicit + // outer-scope bindings via the `use (...)` clause. Phase 1.5. + if ($node instanceof Closure) { + $start = $node->getStartFilePos(); + $end = $node->getEndFilePos(); + if ($start < 0 || $end < 0) { + return null; + } + $outerBindings = $this->currentBindings(); + $closureBindings = self::seedFromParams($node->params, $this->classes); + foreach ($node->uses as $use) { + if (!$use instanceof ClosureUse) { + continue; + } + if (!$use->var instanceof Variable || !is_string($use->var->name)) { + continue; + } + $varName = $use->var->name; + if (isset($outerBindings[$varName])) { + $closureBindings[$varName] = $outerBindings[$varName]; + } + } + $this->scopes[] = [ + 'start' => $start, + 'end' => $end, + 'bindings' => $closureBindings, + ]; + $this->stack[] = count($this->scopes) - 1; + return null; + } + + if ($node instanceof Assign) { + $this->handleAssign($node); + } + return null; + } + + private static function lastSegment(string $fqn): string + { + $pos = strrpos($fqn, '\\'); + return $pos === false ? $fqn : substr($fqn, $pos + 1); + } + + public function leaveNode(Node $node): null + { + if ($node instanceof Function_ || $node instanceof ClassMethod || $node instanceof Closure) { + array_pop($this->stack); + } + return null; + } + + private function handleAssign(Assign $node): void + { + $lhs = $node->var; + if (!$lhs instanceof Variable || !is_string($lhs->name)) { + return; + } + $name = $lhs->name; + $rhs = $node->expr; + + if ($rhs instanceof New_) { + $binding = GenericResolver::buildFromNew($rhs, $this->classes); + if ($binding !== null) { + $this->writeBinding($name, $binding); + } + return; + } + if ($rhs instanceof MethodCall) { + $resolved = GenericResolver::resolveMethodCall( + $rhs, + $this->currentBindings(), + $this->classes, + $this->fqnIndex, + $this->useMap, + $this->currentNamespace, + ); + if ($resolved !== null) { + $this->writeBinding($name, $resolved); + } + return; + } + if ($rhs instanceof StaticCall) { + $resolved = GenericResolver::resolveStaticCall( + $rhs, + $this->classes, + $this->useMap, + $this->currentNamespace, + ); + if ($resolved !== null) { + $this->writeBinding($name, $resolved); + } + return; + } + if ($rhs instanceof FuncCall) { + $resolved = GenericResolver::resolveFuncCall($rhs, $this->fqnIndex); + if ($resolved !== null) { + $this->writeBinding($name, $resolved); + } + } + } + + /** + * @return array + */ + private function currentBindings(): array + { + $idx = $this->stack[count($this->stack) - 1]; + return $this->scopes[$idx]['bindings']; + } + + private function writeBinding(string $name, VarBinding|ResolvedType $binding): void + { + $idx = $this->stack[count($this->stack) - 1]; + $this->scopes[$idx]['bindings'][$name] = $binding; + } + + /** + * Build initial bindings from a function's parameters. + * + * @param list $params + * @return array + */ + private static function seedFromParams(array $params, ClassLikeLookup $classes): array + { + $bindings = []; + foreach ($params as $param) { + if (!$param->var instanceof Variable || !is_string($param->var->name)) { + continue; + } + $type = $param->type; + // Nullable wrapper: `?Collection $users` carries + // the Name we want underneath. We track the binding + // without nullability -- VarBinding only encodes the + // class + paramMap, and a nullable receiver still + // dispatches to the same class's methods. + if ($type instanceof NullableType) { + $type = $type->type; + } + if (!$type instanceof Name) { + continue; + } + $binding = GenericResolver::buildFromName($type, $classes); + if ($binding !== null) { + $bindings[$param->var->name] = $binding; + } + } + return $bindings; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return $visitor->scopes; + } + + /** + * Build a `VarBinding` from a `new Generic(...)` site. + * Returns null when the class isn't a known template, the args don't + * match the params, or the class declaration can't be located. + * + * @internal called from the visitor closure. + */ + public static function buildFromNew(New_ $new, ClassLikeLookup $classes): ?VarBinding + { + $class = $new->class; + if (!$class instanceof Name) { + return null; + } + return self::buildFromName($class, $classes); + } + + /** + * Build a `VarBinding` from a `Name` node carrying + * `ATTR_TEMPLATE_FQN` + `ATTR_GENERIC_ARGS`. Shared between the + * `new Generic<...>(...)` call site and the parameter-type-hint + * extraction in `seedFromParams`. + * + * @internal called from the visitor closure. + */ + public static function buildFromName(Name $name, ClassLikeLookup $classes): ?VarBinding + { + $templateFqn = $name->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + $args = $name->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + if (!is_string($templateFqn) || !is_array($args) || $args === []) { + return null; + } + $classLike = $classes->find($templateFqn); + if ($classLike === null) { + return null; + } + $params = $classLike->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (!is_array($params) || count($params) !== count($args)) { + return null; + } + $paramMap = []; + foreach ($params as $i => $param) { + if (!$param instanceof TypeParam || !($args[$i] instanceof TypeRef)) { + return null; + } + $paramMap[$param->name] = $args[$i]; + } + return new VarBinding($templateFqn, $paramMap); + } + + /** + * Recursive expression typer. Introduced in Phase 1.4 to type + * arbitrary expressions so chained calls (`$a->b()->c()`) compose + * without a separate code path for each chain depth. Every other + * call-site type resolver (resolveMethodCall, resolveStaticCall, + * resolveFuncCall) is a leaf of this dispatch -- exactly one + * shape per leaf, no nesting. + * + * Returns null when the expression's type can't be modelled (the + * caller falls back to its existing path). The recursion stays + * bounded by AST depth -- pathological chains are very unusual in + * real source, and infinite cycles are impossible because the AST + * is a tree. + * + * @param array $bindings + * @param array $useMap + */ + public static function inferType( + Node $expr, + array $bindings, + ClassLikeLookup $classes, + FqnIndex $fqnIndex, + array $useMap, + string $currentNamespace, + ): ?ResolvedType { + if ($expr instanceof Variable && is_string($expr->name)) { + $binding = $bindings[$expr->name] ?? null; + if ($binding === null) { + return null; + } + if ($binding instanceof VarBinding) { + return self::resolvedTypeFromBinding($binding); + } + return $binding; + } + if ($expr instanceof New_) { + $varBinding = self::buildFromNew($expr, $classes); + return $varBinding === null ? null : self::resolvedTypeFromBinding($varBinding); + } + if ($expr instanceof MethodCall) { + return self::resolveMethodCall($expr, $bindings, $classes, $fqnIndex, $useMap, $currentNamespace); + } + if ($expr instanceof StaticCall) { + return self::resolveStaticCall($expr, $classes, $useMap, $currentNamespace); + } + if ($expr instanceof FuncCall) { + return self::resolveFuncCall($expr, $fqnIndex); + } + return null; + } + + /** + * Convert a `VarBinding` (class + paramMap) into a `ResolvedType` -- a + * `TypeRef` whose `args` carry the bound type-args so downstream + * `inferType` calls can recover the paramMap for further chained + * substitutions. Without this round-trip the receiver of a chained + * call would lose its generic context. + */ + private static function resolvedTypeFromBinding(VarBinding $binding): ResolvedType + { + $args = array_values($binding->paramMap); + return new ResolvedType(new TypeRef($binding->classFqn, $args), false); + } + + /** + * Resolve a `$x->method(...)` RHS by typing the receiver via + * `inferType` then substituting the method's return type with the + * receiver's type-arg bindings. Receiver can be any expression -- + * Variable (1-step), MethodCall (n-step chain), etc. + * + * @param array $bindings + * @param array $useMap + * @internal called from the visitor closure and from inferType. + */ + public static function resolveMethodCall( + MethodCall $call, + array $bindings, + ClassLikeLookup $classes, + FqnIndex $fqnIndex, + array $useMap = [], + string $currentNamespace = '', + ): ?ResolvedType { + if (!$call->name instanceof Identifier) { + return null; + } + $receiverType = self::inferType( + $call->var, + $bindings, + $classes, + $fqnIndex, + $useMap, + $currentNamespace, + ); + if ($receiverType === null) { + return null; + } + $classLike = $classes->find($receiverType->ref->name); + if ($classLike === null) { + return null; + } + $method = self::findMethod($classLike, $call->name->toString()); + if ($method === null) { + return null; + } + $returnType = $method->returnType; + if ($returnType === null) { + return null; + } + // Rebuild paramMap from the receiver's TypeRef args (set during + // `resolvedTypeFromBinding` or by a prior chained call's + // substituted output). When the receiver has no args -- e.g. + // an unconstrained or already fully-substituted scalar -- the + // method's own substitution is a no-op, which is correct. + $paramMap = self::paramMapFromReceiver($classLike, $receiverType); + $paramNames = array_keys($paramMap); + + [$nullable, $ref] = self::returnTypeToRef($returnType, $paramNames) ?? [null, null]; + if ($ref === null) { + return null; + } + $substituted = Specializer::substituteTypeRef($ref, $paramMap); + return new ResolvedType($substituted, $nullable); + } + + /** + * Rebuild `paramName => TypeRef` from a class's `ATTR_GENERIC_PARAMS` + * zipped with the receiver `ResolvedType`'s `args`. Returns an empty + * array when the class is non-generic or when the receiver carries + * no args. + * + * @return array + */ + private static function paramMapFromReceiver(ClassLike $classLike, ResolvedType $receiver): array + { + $params = $classLike->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (!is_array($params) || $params === []) { + return []; + } + $args = $receiver->ref->args; + $map = []; + foreach ($params as $i => $p) { + if (!$p instanceof TypeParam) { + continue; + } + if (!isset($args[$i]) || !$args[$i] instanceof TypeRef) { + continue; + } + $map[$p->name] = $args[$i]; + } + return $map; + } + + /** + * Resolve a `Cls::method(...)` RHS. Reads the class via the + * per-document use map (xphp's parser doesn't run nikic's NameResolver + * so single-segment Names stay un-qualified at LSP time), finds the + * method's `ATTR_METHOD_GENERIC_PARAMS`, zips with the call site's + * `ATTR_METHOD_GENERIC_ARGS`, and substitutes the return type. + * + * Returns null when the class can't be located, the method isn't + * generic, or the args don't match the param arity. + * + * @param array $useMap alias -> FQN + * @internal called from the visitor closure. + */ + public static function resolveStaticCall( + StaticCall $call, + ClassLikeLookup $classes, + array $useMap, + string $currentNamespace, + ): ?ResolvedType { + if (!$call->class instanceof Name) { + return null; + } + if (!$call->name instanceof Identifier) { + return null; + } + $args = $call->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + if (!is_array($args) || $args === []) { + return null; + } + $classFqn = self::resolveNameWithUseMap($call->class, $useMap, $currentNamespace); + if ($classFqn === null) { + return null; + } + $classLike = $classes->find($classFqn); + if ($classLike === null) { + return null; + } + $method = self::findMethod($classLike, $call->name->toString()); + if ($method === null) { + return null; + } + $params = $method->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + if (!is_array($params) || count($params) !== count($args)) { + return null; + } + $paramMap = []; + $paramNames = []; + foreach ($params as $i => $param) { + if (!$param instanceof TypeParam || !($args[$i] instanceof TypeRef)) { + return null; + } + $paramMap[$param->name] = $args[$i]; + $paramNames[] = $param->name; + } + $returnType = $method->returnType; + if ($returnType === null) { + return null; + } + [$nullable, $ref] = self::returnTypeToRef($returnType, $paramNames) ?? [null, null]; + if ($ref === null) { + return null; + } + $substituted = Specializer::substituteTypeRef($ref, $paramMap); + return new ResolvedType($substituted, $nullable); + } + + /** + * Resolve a single-segment or qualified class Name through the per- + * document use map. Mirrors the resolution rules used by xphp's own + * parser: leading `\` means already FQ; first segment matched against + * the use map; otherwise prefixed with the current namespace. + * + * @param array $useMap + */ + private static function resolveNameWithUseMap(Name $name, array $useMap, string $currentNamespace): ?string + { + $raw = $name->toString(); + if (str_starts_with($raw, '\\')) { + return ltrim($raw, '\\'); + } + $first = self::firstSegment($raw); + if (isset($useMap[$first])) { + $rest = substr($raw, strlen($first)); + return $useMap[$first] . $rest; + } + return $currentNamespace !== '' + ? $currentNamespace . '\\' . $raw + : $raw; + } + + private static function firstSegment(string $name): string + { + $pos = strpos($name, '\\'); + return $pos === false ? $name : substr($name, 0, $pos); + } + + /** + * Resolve a `generic_fn(...)` RHS. The FuncCall carries + * `ATTR_TEMPLATE_FQN` (resolved against use-fn aliases by xphp's + * parser) and `ATTR_METHOD_GENERIC_ARGS`. Look up the Function_ + * declaration via FqnIndex (open-doc preferred, filesystem fallback), + * substitute the type-params in the return type. + * + * @internal called from the visitor closure. + */ + public static function resolveFuncCall(FuncCall $call, FqnIndex $fqnIndex): ?ResolvedType + { + if (!$call->name instanceof Name) { + return null; + } + $templateFqn = $call->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + $args = $call->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + if (!is_string($templateFqn) || !is_array($args) || $args === []) { + return null; + } + $function = $fqnIndex->functionFor($templateFqn); + if ($function === null) { + return null; + } + $params = $function->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + if (!is_array($params) || count($params) !== count($args)) { + return null; + } + $paramMap = []; + $paramNames = []; + foreach ($params as $i => $param) { + if (!$param instanceof TypeParam || !($args[$i] instanceof TypeRef)) { + return null; + } + $paramMap[$param->name] = $args[$i]; + $paramNames[] = $param->name; + } + $returnType = $function->returnType; + if ($returnType === null) { + return null; + } + [$nullable, $ref] = self::returnTypeToRef($returnType, $paramNames) ?? [null, null]; + if ($ref === null) { + return null; + } + $substituted = Specializer::substituteTypeRef($ref, $paramMap); + return new ResolvedType($substituted, $nullable); + } + + private static function findMethod(ClassLike $class, string $name): ?ClassMethod + { + foreach ($class->getMethods() as $method) { + if (strcasecmp($method->name->toString(), $name) === 0) { + return $method; + } + } + return null; + } + + /** + * Convert a nikic return-type node into a (nullable, TypeRef) tuple. + * + * `NullableType` is unwrapped and the nullable flag is tracked + * separately because `TypeRef` (compile-pipeline value type) doesn't + * encode nullability -- monomorphization stamps the `?` at type-hint + * emission, not in the ref. Keeping it separate also lets the ref + * pass cleanly through `Specializer::substituteTypeRef` without the + * `?` polluting the param lookup key. + * + * Returns null for shapes we don't model (union, intersection). + * + * @param list $paramNames generic param names on the enclosing class + * @return array{0: bool, 1: TypeRef}|null + */ + private static function returnTypeToRef(Node $type, array $paramNames): ?array + { + if ($type instanceof NullableType) { + $inner = self::returnTypeToRef($type->type, $paramNames); + if ($inner === null) { + return null; + } + return [true, $inner[1]]; + } + if ($type instanceof Identifier) { + return [false, new TypeRef($type->toString(), [], isScalar: true)]; + } + if ($type instanceof Name) { + $raw = $type->toString(); + if (in_array($raw, $paramNames, true)) { + return [false, new TypeRef($raw, [], isTypeParam: true)]; + } + // XphpSourceParser stamps `ATTR_TEMPLATE_FQN` on Name nodes + // followed by `<...>` (e.g. `Collection` in a return type) + // and `ATTR_GENERIC_ARGS` with the resolved TypeRef list. + // Both are exactly what we need: a fully-qualified class name + // plus the args (which Specializer::substituteTypeRef will + // recurse into to substitute nested type-params). + $templateFqn = $type->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + $args = $type->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + if (is_string($templateFqn)) { + $argList = is_array($args) + ? array_values(array_filter($args, static fn ($a): bool => $a instanceof TypeRef)) + : []; + return [false, new TypeRef($templateFqn, $argList)]; + } + // Bare un-generic Name (e.g. `User` directly). No way to + // qualify without NameResolver -- hand back the raw name. + return [false, new TypeRef($raw)]; + } + if ($type instanceof UnionType || $type instanceof IntersectionType || $type instanceof ComplexType) { + return null; + } + return null; + } + + private function renderBinding(VarBinding $binding): string + { + if ($binding->paramMap === []) { + return $binding->classFqn; + } + $args = array_map( + static fn (TypeRef $r): string => $r->toDisplayString(), + array_values($binding->paramMap), + ); + return $binding->classFqn . '<' . implode(', ', $args) . '>'; + } +} diff --git a/tools/lsp/src/Resolver/InvalidRenameNameException.php b/tools/lsp/src/Resolver/InvalidRenameNameException.php new file mode 100644 index 00000000..4707ff0f --- /dev/null +++ b/tools/lsp/src/Resolver/InvalidRenameNameException.php @@ -0,0 +1,17 @@ +first()` where `$users: Collection` and + * `Collection::first(T $needle = null): ?T` + * + * yields: + * - `returnType` = "?App\\Models\\User" + * - `paramTypes` = ["needle" => "App\\Models\\User"] + * + * Either field may be null individually when that specific piece can't + * be substituted (e.g. a method with no return-type annotation has + * `returnType=null`; a parameter typed against a non-generic class has + * no entry in `paramTypes`). An entirely empty result means the resolver + * found nothing -- caller falls back to its unsubstituted render. + */ +final readonly class MethodCallSubstitution +{ + /** + * @param array $paramTypes paramName -> rendered type string + */ + public function __construct( + public ?string $returnType, + public array $paramTypes, + ) { + } +} diff --git a/tools/lsp/src/Resolver/PhpCompletionContext.php b/tools/lsp/src/Resolver/PhpCompletionContext.php new file mode 100644 index 00000000..ebeaa671 --- /dev/null +++ b/tools/lsp/src/Resolver/PhpCompletionContext.php @@ -0,0 +1,262 @@ +` or in the middle of an + * identifier following `->` (e.g. `$obj->|`, `$obj->sho|`). + * - `static`: cursor is right after `::` or in the identifier following. + * - `variable`: cursor is on / right after a `$variable` identifier + * (e.g. `$re|`). + * - `new`: cursor is on an identifier directly following the `new` + * keyword (e.g. `new Us|`). Class names only. + * - `expression`: cursor is on a bare identifier in any other position + * not covered above (e.g. `str|`, `User|`). Suggests both classes and + * functions; PHP's lowercase-function / PascalCase-class convention + * means a typical prefix narrows the list naturally. + * - `null`: cursor isn't on anything completable (whitespace, after + * punctuation other than the recognised access operators, etc.). + * + * The detector is intentionally byte-scanning rather than AST-walking + * for two reasons: + * + * 1. AT the cursor, the source is often syntactically incomplete (just + * typed `->` with nothing after). A real parser refuses. + * + * 2. Matches the convention `TypeArgPositionDetector` uses for the + * `<…>` case -- this file is its sibling, picking up where it + * stops. + * + * String/comment context (Phase 3 polish): cursors inside single-quoted + * strings, comments, doc-comments, heredoc bodies, or inline-HTML are + * suppressed -- typing `'$user->'` shouldn't trigger member-completion + * noise. Double-quoted interpolation is left alone because the + * tokenizer already splits `$user->prop` inside `"..."` into regular + * code tokens, so completion fires naturally there. + */ +final class PhpCompletionContext +{ + /** + * Tokens whose interior treats the cursor as "in literal text" -- + * completion stays silent inside them. + * + * Deliberately NOT here: + * - T_INLINE_HTML. Real .xphp documents always open with `|` in HTML BEFORE an opening + * `name..."`. + */ + private const TOKEN_IDS_SUPPRESS_COMPLETION = [ + T_CONSTANT_ENCAPSED_STRING, + T_ENCAPSED_AND_WHITESPACE, + T_COMMENT, + T_DOC_COMMENT, + T_START_HEREDOC, + T_END_HEREDOC, + ]; + + private function __construct() + { + } + + /** + * @return array{kind: 'member', receiverEnd: int, prefix: string} + * | array{kind: 'static', receiverEnd: int, prefix: string} + * | array{kind: 'static-prop',receiverEnd: int, prefix: string} + * | array{kind: 'variable', prefix: string} + * | array{kind: 'new', prefix: string} + * | array{kind: 'expression', prefix: string} + * | null + */ + public static function detect(string $source, int $offset): ?array + { + if ($offset < 0 || $offset > strlen($source)) { + return null; + } + if (self::isInLiteralText($source, $offset)) { + return null; + } + + $prefixStart = $offset; + while ($prefixStart > 0 && self::isIdentifierByte($source[$prefixStart - 1])) { + $prefixStart--; + } + $prefix = substr($source, $prefixStart, $offset - $prefixStart); + + // `::` => static access. Receiver token ends at $prefixStart - 2. + if ($prefixStart >= 2 + && $source[$prefixStart - 1] === ':' + && $source[$prefixStart - 2] === ':' + ) { + return [ + 'kind' => 'static', + 'receiverEnd' => $prefixStart - 2, + 'prefix' => $prefix, + ]; + } + + // `->` => member access. Receiver expression ends at $prefixStart - 2. + if ($prefixStart >= 2 + && $source[$prefixStart - 1] === '>' + && $source[$prefixStart - 2] === '-' + ) { + return [ + 'kind' => 'member', + 'receiverEnd' => $prefixStart - 2, + 'prefix' => $prefix, + ]; + } + + // `Cls::$pre|` => static property access. The `$` is preceded by + // `::`, which distinguishes it from a plain `$var` completion. + // Must come BEFORE the generic `$` -> variable branch so the + // static-prop case isn't swallowed. + if ($prefixStart >= 3 + && $source[$prefixStart - 1] === '$' + && $source[$prefixStart - 2] === ':' + && $source[$prefixStart - 3] === ':' + ) { + return [ + 'kind' => 'static-prop', + 'receiverEnd' => $prefixStart - 3, + 'prefix' => $prefix, + ]; + } + + // `$` immediately before the identifier => variable. Falls through + // BEFORE the bare-identifier path so `$repo` doesn't classify as + // expression with prefix "repo". + if ($prefixStart >= 1 && $source[$prefixStart - 1] === '$') { + return [ + 'kind' => 'variable', + 'prefix' => $prefix, + ]; + } + + // No identifier under the cursor -- nothing to complete. We + // deliberately don't fire on whitespace; the user must type at + // least one character before completion shows up. + if ($prefix === '') { + return null; + } + + // PHP identifiers cannot start with a digit (only letters and + // underscore are valid leaders). If our backwards-walk picked up + // digit bytes as part of the "prefix" (e.g. cursor inside a `1;` + // number literal), the cursor is in number-literal territory and + // not a completable position. Without this guard, `$x = 1;` + // would classify as `expression` with prefix `1`. + $firstByte = $prefix[0]; + if ($firstByte >= '0' && $firstByte <= '9') { + return null; + } + + // Bare identifier. Look back past whitespace for the `new` + // keyword; if it's there, this is a class-construction position + // and we shouldn't suggest functions. + $beforeIdent = $prefixStart; + while ($beforeIdent > 0 && self::isWhitespace($source[$beforeIdent - 1])) { + $beforeIdent--; + } + if (self::endsWithKeyword($source, $beforeIdent, 'new')) { + return [ + 'kind' => 'new', + 'prefix' => $prefix, + ]; + } + + return [ + 'kind' => 'expression', + 'prefix' => $prefix, + ]; + } + + /** + * Does `$source[..$position]` end with the keyword `$keyword`, with the + * preceding byte being a non-identifier (so `mynew` doesn't match `new`)? + */ + private static function endsWithKeyword(string $source, int $position, string $keyword): bool + { + $len = strlen($keyword); + if ($position < $len) { + return false; + } + if (substr($source, $position - $len, $len) !== $keyword) { + return false; + } + if ($position === $len) { + return true; + } + return !self::isIdentifierByte($source[$position - $len - 1]); + } + + /** + * Same identifier rule as `TypeArgPositionDetector::isIdentifierByte` + * but intentionally duplicated to keep the detectors independent. + */ + private static function isIdentifierByte(string $byte): bool + { + return ($byte >= 'a' && $byte <= 'z') + || ($byte >= 'A' && $byte <= 'Z') + || ($byte >= '0' && $byte <= '9') + || $byte === '_' + || $byte === '\\'; + } + + private static function isWhitespace(string $byte): bool + { + return $byte === ' ' || $byte === "\t" || $byte === "\n" || $byte === "\r"; + } + + /** + * Tokenize the source and decide whether `$offset` falls inside a + * literal-text token: single-quoted string body, heredoc literal + * region, comment, doc-comment, or inline-HTML. PHP's tokenizer is + * fast in C and the source is already in memory; PhpToken::tokenize + * is the cheapest correct way to do this without writing our own + * lexer. + * + * Caller behavior: when this returns true, completion suppresses -- + * no member / variable / expression suggestions surface, no matter + * what byte-level pattern the cursor happens to sit on. + */ + private static function isInLiteralText(string $source, int $offset): bool + { + // Boundary case: tokenize requires valid bytes; an offset past + // end-of-source bottoms out on no token, which means "not in + // literal" -- safer to return false than to misclassify. + if ($offset < 0 || $offset >= strlen($source)) { + return false; + } + try { + $tokens = \PhpToken::tokenize($source); + } catch (\Throwable) { + return false; + } + foreach ($tokens as $token) { + $start = $token->pos; + $end = $start + strlen($token->text); + if ($offset < $start) { + return false; + } + if ($offset >= $end) { + continue; + } + // $start <= $offset < $end -- the cursor falls inside this + // token. Suppress only for literal-text token IDs. + return in_array($token->id, self::TOKEN_IDS_SUPPRESS_COMPLETION, true); + } + return false; + } +} diff --git a/tools/lsp/src/Resolver/PhpCompletionResolver.php b/tools/lsp/src/Resolver/PhpCompletionResolver.php new file mode 100644 index 00000000..91c91503 --- /dev/null +++ b/tools/lsp/src/Resolver/PhpCompletionResolver.php @@ -0,0 +1,892 @@ +|`) or `static` (`Cls::|`). For each shape: + * + * - Member: reflect the byte offset just BEFORE the `->` to get the + * receiver's inferred type via `NodeContext::type()`, then reflect + * that class and enumerate methods + properties. + * - Static: same approach -- reflectOffset at the byte before `::` so + * worse-reflection resolves `self`, `parent`, `static`, and bare + * class names against the surrounding use-import scope. + * + * Filter: + * - Public members always included (until we have access-control aware + * callsite tracking, which is hard with non-existent receiver info + * on the line under construction). + * - Magic `__*` methods excluded -- editors are noisy enough about them. + * + * Returns an empty list (not null) for contexts we don't handle so the + * caller can compose this with other completion sources without + * special-casing nulls. + * + * Why not use phpactor/completion-worse? The package is abandoned on + * packagist (entire phpactor/completion-* tree); pulling in an abandoned + * dep was rejected for long-term maintenance reasons. See plan risk #4. + */ +final class PhpCompletionResolver +{ + public function __construct( + private readonly PhpactorWorkspace $workspace, + private readonly XphpSourceParser $parser, + private readonly Reflector $reflector, + private readonly CompletionIndex $completionIndex, + private readonly ParsedDocumentCache $cache, + private readonly GenericParamRegistry $genericParams, + private readonly GenericResolver $genericResolver, + ) { + } + + /** + * @return list + */ + public function complete(string $uri, int $line, int $character): array + { + // Top-level safety net for parity with PhpDefinitionResolver and + // PhpHoverResolver -- any unexpected `Error` from worse-reflection + // becomes "no completion" instead of an uncaught fatal that + // poisons the LSP transport via stdout. + try { + return $this->completeInner($uri, $line, $character); + } catch (Throwable $t) { + self::trace(sprintf( + 'top-level catch %s: %s', + $t::class, + self::oneLine($t->getMessage()), + )); + return []; + } + } + + /** + * @return list + */ + private function completeInner(string $uri, int $line, int $character): array + { + self::trace(sprintf('request uri=%s line=%d char=%d', $uri, $line, $character)); + + if (!$this->workspace->has($uri)) { + self::trace('workspace miss; no document'); + return []; + } + $document = $this->workspace->get($uri); + $cursorOffset = (new PositionMap($document->text))->positionToOffset($line, $character); + + $hit = PhpCompletionContext::detect($document->text, $cursorOffset); + if ($hit === null) { + self::trace(sprintf('context detect=null offset=%d', $cursorOffset)); + return []; + } + self::trace(sprintf( + 'context kind=%s prefix=%s%s offset=%d', + $hit['kind'], + json_encode($hit['prefix']), + isset($hit['receiverEnd']) ? sprintf(' receiverEnd=%d', $hit['receiverEnd']) : '', + $cursorOffset, + )); + + $items = match ($hit['kind']) { + 'member', 'static', 'static-prop' => $this->completeMembers($uri, $document->text, $hit, $line, $character), + 'variable' => $this->completeVariables($uri, $hit['prefix'], $cursorOffset), + 'new' => $this->completeClassesByPrefix($hit['prefix']), + 'expression' => array_merge( + $this->completeClassesByPrefix($hit['prefix']), + $this->completeFunctionsByPrefix($hit['prefix']), + ), + }; + self::trace(sprintf('returned items=%d', count($items))); + return $items; + } + + /** + * Member / static access completion via worse-reflection. + * + * @param array{kind: string, receiverEnd: int, prefix: string} $hit + * @return list + */ + private function completeMembers(string $uri, string $documentText, array $hit, int $line = 0, int $character = 0): array + { + $stripped = $this->parser->strip($documentText); + $source = TextDocumentBuilder::create($stripped)->uri($uri)->language('php')->build(); + + // Worse-reflection wants an offset INSIDE the receiver expression + // (one byte before the operator) so the resolver classifies the + // expression rather than the trailing `->` / `::`. + $receiverProbe = max(0, $hit['receiverEnd'] - 1); + try { + $offsetReflection = $this->reflector->reflectOffset($source, ByteOffset::fromInt($receiverProbe)); + } catch (Throwable $t) { + self::trace(sprintf( + 'reflectOffset threw %s: %s', + $t::class, + self::oneLine($t->getMessage()), + )); + return []; + } + + $context = $offsetReflection->nodeContext(); + // `(string) $type` is safe for every Type subclass (MissingType, + // PrimitiveType, ClassType, ...); calling `name()` directly blows + // up on MissingType. MissingType stringifies to ``. + $typeName = (string) $context->type(); + self::trace(sprintf( + 'reflectOffset type=%s symbolKind=%s name=%s', + $typeName, + $context->symbol()->symbolType(), + $context->symbol()->name(), + )); + if ($typeName === '' || $typeName === '') { + return []; + } + + // Nullable wrapper: worse-reflection surfaces `function f(): ?User` + // return-type as the receiver type `?App\Models\User` for chained + // `f()?->|` access. reflectClassLike treats the `?` as part of the + // FQN and throws SourceNotFound -- so strip it before lookup. The + // members of `?User` are the same as the members of `User`. + $lookupName = ltrim($typeName, '?'); + + // Monomorphization rescue: when the receiver is anything bound + // to a generic class (variable holding a `new Generic<...>(...)`, + // method call returning a generic-instantiated type, etc.), + // worse-reflection sees only the post-strip placeholder + // (`?App\Containers\T`) and the lookup above would fail. + // GenericResolver walks the receiver expression and hands back + // the substituted concrete class. Covers BOTH the variable + // case (`$user->|`) and the chained-call case (`$x->y()?->|`). + $swapped = $this->genericResolver->resolveMemberAccessReceiverClassAt($uri, $receiverProbe); + if ($swapped !== null && $swapped !== $lookupName) { + self::trace(sprintf( + 'receiver swap via GenericResolver: %s -> %s (kind=%s name=%s)', + $lookupName, + $swapped, + $context->symbol()->symbolType(), + $context->symbol()->name(), + )); + $lookupName = $swapped; + } + + try { + $class = $this->reflector->reflectClassLike($lookupName); + } catch (Throwable $t) { + self::trace(sprintf( + 'reflectClassLike(%s) threw %s: %s', + $lookupName, + $t::class, + self::oneLine($t->getMessage()), + )); + return []; + } + + $methodsAll = count($class->methods()); + $propsAll = count($class->properties()); + $constsAll = count($class->constants()); + self::trace(sprintf( + 'reflectClassLike(%s) ok methods=%d props=%d consts=%d', + $lookupName, + $methodsAll, + $propsAll, + $constsAll, + )); + + $items = []; + $isStatic = $hit['kind'] === 'static'; + $isStaticProp = $hit['kind'] === 'static-prop'; + // Static-prop is a STATIC context (the `::$prop` form), but the + // member set we want is only static properties -- no methods, + // no constants. Treat as static for the same-class / subclass + // visibility plumbing below; gate the iteration shapes below + // on the discriminating $isStaticProp flag. + if ($isStaticProp) { + $isStatic = true; + } + $droppedMagic = 0; + $droppedStatic = 0; + $droppedVis = 0; + $droppedPrefix = 0; + + // Phase 3 polish: thread the caller's enclosing class FQN so + // private + protected members are visible when the cursor is + // inside the same class. Subclass-protected (caller is a + // descendant of the receiver class) consults worse-reflection's + // parents() walk -- protected becomes visible there too; + // private stays gated to same-class only. + $callerClassFqn = $this->enclosingClassFqnAt($uri, $receiverProbe); + $isSameClass = $callerClassFqn !== null && $callerClassFqn === $lookupName; + $isSubclass = !$isSameClass + && $callerClassFqn !== null + && $this->isSubclassOf($callerClassFqn, $lookupName); + + // static-prop is a narrow context -- only static properties show. + // Skip methods + constants entirely; that keeps `Cls::$|` from + // polluting the popup with unrelated members. + if (!$isStaticProp) { + foreach ($class->methods() as $method) { + if (str_starts_with($method->name(), '__')) { + $droppedMagic++; + continue; + } + if ($isStatic xor $method->isStatic()) { + $droppedStatic++; + continue; + } + if (!self::isVisibleFromCaller($method->visibility(), $isSameClass, $isSubclass)) { + $droppedVis++; + continue; + } + if (!self::matchesPrefix($method->name(), $hit['prefix'])) { + $droppedPrefix++; + continue; + } + $items[] = self::methodItem($method); + } + } + + if ($isStaticProp) { + // `Cls::$|` -- only static properties. + // The textEdit's range starts at the cursor (in LSP coords) + // and extends to the same point: an explicit empty-range + // anchor. Without it PhpStorm extends the replacement + // range backwards through the `$` and swallows it on + // accept, producing `Cls::name` (constant access) instead + // of `Cls::$name` (static-property access). PhpStorm + // observed in prod log id=28 of xphp-20260525-172338-536.log. + $staticPropAnchorStart = new Position($line, $character); + $staticPropPrefixLen = strlen($hit['prefix']); + $staticPropAnchorEnd = new Position($line, $character); + // When the user has typed `Stats::$la|`, the popup-side + // filter has already narrowed to items whose label has + // `$la` prefix. On accept we still need to replace the + // `la` they typed -- extend the range BACKWARDS to cover + // the typed prefix (but NOT the `$`). + if ($staticPropPrefixLen > 0) { + $staticPropAnchorStart = new Position($line, max(0, $character - $staticPropPrefixLen)); + } + foreach ($class->properties() as $property) { + if (!$property->isStatic()) { + continue; + } + if (!self::isVisibleFromCaller($property->visibility(), $isSameClass, $isSubclass)) { + continue; + } + if (!self::matchesPrefix($property->name(), $hit['prefix'])) { + continue; + } + $items[] = $this->propertyItem( + $property, + forStaticProp: true, + textEditRange: new Range($staticPropAnchorStart, $staticPropAnchorEnd), + ); + } + } elseif (!$isStatic) { + // `$obj->|` -- only instance properties. + foreach ($class->properties() as $property) { + if (!self::isVisibleFromCaller($property->visibility(), $isSameClass, $isSubclass)) { + continue; + } + if ($property->isStatic()) { + continue; + } + if (!self::matchesPrefix($property->name(), $hit['prefix'])) { + continue; + } + $items[] = self::propertyItem($property); + } + } else { + // `Cls::|` -- static methods (above) + class constants. + foreach ($class->constants() as $constant) { + if (!self::matchesPrefix((string) $constant->name(), $hit['prefix'])) { + continue; + } + $items[] = new CompletionItem( + label: (string) $constant->name(), + kind: CompletionItemKind::CONSTANT, + detail: (string) $class->name(), + ); + } + } + + self::trace(sprintf( + 'member filter kept=%d dropped magic=%d static=%d vis=%d prefix=%d', + count($items), + $droppedMagic, + $droppedStatic, + $droppedVis, + $droppedPrefix, + )); + + /** @var list $items */ + return $items; + } + + /** + * Variable completion, scope-aware. + * + * Visible names at the cursor: + * - Top-level cursor: variables introduced anywhere at the script's + * top level (outside any function/method/closure). + * - Inside a function/method: only that body's params + locally- + * assigned variables. PHP's function-scope barrier hides outer + * scope from regular `function` / method bodies. + * - Inside a `function () use ($a, $b) { ... }` closure: the + * closure's params + `use (...)` captures + closure-local + * variables. Outer-scope names not in the use clause stay + * hidden. + * - Inside an `fn () => ...` arrow function: the arrow's params + + * all outer-scope variables (PHP's auto-capture semantics). + * + * Mid-edit safety: if the strict parse failed (typical for cursor on + * `$us` with no statement terminator), retry with an error-collecting + * handler so the variables defined BEFORE the broken region still + * surface. + * + * @return list + */ + private function completeVariables(string $uri, string $prefix, int $cursorOffset): array + { + if (!$this->workspace->has($uri)) { + return []; + } + $item = $this->workspace->get($uri); + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + + $ast = $result->ast; + if ($ast === null) { + $ast = $this->tolerantParse($item->text); + self::trace(sprintf( + 'variable completion: cache miss; tolerant-parse %s', + $ast === null ? 'returned null too' : sprintf('recovered %d top-level stmts', count($ast)), + )); + if ($ast === null) { + return []; + } + } + + // The script body itself is the outermost "scope" (null sentinel). + // Inner scopes are Function_/ClassMethod/Closure/ArrowFunction nodes. + // The chain runs outermost (null) -> ... -> innermost (the scope + // containing the cursor). + $chain = self::scopeChainAt($ast, $cursorOffset); + + /** @var array $visible */ + $visible = []; + + $innermost = end($chain); + if ($innermost === null) { + // Top-level cursor -- collect top-level variables. + self::collectScopeBodyVariables($ast, $visible); + } else { + self::collectScopeOwnVariables($innermost, $visible); + + // Closures: only explicit `use (...)` brings outer names in. + // (Already captured by collectScopeOwnVariables.) + // + // ArrowFunctions: auto-capture from the chain of enclosing + // arrow functions / top-level until we hit the first function + // barrier (Function_, ClassMethod, Closure). Closure stops + // the walk because it ALREADY filters outer scope through its + // own `use (...)` clause. + if ($innermost instanceof ArrowFunction) { + for ($i = count($chain) - 2; $i >= 0; $i--) { + $outer = $chain[$i]; + if ($outer === null) { + self::collectScopeBodyVariables($ast, $visible); + break; + } + self::collectScopeOwnVariables($outer, $visible); + if ($outer instanceof Function_ + || $outer instanceof ClassMethod + || $outer instanceof Closure + ) { + break; + } + } + } + } + + $items = []; + foreach (array_keys($visible) as $name) { + if (!self::variableMatchesPrefix($name, $prefix)) { + continue; + } + $items[] = new CompletionItem( + label: '$' . $name, + kind: CompletionItemKind::VARIABLE, + insertText: $name, + ); + } + return $items; + } + + /** + * Build the scope chain (outermost -> innermost) for `$byteOffset`. + * `null` represents the script's top-level scope. Each non-null + * entry is the scope node (Function_/ClassMethod/Closure/ArrowFunction) + * whose `[startFilePos..endFilePos]` covers the cursor. + * + * @param list $ast + * @return list + */ + private static function scopeChainAt(array $ast, int $byteOffset): array + { + /** @var list $covering */ + $covering = []; + $visitor = new class($byteOffset, $covering) extends NodeVisitorAbstract { + /** @param list $covering */ + public function __construct( + private readonly int $cursor, + private array &$covering, + ) { + } + + public function enterNode(Node $node): null + { + if (!$node instanceof Function_ + && !$node instanceof ClassMethod + && !$node instanceof Closure + && !$node instanceof ArrowFunction + ) { + return null; + } + $start = $node->getStartFilePos(); + $end = $node->getEndFilePos(); + if ($start === -1 || $end === -1) { + return null; + } + if ($this->cursor >= $start && $this->cursor <= $end + 1) { + $this->covering[] = $node; + } + return null; + } + }; + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + + // Sort by innermost (largest start, smallest end). Simple sort by + // startFilePos ascending puts ancestors first. + usort($covering, static fn ($a, $b) => $a->getStartFilePos() <=> $b->getStartFilePos()); + return array_merge([null], $covering); + } + + /** + * Collect variables introduced in a scope's own body: its params, + * its `use (...)` captures (for closures), and assignments / foreach + * / closure-use within the body that don't descend into a nested + * scope. + * + * @param array $names out-param accumulator + */ + private static function collectScopeOwnVariables( + Function_|ClassMethod|Closure|ArrowFunction $scope, + array &$names, + ): void { + foreach ($scope->params as $param) { + if ($param->var instanceof Variable && is_string($param->var->name)) { + $names[$param->var->name] = true; + } + } + if ($scope instanceof Closure) { + foreach ($scope->uses as $use) { + if ($use->var instanceof Variable && is_string($use->var->name)) { + $names[$use->var->name] = true; + } + } + } + $body = $scope instanceof ArrowFunction ? [$scope->expr] : ($scope->stmts ?? []); + if (is_array($body)) { + self::collectScopeBodyVariables($body, $names); + } + } + + /** + * Walk `$nodes` collecting variable-defining sites, BUT skipping any + * nested function/method/closure/arrow body so we don't leak names + * from a sibling scope. Used both for the script's top-level body + * and for the body of a specific scope node. + * + * @param array $nodes + * @param array $names out-param accumulator + */ + private static function collectScopeBodyVariables(array $nodes, array &$names): void + { + $collector = new class($names) extends NodeVisitorAbstract { + /** @param array $names */ + public function __construct(private array &$names) + { + } + + public function enterNode(Node $node): null|int + { + if ($node instanceof Function_ + || $node instanceof ClassMethod + || $node instanceof Closure + || $node instanceof ArrowFunction + ) { + return NodeTraverser::DONT_TRAVERSE_CHILDREN; + } + if ($node instanceof Assign && $node->var instanceof Variable && is_string($node->var->name)) { + $this->names[$node->var->name] = true; + return null; + } + if ($node instanceof Foreach_) { + if ($node->keyVar instanceof Variable && is_string($node->keyVar->name)) { + $this->names[$node->keyVar->name] = true; + } + if ($node->valueVar instanceof Variable && is_string($node->valueVar->name)) { + $this->names[$node->valueVar->name] = true; + } + } + return null; + } + }; + $traverser = new NodeTraverser(); + $traverser->addVisitor($collector); + $traverser->traverse($nodes); + } + + /** + * @return list + */ + private function completeClassesByPrefix(string $prefix): array + { + // Empty prefix in `new ` or bare-expression position would dump the + // entire workspace + ~1000 stub classes into the popup. Require + // the user to type at least one letter; the cost is one extra + // keystroke and the win is a usable suggestion list. + if ($prefix === '') { + return []; + } + + $items = []; + foreach ($this->completionIndex->classFqns() as $fqn) { + $short = self::lastSegment($fqn); + if (!self::fqnMatchesPrefix($short, $fqn, $prefix)) { + continue; + } + $items[] = new CompletionItem( + label: $short, + kind: CompletionItemKind::CLASS_, + detail: $fqn, + insertText: $fqn, + ); + } + return $items; + } + + /** + * @return list + */ + private function completeFunctionsByPrefix(string $prefix): array + { + // Same rationale as `completeClassesByPrefix`: phpstorm-stubs ships + // ~5000 functions; dumping all on Ctrl+Space with no prefix is + // unusable. + if ($prefix === '') { + return []; + } + + $items = []; + foreach ($this->completionIndex->functionFqns() as $fqn) { + $short = self::lastSegment($fqn); + if (!self::fqnMatchesPrefix($short, $fqn, $prefix)) { + continue; + } + $items[] = new CompletionItem( + label: $short, + kind: CompletionItemKind::FUNCTION, + detail: $fqn, + insertText: $fqn, + ); + } + return $items; + } + + private static function lastSegment(string $fqn): string + { + $idx = strrpos($fqn, '\\'); + return $idx === false ? $fqn : substr($fqn, $idx + 1); + } + + /** + * Match either the short name (prefix start) or the FQN (substring). + * `str` -> `strlen`, `str_replace`; `App\Mo` -> `App\Models\User`. + */ + private static function fqnMatchesPrefix(string $shortName, string $fqn, string $prefix): bool + { + $needle = ltrim($prefix, '\\'); + if ($needle === '') { + return true; + } + return stripos($shortName, $needle) === 0 || stripos($fqn, $needle) !== false; + } + + private static function variableMatchesPrefix(string $varName, string $prefix): bool + { + if ($prefix === '') { + return true; + } + return stripos($varName, $prefix) === 0; + } + + private function methodItem($method): CompletionItem + { + $params = []; + foreach ($method->parameters() as $p) { + $type = $this->genericParams->prettify((string) $p->inferredType()); + $params[] = trim(($type !== '' && $type !== '' ? $type . ' ' : '') . '$' . $p->name()); + } + $return = $this->genericParams->prettify((string) $method->returnType()); + $signature = sprintf( + '(%s)%s', + implode(', ', $params), + $return !== '' && $return !== '' ? ': ' . $return : '', + ); + return new CompletionItem( + label: $method->name(), + kind: CompletionItemKind::METHOD, + detail: $signature, + insertText: $method->name(), + ); + } + + private function propertyItem($property, bool $forStaticProp = false, ?Range $textEditRange = null): CompletionItem + { + $type = $this->genericParams->prettify((string) $property->inferredType()); + $name = $property->name(); + if ($forStaticProp) { + // For `Cls::$|`: + // - label carries the `$` so PhpStorm's popup-filter accepts + // items matching the user's typed `$` prefix. (Without it, + // every item is dropped silently -- diagnosed via prod log + // id=6 of xphp-20260525-171654-492.log.) + // - textEdit pins the replacement range explicitly so + // PhpStorm doesn't extend the range backwards through the + // `$` and swallow it on accept (which produced + // `Stats::name` instead of `Stats::$name` in prod log + // id=28 of xphp-20260525-172338-536.log). + // - newText is the bare property name; the `$` already in + // source survives because the textEdit's range starts AT + // or AFTER the `$`. + $item = new CompletionItem( + label: '$' . $name, + kind: CompletionItemKind::PROPERTY, + detail: $type !== '' && $type !== '' ? $type : null, + insertText: $name, + filterText: '$' . $name, + ); + if ($textEditRange !== null) { + $item->textEdit = new TextEdit($textEditRange, $name); + } + return $item; + } + return new CompletionItem( + label: $name, + kind: CompletionItemKind::PROPERTY, + detail: $type !== '' && $type !== '' ? $type : null, + insertText: $name, + ); + } + + private static function isVisibleFromCaller(Visibility $visibility, bool $isSameClass, bool $isSubclass): bool + { + if ($visibility->isPublic()) { + return true; + } + if ($isSameClass) { + return true; + } + // Private stays gated to the declaring class. Protected leaks + // one level: subclasses see their ancestors' protected members. + if ($isSubclass && !$visibility->isPrivate()) { + return true; + } + return false; + } + + /** + * "Does `$callerFqn` extend or implement `$receiverFqn`?" Used by + * member-completion to decide whether protected members of the + * receiver class should surface at the cursor. Delegates to + * worse-reflection's `isInstanceOf` which walks the entire ancestor + * chain (parents + interfaces, transitive). + */ + private function isSubclassOf(string $callerFqn, string $receiverFqn): bool + { + $callerNorm = ltrim($callerFqn, '\\'); + $receiverNorm = ltrim($receiverFqn, '\\'); + if ($callerNorm === '' || $receiverNorm === '' || $callerNorm === $receiverNorm) { + return false; + } + try { + $class = $this->reflector->reflectClassLike($callerNorm); + return $class->isInstanceOf(\Phpactor\WorseReflection\Core\ClassName::fromString($receiverNorm)); + } catch (\Throwable) { + return false; + } + } + + /** + * Walk the document AST and return the FQN of the innermost + * ClassLike whose `[startFilePos..endFilePos]` covers `$byteOffset`, + * or null if the cursor isn't inside a class body. + * + * Used by member-completion to know whether the caller is inside + * the receiver's class -- the gate for surfacing private + + * protected members. + */ + private function enclosingClassFqnAt(string $uri, int $byteOffset): ?string + { + $item = $this->workspace->has($uri) ? $this->workspace->get($uri) : null; + if ($item === null) { + return null; + } + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + $ast = $result->ast; + if ($ast === null) { + // Cache miss: the cached parse failed because the document is + // syntactically mid-edit (a common case for completion -- the + // user typed `$t` or similar that breaks the surrounding + // statement). Fall back to the tolerant parser so we still + // recover the enclosing class. Don't cache this result -- + // the regular `getOrParse` cache stays the source of truth + // for non-completion callers. + $ast = $this->parser->parseTolerant($item->text); + if ($ast === null) { + return null; + } + } + // The receiverProbe came from the stripped-source byte offset + // (worse-reflection already operates on stripped source), so the + // AST positions match without any ByteOffsetMap translation. + $visitor = new class($byteOffset) extends NodeVisitorAbstract { + public ?string $bestFqn = null; + public int $bestRange = PHP_INT_MAX; + private string $currentNamespace = ''; + + public function __construct(private readonly int $offset) + { + } + + public function enterNode(Node $node): null + { + if ($node instanceof Namespace_) { + $this->currentNamespace = $node->name?->toString() ?? ''; + return null; + } + if (!$node instanceof ClassLike || $node->name === null) { + return null; + } + $start = $node->getStartFilePos(); + $end = $node->getEndFilePos(); + if ($start < 0 || $end < 0 || $this->offset < $start || $this->offset > $end) { + return null; + } + $range = $end - $start; + if ($range >= $this->bestRange) { + return null; + } + $short = $node->name->toString(); + $this->bestFqn = $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $short + : $short; + $this->bestRange = $range; + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return $visitor->bestFqn; + } + + private static function matchesPrefix(string $candidate, string $prefix): bool + { + if ($prefix === '') { + return true; + } + return stripos($candidate, $prefix) === 0; + } + + /** + * Best-effort parse of xphp-stripped source through nikic with error + * recovery so variable completion can still succeed when the user is + * mid-edit and the buffer isn't syntactically valid PHP yet. + * + * @return list|null + */ + private function tolerantParse(string $source): ?array + { + if (self::$tolerantParser === null) { + self::$tolerantParser = (new ParserFactory())->createForHostVersion(); + } + $stripped = $this->parser->strip($source); + try { + $ast = self::$tolerantParser->parse($stripped, new CollectingErrorHandler()); + } catch (Throwable) { + return null; + } + return $ast; + } + + private static ?Parser $tolerantParser = null; + + /** + * Write a tagged diagnostic line to stderr. PhpStorm captures the LSP + * server's stderr into idea.log; the `[xphp-lsp completion]` prefix + * lets users grep one round trip out of a noisy log. Failures here + * are themselves silenced (stderr could close in tests) so we never + * mask a real error with a logging error. + */ + private static function trace(string $message): void + { + @fwrite(STDERR, '[xphp-lsp completion] ' . $message . "\n"); + } + + private static function oneLine(string $message): string + { + return str_replace(["\r", "\n"], ' ', $message); + } +} diff --git a/tools/lsp/src/Resolver/PhpDefinitionResolver.php b/tools/lsp/src/Resolver/PhpDefinitionResolver.php new file mode 100644 index 00000000..cb1988fb --- /dev/null +++ b/tools/lsp/src/Resolver/PhpDefinitionResolver.php @@ -0,0 +1,526 @@ + resolve the class FQN, return its declaration location. + * - `function` -> resolve the function name (workspace + stubs). + * - `method` -> use `containerType()` to find the class, then look up + * the method. + * - `property` -> same shape as method. + * - `constant` -> same shape as method (class const) OR top-level + * constant lookup. + * 5. Translate the resulting reflection's name-range to an LSP `Location`. + * + * Returns null for any cursor position that doesn't resolve to a known + * symbol -- worse-reflection's null-object pattern (`SymbolType::UNKNOWN`, + * `Type::unknown()`) bubbles up as "no answer" rather than an exception + * here. This is intentional: GTD on unknown identifiers is silent in + * editors, not noisy. + * + * Native function GTD lands inside `vendor/jetbrains/phpstorm-stubs/...` + * which is exactly what PhpStorm does on .php files, so the UX is + * consistent. + */ +final class PhpDefinitionResolver +{ + public function __construct( + private readonly PhpactorWorkspace $workspace, + private readonly XphpSourceParser $parser, + private readonly Reflector $reflector, + private readonly ParsedDocumentCache $cache, + private readonly GenericResolver $genericResolver, + ) { + } + + public function resolve(string $uri, int $line, int $character): ?Location + { + // Belt-and-braces: the resolver calls into third-party + // worse-reflection which has its own surprises on edge cases + // (e.g. `MissingType::name()` -- the original cause of the LSP + // crash captured in xphp-20260524-125122-098.log). A single + // top-level catch makes any unexpected internal failure surface + // as "no result" instead of a fatal that poisons the LSP + // transport via stdout. + try { + return $this->resolveInner($uri, $line, $character); + } catch (Throwable) { + return null; + } + } + + private function resolveInner(string $uri, int $line, int $character): ?Location + { + $document = $this->workspace->has($uri) ? $this->workspace->get($uri) : null; + if ($document === null) { + return null; + } + + $offset = (new PositionMap($document->text))->positionToOffset($line, $character); + $stripped = $this->parser->strip($document->text); + $sourceCode = TextDocumentBuilder::create($stripped) + ->uri($uri) + ->language('php') + ->build(); + + try { + $reflectionOffset = $this->reflector->reflectOffset($sourceCode, ByteOffset::fromInt($offset)); + } catch (Throwable) { + return null; + } + + $context = $reflectionOffset->nodeContext(); + $symbol = $context->symbol(); + + // Worse-reflection classifies the imported name inside + // `use function App\foo;` as Symbol::CLASS_ (its TolerantParser + // sees `App\foo` and assumes it's a class), so the function + // dispatch never fires and locateClass throws SourceNotFound. + // Override to FUNCTION when the cursor's AST context says we're + // inside a `use function` (or `use function Foo\{...}` group) + // statement. Same logic applies to PhpHoverResolver. + $useFunctionFqn = $this->useFunctionFqnAtOffset($uri, $offset, $symbol->name()); + if ($useFunctionFqn !== null) { + return $this->locateFunction($useFunctionFqn); + } + + // For class references, worse-reflection puts the SHORT name (or + // the literal source name) on the Symbol and the resolved FQN on + // the inferred Type. In `new User(...)` after `use App\User;` the + // symbol name is "User" but the type name is "App\User" -- which + // is what we need to feed to reflectClassLike(). When the cursor + // is on a use statement itself, both happen to be the FQN. + // + // For method/property/case dispatch, containerType() may be a + // MissingType when worse-reflection couldn't infer the receiver + // (e.g. xphp generic-method return values stripped to bare `T`, + // dynamic property access on unknown variables, etc.). We funnel + // through `containerOrNull()` so MissingType means "give up + // gracefully" instead of "crash on undefined method name()". + return match ($symbol->symbolType()) { + Symbol::CLASS_ => $this->locateClass(self::preferType($context, $symbol->name())), + Symbol::FUNCTION => $this->locateFunction($symbol->name()), + Symbol::METHOD => ($c = self::containerOrNull($context)) !== null + ? $this->locateMethod($c, $symbol->name()) + : null, + Symbol::PROPERTY => $this->locateProperty( + // Resolver-first: substituted receiver wins + // when GenericResolver has a binding for + // `$x->method()?->prop` (Phase 0.7). Falls + // back to worse-reflection's containerType. + $this->genericResolver->resolvePropertyReceiverClassAt($uri, $offset) + ?? self::containerOrNull($context), + $symbol->name(), + ), + Symbol::CONSTANT => $this->locateConstant($context, $symbol->name()), + Symbol::CASE => ($c = self::containerOrNull($context)) !== null + ? $this->locateEnumCase($c, $symbol->name()) + : null, + Symbol::VARIABLE => $this->locateVariable($uri, $symbol->name()), + default => null, + }; + } + + /** + * Prefer the inferred-Type FQN over the surface symbol name when the + * type is known. Falls back to the symbol name (which may still be + * resolvable -- e.g. in `use App\X;` the symbol is already the FQN). + */ + private static function preferType( + \Phpactor\WorseReflection\Core\Inference\NodeContext $context, + string $fallback, + ): string { + // `(string) $type` works for every Type subclass; calling + // `name()` directly blows up on `MissingType` which doesn't + // expose `name()`. + $typeName = (string) $context->type(); + return $typeName !== '' && $typeName !== '' ? $typeName : $fallback; + } + + /** + * Return the resolved FQN of the symbol's containing class/interface + * (for METHOD/PROPERTY/CASE access), or null when worse-reflection + * couldn't infer it. Centralises the MissingType safety check so + * the dispatch site doesn't crash when an upstream inference + * failure makes containerType a `MissingType` (which lacks `name()`). + */ + private static function containerOrNull(\Phpactor\WorseReflection\Core\Inference\NodeContext $context): ?string + { + $name = (string) $context->containerType(); + return ($name === '' || $name === '') ? null : $name; + } + + /** + * Resolve a variable cursor to its first definition site in the + * document. PhpStorm's native PHP GTD navigates `$x` to wherever + * `$x` was introduced; we replicate that by walking the cached + * nikic AST for the document and picking the first node that + * INTRODUCES the variable (param, assignment target, foreach var, + * closure-use), ignoring later reads of the same name. + * + * Scope caveat: this resolver looks at the whole file, not just + * the smallest enclosing function/method/closure. Shadowed + * variables (same name in two different functions) all resolve + * to the first one in document order. Good enough for the + * common case; revisit with a scope-aware walker when shadowing + * shows up in user reports. + */ + /** + * Detect whether the cursor sits inside a `use function App\foo;` (or + * group-use `use function App\{foo, bar};`) statement's imported name. + * Returns the function FQN if so -- the caller routes to + * `locateFunction()` to bypass worse-reflection's misclassification of + * the imported name as Symbol::CLASS_. + * + * `$fallbackName` is the symbol name worse-reflection reported (already + * an FQN like `App\foo`) -- used when the AST walk identifies the + * `use function` context but we still need a name to look up. + */ + private function useFunctionFqnAtOffset(string $uri, int $byteOffset, string $fallbackName): ?string + { + if (!$this->workspace->has($uri)) { + return null; + } + $item = $this->workspace->get($uri); + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + $ast = $result->ast; + if ($ast === null) { + $ast = $this->parser->parseTolerant($item->text); + if ($ast === null) { + return null; + } + } + + $visitor = new class($byteOffset) extends NodeVisitorAbstract { + public ?string $fqn = null; + private bool $insideUseFunction = false; + private string $groupPrefix = ''; + + public function __construct(private readonly int $offset) + { + } + + public function enterNode(Node $node): null + { + if ($node instanceof Node\Stmt\Use_ && $node->type === Node\Stmt\Use_::TYPE_FUNCTION) { + $this->insideUseFunction = true; + } + if ($node instanceof Node\Stmt\GroupUse && $node->type === Node\Stmt\Use_::TYPE_FUNCTION) { + $this->insideUseFunction = true; + $this->groupPrefix = $node->prefix->toString(); + } + if (!$this->insideUseFunction || $this->fqn !== null) { + return null; + } + if ($node instanceof Node\UseItem) { + $nameStart = $node->name->getStartFilePos(); + $nameEnd = $node->name->getEndFilePos(); + if ($nameStart >= 0 && $this->offset >= $nameStart && $this->offset <= $nameEnd) { + $name = $node->name->toString(); + $this->fqn = $this->groupPrefix !== '' + ? $this->groupPrefix . '\\' . $name + : $name; + } + } + return null; + } + + public function leaveNode(Node $node): null + { + if ($node instanceof Node\Stmt\Use_ && $node->type === Node\Stmt\Use_::TYPE_FUNCTION) { + $this->insideUseFunction = false; + } + if ($node instanceof Node\Stmt\GroupUse && $node->type === Node\Stmt\Use_::TYPE_FUNCTION) { + $this->insideUseFunction = false; + $this->groupPrefix = ''; + } + return null; + } + }; + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return $visitor->fqn ?? null; + } + + private function locateVariable(string $uri, string $varName): ?Location + { + if (!$this->workspace->has($uri)) { + return null; + } + $item = $this->workspace->get($uri); + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + return null; + } + + $finder = new class($varName) extends NodeVisitorAbstract { + public ?Variable $hit = null; + + public function __construct(private readonly string $varName) + { + } + + public function enterNode(Node $node): null + { + if ($this->hit !== null) { + return null; + } + + // Function / method / closure parameters: `function f($x)` introduces $x. + if ($node instanceof Param + && $node->var instanceof Variable + && $node->var->name === $this->varName + ) { + $this->hit = $node->var; + return null; + } + + // Assignment LHS: `$x = ...` introduces (or re-introduces) $x. + if ($node instanceof Assign + && $node->var instanceof Variable + && $node->var->name === $this->varName + ) { + $this->hit = $node->var; + return null; + } + + // `foreach ($arr as $k => $v)` -- both $k (if present) and $v are introductions. + if ($node instanceof Foreach_) { + if ($node->keyVar instanceof Variable && $node->keyVar->name === $this->varName) { + $this->hit = $node->keyVar; + return null; + } + if ($node->valueVar instanceof Variable && $node->valueVar->name === $this->varName) { + $this->hit = $node->valueVar; + } + return null; + } + + // `function () use ($x)` brings $x into the closure body. + if ($node instanceof ClosureUse + && $node->var instanceof Variable + && $node->var->name === $this->varName + ) { + $this->hit = $node->var; + } + + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($finder); + $traverser->traverse($result->ast); + + if ($finder->hit === null) { + return null; + } + + $start = $finder->hit->getStartFilePos(); + $end = $finder->hit->getEndFilePos() + 1; + + $positionMap = new PositionMap($item->text); + [$startLine, $startChar] = $positionMap->offsetToPosition($start); + [$endLine, $endChar] = $positionMap->offsetToPosition($end); + + return new Location( + $uri, + new Range( + new Position($startLine, $startChar), + new Position($endLine, $endChar), + ), + ); + } + + private function locateClass(string $fqn): ?Location + { + try { + $class = $this->reflector->reflectClassLike($fqn); + } catch (NotFound | SourceNotFound) { + return null; + } + return $this->classNameRange($class, $fqn); + } + + private function locateFunction(string $fqn): ?Location + { + try { + $function = $this->reflector->reflectFunction($fqn); + } catch (NotFound | SourceNotFound) { + return null; + } + return $this->functionNameRange($function); + } + + private function locateMethod(string $classFqn, string $methodName): ?Location + { + try { + $class = $this->reflector->reflectClassLike($classFqn); + $method = $class->methods()->get($methodName); + } catch (Throwable) { + return null; + } + return $this->memberNameRange($method->declaringClass()->sourceCode(), $method->nameRange()); + } + + private function locateProperty(?string $classFqn, string $propertyName): ?Location + { + if ($classFqn === null) { + return null; + } + try { + $class = $this->reflector->reflectClassLike($classFqn); + if (!$class->isClass() && !$class->isInterface() && !$class->isTrait()) { + return null; + } + $property = $class->properties()->get($propertyName); + } catch (Throwable) { + return null; + } + return $this->memberNameRange($property->declaringClass()->sourceCode(), $property->nameRange()); + } + + private function locateConstant(\Phpactor\WorseReflection\Core\Inference\NodeContext $context, string $name): ?Location + { + // Two shapes: class constants `Foo::BAR` (containerType resolves) + // OR global constants `BAR` (containerType is missing/empty -- fall + // through to top-level reflectConstant). + $containerName = self::containerOrNull($context); + if ($containerName !== null) { + try { + $class = $this->reflector->reflectClassLike($containerName); + $constant = $class->constants()->get($name); + } catch (Throwable) { + return null; + } + return $this->memberNameRange($constant->declaringClass()->sourceCode(), $constant->nameRange()); + } + + try { + $constant = $this->reflector->reflectConstant($name); + } catch (NotFound | SourceNotFound) { + return null; + } + // ReflectionDeclaredConstant exposes position via AbstractReflectedNode. + $position = $constant->position(); + return $this->locationFromSource($constant->sourceCode(), $position->start()->toInt(), $position->end()->toInt()); + } + + private function locateEnumCase(string $enumFqn, string $caseName): ?Location + { + try { + $class = $this->reflector->reflectClassLike($enumFqn); + if (!$class->isEnum()) { + return null; + } + $case = $class->members()->byMemberType(Symbol::CASE)->get($caseName); + } catch (Throwable) { + return null; + } + return $this->memberNameRange($case->declaringClass()->sourceCode(), $case->nameRange()); + } + + /** + * Compute the LSP location of the class identifier token within its + * declaration. worse-reflection's `position()` covers the whole class + * body (`class … { … }`); we narrow that to the identifier so the + * caret lands on the name, matching the existing + * `WorkspaceSymbols::findClassByName` convention used by xphp-specific + * GTD (and matching PhpStorm's native PHP GTD behaviour). + */ + private function classNameRange(ReflectionClassLike $class, string $fqn): ?Location + { + $source = $class->sourceCode(); + $position = $class->position(); + $shortName = self::shortName($fqn); + $sourceText = (string) $source; + $offset = strpos($sourceText, $shortName, $position->start()->toInt()); + if ($offset === false) { + // Fallback to the whole class range -- unusual but better than + // returning null on a successfully-reflected class. + return $this->locationFromSource($source, $position->start()->toInt(), $position->end()->toInt()); + } + return $this->locationFromSource($source, $offset, $offset + strlen($shortName)); + } + + private function functionNameRange(ReflectionFunction $function): ?Location + { + $source = $function->sourceCode(); + $position = $function->position(); + $shortName = self::shortName((string) $function->name()); + $sourceText = (string) $source; + $offset = strpos($sourceText, $shortName, $position->start()->toInt()); + if ($offset === false) { + return $this->locationFromSource($source, $position->start()->toInt(), $position->end()->toInt()); + } + return $this->locationFromSource($source, $offset, $offset + strlen($shortName)); + } + + private function memberNameRange( + \Phpactor\TextDocument\TextDocument $source, + \Phpactor\TextDocument\ByteOffsetRange $nameRange, + ): Location { + return $this->locationFromSource($source, $nameRange->start()->toInt(), $nameRange->end()->toInt()); + } + + private function locationFromSource( + \Phpactor\TextDocument\TextDocument $source, + int $start, + int $end, + ): Location { + $text = (string) $source; + $positionMap = new PositionMap($text); + [$startLine, $startChar] = $positionMap->offsetToPosition($start); + [$endLine, $endChar] = $positionMap->offsetToPosition($end); + return new Location( + (string) $source->uri(), + new Range( + new Position($startLine, $startChar), + new Position($endLine, $endChar), + ), + ); + } + + private static function shortName(string $fqn): string + { + $segments = explode('\\', ltrim($fqn, '\\')); + return $segments[count($segments) - 1]; + } +} diff --git a/tools/lsp/src/Resolver/PhpHoverResolver.php b/tools/lsp/src/Resolver/PhpHoverResolver.php new file mode 100644 index 00000000..78792972 --- /dev/null +++ b/tools/lsp/src/Resolver/PhpHoverResolver.php @@ -0,0 +1,548 @@ +resolveInner($uri, $line, $character); + } catch (Throwable) { + return null; + } + } + + private function resolveInner(string $uri, int $line, int $character): ?Hover + { + if (!$this->workspace->has($uri)) { + return null; + } + $document = $this->workspace->get($uri); + $offset = (new PositionMap($document->text))->positionToOffset($line, $character); + $stripped = $this->parser->strip($document->text); + $source = TextDocumentBuilder::create($stripped)->uri($uri)->language('php')->build(); + + try { + $reflectionOffset = $this->reflector->reflectOffset($source, ByteOffset::fromInt($offset)); + } catch (Throwable) { + return null; + } + + $context = $reflectionOffset->nodeContext(); + $symbol = $context->symbol(); + + // Worse-reflection misclassifies the imported name inside + // `use function App\foo;` as Symbol::CLASS_, so renderClass + // throws SourceNotFound and we return null hover. Override to + // FUNCTION dispatch when the AST context confirms a `use + // function` (or group-use) statement. Mirrors the same fix in + // PhpDefinitionResolver. + $useFunctionFqn = $this->useFunctionFqnAtOffset($uri, $offset); + if ($useFunctionFqn !== null) { + $markdown = $this->renderFunction($useFunctionFqn); + return $markdown !== null + ? new Hover(new MarkupContent(MarkupKind::MARKDOWN, $markdown)) + : null; + } + + // Cursor on a declaration's own name token (`function foo`, + // `class Foo`, `public function method()`). Worse-reflection + // returns an unhelpful symbol classification for these (the + // declaration isn't a "reference" -- it IS the symbol), so the + // standard dispatch yields null. Walk the AST to identify the + // enclosing declaration and render its signature directly. + $declHit = $this->declarationFqnAtOffset($uri, $offset); + if ($declHit !== null) { + $markdown = match ($declHit['kind']) { + 'function' => $this->renderFunction($declHit['fqn']), + 'class' => $this->renderClass($declHit['fqn']), + 'method' => $this->renderMethod($declHit['className'], $declHit['memberName']), + 'property' => $this->renderProperty($declHit['className'], $declHit['memberName']), + default => null, + }; + if ($markdown !== null) { + return new Hover(new MarkupContent(MarkupKind::MARKDOWN, $markdown)); + } + } + + // METHOD / PROPERTY / CONSTANT dispatch go through `containerOrNull` + // so a MissingType container (when worse-reflection can't infer + // the receiver -- e.g. result of an xphp generic method call) + // returns "no hover" instead of crashing on the absent `name()`. + $markdown = match ($symbol->symbolType()) { + Symbol::CLASS_ => $this->renderClass(self::preferType($context, $symbol->name())), + Symbol::FUNCTION => $this->renderFunction( + $symbol->name(), + $this->genericResolver->resolveFunctionCallSubstitutionAt($uri, $offset), + ), + Symbol::METHOD => ($c = self::containerOrNull($context)) !== null + ? $this->renderMethod( + $c, + $symbol->name(), + $this->genericResolver->resolveMethodCallSubstitutionAt($uri, $offset) + ?? $this->genericResolver->resolveStaticCallSubstitutionAt($uri, $offset), + ) + : null, + Symbol::PROPERTY => $this->renderProperty( + // Resolver-first: substituted receiver wins + // when GenericResolver has a binding for + // `$x->method()?->prop` (Phase 0.7). Falls + // back to worse-reflection's containerType. + $this->genericResolver->resolvePropertyReceiverClassAt($uri, $offset) + ?? self::containerOrNull($context), + $symbol->name(), + ), + Symbol::CONSTANT => $this->renderConstant($context, $symbol->name()), + Symbol::VARIABLE => $this->renderVariable($uri, $offset, $context, $symbol->name()), + default => null, + }; + + return $markdown !== null + ? new Hover(new MarkupContent(MarkupKind::MARKDOWN, $markdown)) + : null; + } + + private function renderClass(string $fqn): ?string + { + try { + $class = $this->reflector->reflectClassLike($fqn); + } catch (NotFound | SourceNotFound) { + return null; + } + $kind = $class->classLikeType(); + $signature = sprintf('%s %s', $kind, (string) $class->name()); + $docblock = self::docblockText($class->docblock()); + return self::format($signature, $docblock); + } + + private function renderFunction(string $name, ?MethodCallSubstitution $substitution = null): ?string + { + try { + $function = $this->reflector->reflectFunction($name); + } catch (NotFound | SourceNotFound) { + return null; + } + $params = []; + foreach ($function->parameters() as $param) { + // Phase 5 follow-up: when the call site is a generic + // function (`identity(...)`), GenericResolver provides + // substituted parameter types via the same MethodCallSubstitution + // shape the method path uses. Prefer the substituted type; + // fall back to prettify(inferredType) for params with no + // substitution entry (unannotated / union / intersection). + $substituted = $substitution?->paramTypes[$param->name()] ?? null; + $type = $substituted ?? $this->genericParams->prettify((string) $param->inferredType()); + $params[] = trim(($type !== '' && $type !== '' ? $type . ' ' : '') . '$' . $param->name()); + } + $return = $substitution?->returnType + ?? $this->genericParams->prettify((string) $function->inferredType()); + $signature = sprintf( + 'function %s(%s)%s', + (string) $function->name(), + implode(', ', $params), + $return !== '' && $return !== '' ? ': ' . $return : '', + ); + $docblock = self::docblockText($function->docblock()); + return self::format($signature, $docblock); + } + + private function renderMethod(string $classFqn, string $methodName, ?MethodCallSubstitution $substitution = null): ?string + { + try { + $class = $this->reflector->reflectClassLike($classFqn); + $method = $class->methods()->get($methodName); + } catch (Throwable) { + return null; + } + $visibility = (string) $method->visibility(); + $static = $method->isStatic() ? 'static ' : ''; + // When the cursor is on a method call whose receiver is a tracked + // generic-instantiated variable (e.g. `$users->first()` where + // `$users = new Collection(...)`), GenericResolver has + // already substituted the type-params -- for both the return type + // and each parameter type. Use the substituted form when + // available; fall back to prettify (drops the namespace from + // placeholder names) for params/return the substitution doesn't + // cover (e.g. parameters with union types). + $params = []; + foreach ($method->parameters() as $param) { + $paramName = $param->name(); + $type = $substitution !== null && isset($substitution->paramTypes[$paramName]) + ? $substitution->paramTypes[$paramName] + : $this->genericParams->prettify((string) $param->inferredType()); + $params[] = trim(($type !== '' && $type !== '' ? $type . ' ' : '') . '$' . $paramName); + } + $return = ($substitution !== null && $substitution->returnType !== null) + ? $substitution->returnType + : $this->genericParams->prettify((string) $method->returnType()); + $signature = sprintf( + '%s %sfunction %s(%s)%s', + $visibility, + $static, + $method->name(), + implode(', ', $params), + $return !== '' && $return !== '' ? ': ' . $return : '', + ); + $signature = sprintf('// %s%s%s', $classFqn, "\n", $signature); + $docblock = self::docblockText($method->docblock()); + return self::format($signature, $docblock); + } + + private function renderProperty(?string $classFqn, string $propertyName): ?string + { + if ($classFqn === null) { + return null; + } + try { + $class = $this->reflector->reflectClassLike($classFqn); + $property = $class->properties()->get($propertyName); + } catch (Throwable) { + return null; + } + $visibility = (string) $property->visibility(); + $static = $property->isStatic() ? 'static ' : ''; + $type = $this->genericParams->prettify((string) $property->inferredType()); + $signature = sprintf( + "// %s\n%s %s%s\$%s", + $classFqn, + $visibility, + $static, + $type !== '' && $type !== '' ? $type . ' ' : '', + $property->name(), + ); + $docblock = self::docblockText($property->docblock()); + return self::format($signature, $docblock); + } + + private function renderConstant(NodeContext $context, string $name): ?string + { + $container = self::containerOrNull($context); + if ($container !== null) { + try { + $class = $this->reflector->reflectClassLike($container); + $constant = $class->constants()->get($name); + } catch (Throwable) { + return null; + } + $signature = sprintf("// %s\nconst %s", $container, $constant->name()); + return self::format($signature, self::docblockText($constant->docblock())); + } + + try { + $constant = $this->reflector->reflectConstant($name); + } catch (NotFound | SourceNotFound) { + return null; + } + return self::format(sprintf('const %s', (string) $constant->name()), ''); + } + + /** + * Render a variable hover as `Type $name`. worse-reflection's + * `NodeContext::type()` carries the inferred type from prior + * assignments / params / closure-use captures in scope. Returns + * null when no useful type was inferred (MissingType, references + * to never-declared vars) so the editor doesn't pop up an empty + * tooltip. + * + * Literal types get collapsed via `generalize()` so a hover on + * `$x = 1` shows `int $x` rather than `1 $x`. Class types are + * left unchanged (their `generalize()` returns the same FQN). + */ + private function renderVariable(string $uri, int $offset, NodeContext $context, string $name): ?string + { + // Resolver-first: when we can monomorphize the variable's source + // (a `$x = new Generic<...>(...)` followed by `$y = $x->method()` + // in the same file, OR a `function f(Collection $users)` + // param at scope entry), the resolver returns the substituted + // concrete type and we render that directly. When it can't model + // the shape, fall through to worse-reflection + prettify -- this + // resolver is purely additive, never regresses the fallback. + $resolved = $this->genericResolver->resolveVariable($uri, $name, $offset); + if ($resolved !== null) { + return self::format(sprintf('%s $%s', $resolved, $name), ''); + } + + $type = (string) $context->type()->generalize(); + if ($type === '' || $type === '') { + return null; + } + // Strip namespace prefix from generic-placeholder references so the + // user sees `?T $user` rather than `?App\Containers\T $user` when + // hovering a variable assigned from a `Collection::first(): ?T` + // call. See GenericParamRegistry for the recognition logic. + $type = $this->genericParams->prettify($type); + // No docblock for variables -- worse-reflection's NodeContext + // doesn't carry one for locals. Type + name is the useful bit. + return self::format(sprintf('%s $%s', $type, $name), ''); + } + + private static function preferType(NodeContext $context, string $fallback): string + { + // `(string) $type` works for every Type subclass; calling + // `name()` directly blows up on `MissingType` which doesn't + // expose `name()`. + $typeName = (string) $context->type(); + return $typeName !== '' && $typeName !== '' ? $typeName : $fallback; + } + + /** + * Return the resolved FQN of the symbol's containing class/interface + * for METHOD/PROPERTY/CONSTANT access, or null if worse-reflection + * couldn't infer it (MissingType). Same rationale as the matching + * helper on PhpDefinitionResolver. + */ + private static function containerOrNull(NodeContext $context): ?string + { + $name = (string) $context->containerType(); + return ($name === '' || $name === '') ? null : $name; + } + + private static function format(string $signature, string $docblockText): string + { + $out = "```php\n" . $signature . "\n```"; + if ($docblockText !== '') { + $out .= "\n\n" . $docblockText; + } + return $out; + } + + + /** + * Detect whether the cursor sits on the name token of a Function_, + * ClassLike, ClassMethod, or PropertyProperty declaration. Returns + * a {kind, fqn, [className, memberName]} hit so the caller can pick + * the right render path. Used when worse-reflection's reflectOffset + * returns nothing useful for a declaration cursor (the symbol IS the + * declaration; there's nothing to "look up"). + * + * @return array{kind: string, fqn?: string, className?: string, memberName?: string}|null + */ + private function declarationFqnAtOffset(string $uri, int $byteOffset): ?array + { + if (!$this->workspace->has($uri)) { + return null; + } + $item = $this->workspace->get($uri); + try { + $ast = $this->parser->parseTolerant($item->text); + } catch (Throwable) { + return null; + } + if ($ast === null) { + return null; + } + $visitor = new class($byteOffset) extends NodeVisitorAbstract { + public ?array $hit = null; + private string $namespace = ''; + /** @var list */ + private array $classStack = []; + + public function __construct(private readonly int $offset) + { + } + + public function enterNode(Node $node): null + { + if ($node instanceof Node\Stmt\Namespace_) { + $this->namespace = $node->name?->toString() ?? ''; + return null; + } + if ($node instanceof Node\Stmt\ClassLike && $node->name !== null) { + $short = $node->name->toString(); + $fqn = $this->namespace !== '' ? $this->namespace . '\\' . $short : $short; + $this->classStack[] = $fqn; + if ($this->hits($node->name)) { + $this->hit = ['kind' => 'class', 'fqn' => $fqn]; + } + return null; + } + if ($node instanceof Node\Stmt\Function_) { + if ($this->hits($node->name)) { + $short = $node->name->toString(); + $fqn = $this->namespace !== '' ? $this->namespace . '\\' . $short : $short; + $this->hit = ['kind' => 'function', 'fqn' => $fqn]; + } + return null; + } + if ($node instanceof Node\Stmt\ClassMethod && $this->classStack !== []) { + if ($this->hits($node->name)) { + $this->hit = [ + 'kind' => 'method', + 'className' => end($this->classStack), + 'memberName' => $node->name->toString(), + ]; + } + return null; + } + if ($node instanceof Node\PropertyItem && $this->classStack !== []) { + if ($this->hits($node->name)) { + $this->hit = [ + 'kind' => 'property', + 'className' => end($this->classStack), + 'memberName' => $node->name->toString(), + ]; + } + } + return null; + } + + public function leaveNode(Node $node): null + { + if ($node instanceof Node\Stmt\ClassLike && $node->name !== null) { + array_pop($this->classStack); + } + return null; + } + + private function hits(Node $name): bool + { + $s = $name->getStartFilePos(); + $e = $name->getEndFilePos(); + return $s >= 0 && $this->offset >= $s && $this->offset <= $e; + } + }; + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return $visitor->hit; + } + + /** + * Mirror of `PhpDefinitionResolver::useFunctionFqnAtOffset`: detect a + * `use function App\foo;` (or group-use variant) cursor and return the + * imported FQN. Reusing the cached AST isn't worth the cache + * dependency here -- one extra tolerant parse per hover call is fast + * enough. + */ + private function useFunctionFqnAtOffset(string $uri, int $byteOffset): ?string + { + if (!$this->workspace->has($uri)) { + return null; + } + $item = $this->workspace->get($uri); + try { + $ast = $this->parser->parseTolerant($item->text); + } catch (Throwable) { + return null; + } + if ($ast === null) { + return null; + } + $visitor = new class($byteOffset) extends NodeVisitorAbstract { + public ?string $fqn = null; + private bool $inside = false; + private string $groupPrefix = ''; + + public function __construct(private readonly int $offset) + { + } + + public function enterNode(Node $node): null + { + if ($node instanceof Node\Stmt\Use_ && $node->type === Node\Stmt\Use_::TYPE_FUNCTION) { + $this->inside = true; + } + if ($node instanceof Node\Stmt\GroupUse && $node->type === Node\Stmt\Use_::TYPE_FUNCTION) { + $this->inside = true; + $this->groupPrefix = $node->prefix->toString(); + } + if (!$this->inside || $this->fqn !== null) { + return null; + } + if ($node instanceof Node\UseItem) { + $start = $node->name->getStartFilePos(); + $end = $node->name->getEndFilePos(); + if ($start >= 0 && $this->offset >= $start && $this->offset <= $end) { + $name = $node->name->toString(); + $this->fqn = $this->groupPrefix !== '' + ? $this->groupPrefix . '\\' . $name + : $name; + } + } + return null; + } + + public function leaveNode(Node $node): null + { + if ($node instanceof Node\Stmt\Use_ && $node->type === Node\Stmt\Use_::TYPE_FUNCTION) { + $this->inside = false; + } + if ($node instanceof Node\Stmt\GroupUse && $node->type === Node\Stmt\Use_::TYPE_FUNCTION) { + $this->inside = false; + $this->groupPrefix = ''; + } + return null; + } + }; + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return $visitor->fqn ?? null; + } + + private static function docblockText(\Phpactor\WorseReflection\Core\DocBlock\DocBlock $docblock): string + { + if (!$docblock->isDefined()) { + return ''; + } + // `formatted()` returns the docblock body with the `/**`, ` * ` + // gutter, and `*/` stripped -- close enough to "human prose" for an + // LSP hover. Markdown renderers in editors handle the result + // sensibly without further normalization. + return trim($docblock->formatted()); + } +} diff --git a/tools/lsp/src/Resolver/ReferenceFinder.php b/tools/lsp/src/Resolver/ReferenceFinder.php new file mode 100644 index 00000000..561770c5 --- /dev/null +++ b/tools/lsp/src/Resolver/ReferenceFinder.php @@ -0,0 +1,1025 @@ +method(...)`, `$x?->method(...)`, and + * `Foo::method(...)` where the receiver's inferred class matches. + * V1 matches by exact class FQN; subclass-inherited calls aren't + * surfaced yet (a follow-up worth the inheritance walk). + * - Property references: `$x->prop`, `$x?->prop`, `Foo::$prop`, + * same exact-FQN-match rule. + * + * Receiver-type inference at each call site uses the same worse- + * reflection + `GenericResolver` swap completion uses for `$x->|`, + * so xphp-generic shapes (`Repository`) substitute correctly. + */ +final class ReferenceFinder +{ + public function __construct( + private readonly PhpactorWorkspace $workspace, + private readonly ParsedDocumentCache $cache, + private readonly FqnIndex $fqnIndex, + private readonly XphpSourceParser $parser, + private readonly Reflector $reflector, + private readonly GenericResolver $genericResolver, + ) { + } + + /** + * Expose the cursor's target descriptor for callers that need to + * inspect the symbol's kind / FQN (e.g. RenameProvider's file-rename + * branch for class targets). Mirrors `resolveTargetAt` shape; see + * its docblock for the available keys. + * + * @return array{kind: string, fqn?: string, className?: string, memberName?: string, aliasName?: string, scopeUri?: string, declUri?: string, declLine?: int, declChar?: int}|null + */ + public function targetAt(string $uri, int $byteOffset): ?array + { + return $this->resolveTargetAt($uri, $byteOffset); + } + + /** + * Short name (last `\`-segment for FQN targets, the member name for + * method/property targets) of the symbol the cursor is on. Used by + * RenameProvider to skip aliased references whose source text + * doesn't match the original symbol -- e.g. `bar()` calls that + * resolve to `foo` via `use function foo as bar`. + */ + public function shortNameAt(string $uri, int $byteOffset): ?string + { + $target = $this->resolveTargetAt($uri, $byteOffset); + if ($target === null) { + return null; + } + if (isset($target['aliasName'])) { + return (string) $target['aliasName']; + } + if (isset($target['memberName'])) { + return (string) $target['memberName']; + } + if (!isset($target['fqn'])) { + return null; + } + $fqn = ltrim((string) $target['fqn'], '\\'); + $idx = strrpos($fqn, '\\'); + return $idx === false ? $fqn : substr($fqn, $idx + 1); + } + + /** + * @return list + */ + public function findReferences(string $uri, int $byteOffset, bool $includeDeclaration): array + { + $target = $this->resolveTargetAt($uri, $byteOffset); + if ($target === null) { + return []; + } + + $locations = []; + $seenUris = []; + + // Open-doc pass: live state beats on-disk. + foreach ($this->workspace as $docUri => $item) { + $seenUris[(string) $docUri] = true; + $result = $this->cache->getOrParse((string) $docUri, $item->version, $item->text); + $ast = $result->ast; + $offsets = $result->byteOffsetMap; + if ($ast === null) { + $parsed = $this->parser->parseTolerantWithMap($item->text); + if ($parsed === null) { + continue; + } + $ast = $parsed->ast; + $offsets = $parsed->byteOffsetMap; + } + foreach ($this->collectReferences($ast, $target, $item->text, (string) $docUri) as $hit) { + $locations[] = $this->buildLocation((string) $docUri, $item->text, $offsets, $hit); + } + } + + // Filesystem pass: parse on demand, skipping any URI the workspace + // already covered (open-doc precedence). + foreach ($this->fqnIndex->indexedFilesystemPaths() as $path) { + $fsUri = 'file://' . $path; + if (isset($seenUris[$fsUri])) { + continue; + } + $source = @file_get_contents($path); + if ($source === false) { + continue; + } + try { + $parsed = $this->parser->parseTolerantWithMap($source); + } catch (Throwable) { + continue; + } + if ($parsed === null) { + continue; + } + foreach ($this->collectReferences($parsed->ast, $target, $source, $fsUri) as $hit) { + $locations[] = $this->buildLocation($fsUri, $source, $parsed->byteOffsetMap, $hit); + } + } + + if (!$includeDeclaration) { + $locations = array_values(array_filter( + $locations, + fn (Location $l): bool => !self::isDeclarationLocation($l, $target), + )); + } + + return $locations; + } + + /** + * Identify what the user clicked on and produce a target descriptor. + * Returns null when the cursor isn't on a referenceable symbol. + * + * Target shapes: + * - {kind:'class', fqn:'App\Foo'} + * - {kind:'function', fqn:'App\foo'} + * - {kind:'method', className:'App\Foo', memberName:'bar'} + * - {kind:'property', className:'App\Foo', memberName:'baz'} + * + * @return array{kind: string, fqn?: string, className?: string, memberName?: string, declUri?: string, declLine?: int, declChar?: int}|null + */ + private function resolveTargetAt(string $uri, int $byteOffset): ?array + { + if (!$this->workspace->has($uri)) { + return null; + } + $item = $this->workspace->get($uri); + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + $ast = $result->ast; + $offsets = $result->byteOffsetMap; + if ($ast === null) { + $parsed = $this->parser->parseTolerantWithMap($item->text); + if ($parsed === null) { + return null; + } + $ast = $parsed->ast; + $offsets = $parsed->byteOffsetMap; + } + + // Run NameResolver to populate `resolvedName` on Name nodes and + // `namespacedName` on declarations. `replaceNodes: false` + // preserves the original positions for cursor matching. + $ast = self::cloneWithResolvedNames($ast); + + $finder = new NodeFinder(); + // Pick the smallest node covering the offset. + $best = null; + $bestRange = PHP_INT_MAX; + foreach ($finder->find($ast, static fn (Node $n): bool => true) as $node) { + $start = $node->getStartFilePos(); + $end = $node->getEndFilePos(); + if ($start < 0 || $end < 0) { + continue; + } + if ($byteOffset < $start || $byteOffset > $end) { + continue; + } + $range = $end - $start; + if ($range < $bestRange) { + $best = $node; + $bestRange = $range; + } + } + if ($best === null) { + return null; + } + + // Class declaration: cursor on `class Foo`. + if ($best instanceof ClassLike && $best->name !== null) { + $fqn = isset($best->namespacedName) + ? $best->namespacedName->toString() + : $best->name->toString(); + return [ + 'kind' => 'class', + 'fqn' => $fqn, + 'declUri' => $uri, + 'declLine' => self::lineCharFromOffset($item->text, $offsets->toOriginal($best->name->getStartFilePos()))[0], + 'declChar' => self::lineCharFromOffset($item->text, $offsets->toOriginal($best->name->getStartFilePos()))[1], + ]; + } + + // Function declaration: cursor on `function foo()`. + if ($best instanceof Function_) { + $fqn = isset($best->namespacedName) + ? $best->namespacedName->toString() + : $best->name->toString(); + return [ + 'kind' => 'function', + 'fqn' => $fqn, + 'declUri' => $uri, + 'declLine' => self::lineCharFromOffset($item->text, $offsets->toOriginal($best->name->getStartFilePos()))[0], + 'declChar' => self::lineCharFromOffset($item->text, $offsets->toOriginal($best->name->getStartFilePos()))[1], + ]; + } + + // Identifier of a ClassLike's name token (cursor exactly on the + // name, which NodeFinder may pick over the surrounding ClassLike). + // Also handles ClassMethod / Property declarations and member- + // access call sites. + if ($best instanceof Identifier) { + $parent = self::findParentOfIdentifier($ast, $best); + if ($parent instanceof ClassLike && $parent->name === $best) { + $fqn = isset($parent->namespacedName) + ? $parent->namespacedName->toString() + : $best->toString(); + return [ + 'kind' => 'class', + 'fqn' => $fqn, + 'declUri' => $uri, + 'declLine' => self::lineCharFromOffset($item->text, $offsets->toOriginal($best->getStartFilePos()))[0], + 'declChar' => self::lineCharFromOffset($item->text, $offsets->toOriginal($best->getStartFilePos()))[1], + ]; + } + if ($parent instanceof Function_ && $parent->name === $best) { + $fqn = isset($parent->namespacedName) + ? $parent->namespacedName->toString() + : $best->toString(); + return [ + 'kind' => 'function', + 'fqn' => $fqn, + 'declUri' => $uri, + 'declLine' => self::lineCharFromOffset($item->text, $offsets->toOriginal($best->getStartFilePos()))[0], + 'declChar' => self::lineCharFromOffset($item->text, $offsets->toOriginal($best->getStartFilePos()))[1], + ]; + } + if ($parent instanceof ClassMethod && $parent->name === $best) { + $className = self::enclosingClassFqn($ast, $parent); + if ($className !== null) { + return [ + 'kind' => 'method', + 'className' => $className, + 'memberName' => $best->toString(), + 'declUri' => $uri, + 'declLine' => self::lineCharFromOffset($item->text, $offsets->toOriginal($best->getStartFilePos()))[0], + 'declChar' => self::lineCharFromOffset($item->text, $offsets->toOriginal($best->getStartFilePos()))[1], + ]; + } + } + // Identifier as the name token of a method call: `$x->FOO(...)` + // or `Foo::BAR(...)`. Infer the receiver class and treat as + // a method-ref target. + // + // Item 1: when the receiver inherits the member from an + // ancestor, climb to the declaring class. Without this, + // cursor on `$dog->speak()` (with `Dog extends Animal`) + // targets the non-existent `Dog::speak`; find-references + // then finds nothing and rename does nothing. Resolving up + // to `Animal::speak` makes the symbol identity match every + // call site through the inheritance chain. + if ($parent instanceof MethodCall || $parent instanceof NullsafeMethodCall) { + if ($parent->name === $best) { + $receiverClass = $this->inferReceiverClassAt( + $item->text, + $uri, + max(0, $parent->var->getEndFilePos()), + ); + if ($receiverClass !== null) { + $memberName = $best->toString(); + $declaring = $this->declaringClassOf($receiverClass, $memberName, true) ?? $receiverClass; + return [ + 'kind' => 'method', + 'className' => $declaring, + 'memberName' => $memberName, + ]; + } + } + } + if ($parent instanceof StaticCall && $parent->name === $best) { + $receiverClass = self::resolvedNameOf($parent->class); + if ($receiverClass !== null) { + $memberName = $best->toString(); + $declaring = $this->declaringClassOf($receiverClass, $memberName, true) ?? $receiverClass; + return [ + 'kind' => 'method', + 'className' => $declaring, + 'memberName' => $memberName, + ]; + } + } + if ($parent instanceof PropertyFetch || $parent instanceof NullsafePropertyFetch) { + if ($parent->name === $best) { + $receiverClass = $this->inferReceiverClassAt( + $item->text, + $uri, + max(0, $parent->var->getEndFilePos()), + ); + if ($receiverClass !== null) { + $memberName = $best->toString(); + $declaring = $this->declaringClassOf($receiverClass, $memberName, false) ?? $receiverClass; + return [ + 'kind' => 'property', + 'className' => $declaring, + 'memberName' => $memberName, + ]; + } + } + } + if ($parent instanceof StaticPropertyFetch && $parent->name === $best) { + $receiverClass = self::resolvedNameOf($parent->class); + if ($receiverClass !== null) { + $memberName = $best->toString(); + $declaring = $this->declaringClassOf($receiverClass, $memberName, false) ?? $receiverClass; + return [ + 'kind' => 'property', + 'className' => $declaring, + 'memberName' => $memberName, + ]; + } + } + // Alias declaration token: cursor on `xyz` inside + // `use ... as xyz;` (or the group-use variant). Treat as a + // file-scoped alias rename target. Same shape collect- + // References already understands. + if ($parent instanceof Node\UseItem && $parent->alias === $best) { + return [ + 'kind' => 'alias', + 'aliasName' => $best->toString(), + 'scopeUri' => $uri, + ]; + } + } + + // VarLikeIdentifier on a Property declaration (PropertyProperty's + // `name` field is a VarLikeIdentifier in some php-parser versions; + // Identifier in others -- handle both via parent walk). + if ($best instanceof Node\VarLikeIdentifier || $best instanceof Identifier) { + $parent = self::findParentOfIdentifier($ast, $best); + if ($parent instanceof PropertyProperty && $parent->name === $best) { + $propertyStmt = self::findEnclosingProperty($ast, $parent); + if ($propertyStmt !== null) { + $className = self::enclosingClassFqn($ast, $propertyStmt); + if ($className !== null) { + return [ + 'kind' => 'property', + 'className' => $className, + 'memberName' => $best->toString(), + 'declUri' => $uri, + 'declLine' => self::lineCharFromOffset($item->text, $offsets->toOriginal($best->getStartFilePos()))[0], + 'declChar' => self::lineCharFromOffset($item->text, $offsets->toOriginal($best->getStartFilePos()))[1], + ]; + } + } + } + } + + // Name reference: resolvedName attribute carries the FQN after + // NameResolver has run. + if ($best instanceof Name) { + $resolved = $best->getAttribute('resolvedName'); + $literalShort = self::shortSegment($best->toString()); + if ($resolved instanceof Name) { + $resolvedShort = self::shortSegment($resolved->toString()); + // Alias-resolved reference: the literal text the user typed + // differs from the underlying short name (`use App\Foo as + // Bar; new Bar()` -- literal `Bar`, resolved short `Foo`). + // PhpStorm-style: rename should be SCOPED to the alias + // locally, not pivot to the source symbol the user can't + // see at this site. + if ($literalShort !== $resolvedShort && !$best->isFullyQualified()) { + return [ + 'kind' => 'alias', + 'aliasName' => $literalShort, + 'scopeUri' => $uri, + ]; + } + $fqn = $resolved->toString(); + // Distinguish function names from class names by checking + // the parent context. FuncCall.name -> function; + // everything else -> class. + $kind = self::isFunctionNameContext($ast, $best) ? 'function' : 'class'; + return ['kind' => $kind, 'fqn' => $fqn]; + } + // No resolvedName -> a fully-qualified leading `\Foo` that the + // resolver passed through unchanged. Use the literal. + $fqn = ltrim($best->toString(), '\\'); + $kind = self::isFunctionNameContext($ast, $best) ? 'function' : 'class'; + return ['kind' => $kind, 'fqn' => $fqn]; + } + + return null; + } + + private static function shortSegment(string $name): string + { + $trimmed = ltrim($name, '\\'); + $idx = strrpos($trimmed, '\\'); + return $idx === false ? $trimmed : substr($trimmed, $idx + 1); + } + + /** + * @param list $ast + * @return iterable + */ + private function collectReferences(array $ast, array $target, string $source, string $uri): iterable + { + // Alias rename is file-scoped: PHP's `use ... as ` lives + // for the rest of the current file and nowhere else. Skip every + // file except the one the cursor lives in. + if ($target['kind'] === 'alias') { + if ($uri !== (string) $target['scopeUri']) { + return; + } + $ast = self::cloneWithResolvedNames($ast); + $finder = new NodeFinder(); + $aliasName = (string) $target['aliasName']; + foreach ($finder->find($ast, static fn (Node $n): bool => true) as $node) { + if ($node instanceof Node\UseItem + && $node->alias instanceof Identifier + && $node->alias->toString() === $aliasName + ) { + yield ['node' => $node->alias, 'kind' => 'alias-decl']; + continue; + } + if ($node instanceof Name + && !$node->isFullyQualified() + && $node->toString() === $aliasName + ) { + yield ['node' => $node, 'kind' => 'alias-use']; + } + } + return; + } + + $ast = self::cloneWithResolvedNames($ast); + $finder = new NodeFinder(); + + if ($target['kind'] === 'class' || $target['kind'] === 'function') { + $targetFqn = ltrim((string) $target['fqn'], '\\'); + foreach ($finder->find($ast, static fn (Node $n): bool => true) as $node) { + if ($target['kind'] === 'class') { + if ($node instanceof Name) { + if (self::isFunctionNameContext($ast, $node)) { + continue; + } + $resolved = $node->getAttribute('resolvedName'); + $candidate = $resolved instanceof Name + ? $resolved->toString() + : ltrim($node->toString(), '\\'); + if ($candidate === $targetFqn) { + yield ['node' => $node, 'kind' => 'class']; + } + continue; + } + if ($node instanceof ClassLike && $node->name !== null) { + $declFqn = isset($node->namespacedName) + ? $node->namespacedName->toString() + : $node->name->toString(); + if ($declFqn === $targetFqn) { + yield ['node' => $node->name, 'kind' => 'class-decl']; + } + } + continue; + } + // Function target. + if ($node instanceof FuncCall && $node->name instanceof Name) { + $resolved = $node->name->getAttribute('resolvedName'); + $candidate = $resolved instanceof Name + ? $resolved->toString() + : ltrim($node->name->toString(), '\\'); + if ($candidate === $targetFqn) { + yield ['node' => $node->name, 'kind' => 'function']; + } + continue; + } + // `use function App\foo;` and `use function App\{foo};` + // import statements -- the imported Name is a function + // reference too, otherwise rename leaves the alias stale. + if ($node instanceof Node\Stmt\Use_ + && $node->type === Node\Stmt\Use_::TYPE_FUNCTION + ) { + foreach ($node->uses as $useUse) { + $candidate = ltrim($useUse->name->toString(), '\\'); + if ($candidate === $targetFqn) { + yield ['node' => $useUse->name, 'kind' => 'function-use']; + } + } + continue; + } + if ($node instanceof Node\Stmt\GroupUse + && $node->type === Node\Stmt\Use_::TYPE_FUNCTION + ) { + $prefix = $node->prefix->toString(); + foreach ($node->uses as $useUse) { + $candidate = ltrim($prefix . '\\' . $useUse->name->toString(), '\\'); + if ($candidate === $targetFqn) { + yield ['node' => $useUse->name, 'kind' => 'function-use']; + } + } + continue; + } + if ($node instanceof Function_) { + $declFqn = isset($node->namespacedName) + ? $node->namespacedName->toString() + : $node->name->toString(); + if ($declFqn === $targetFqn) { + yield ['node' => $node->name, 'kind' => 'function-decl']; + } + } + } + return; + } + + // Member target: method or property. + $targetClass = ltrim((string) $target['className'], '\\'); + $targetName = (string) $target['memberName']; + + // Item 1: receiver-side match is "does the receiver class inherit + // this member from `$targetClass`?" -- exact-FQN match preserved + // for the common case (cheap path), inheritance walk for the + // subclass case via `inheritsMemberFromTarget`. Declaration-side + // matches stay as exact `$targetClass` comparisons because we + // only want the canonical declaration site, not every + // unrelated class that happens to use the same name. + foreach ($finder->find($ast, static fn (Node $n): bool => true) as $node) { + if ($target['kind'] === 'method') { + if (($node instanceof MethodCall || $node instanceof NullsafeMethodCall) + && $node->name instanceof Identifier + && $node->name->toString() === $targetName + ) { + $receiver = $this->inferReceiverClassAt( + $source, + $uri, + max(0, $node->var->getEndFilePos()), + ); + if ($receiver !== null && $this->inheritsMemberFromTarget($receiver, $targetName, $targetClass, true)) { + yield ['node' => $node->name, 'kind' => 'method']; + } + continue; + } + if ($node instanceof StaticCall + && $node->name instanceof Identifier + && $node->name->toString() === $targetName + ) { + $receiver = self::resolvedNameOf($node->class); + if ($receiver !== null && $this->inheritsMemberFromTarget($receiver, $targetName, $targetClass, true)) { + yield ['node' => $node->name, 'kind' => 'method']; + } + continue; + } + if ($node instanceof ClassMethod + && $node->name->toString() === $targetName + ) { + $declClass = self::enclosingClassFqn($ast, $node); + if ($declClass !== null && ltrim($declClass, '\\') === $targetClass) { + yield ['node' => $node->name, 'kind' => 'method-decl']; + } + } + continue; + } + // Property target. + if (($node instanceof PropertyFetch || $node instanceof NullsafePropertyFetch) + && $node->name instanceof Identifier + && $node->name->toString() === $targetName + ) { + $receiver = $this->inferReceiverClassAt( + $source, + $uri, + max(0, $node->var->getEndFilePos()), + ); + if ($receiver !== null && $this->inheritsMemberFromTarget($receiver, $targetName, $targetClass, false)) { + yield ['node' => $node->name, 'kind' => 'property']; + } + continue; + } + if ($node instanceof StaticPropertyFetch + && $node->name instanceof Node\VarLikeIdentifier + && $node->name->toString() === $targetName + ) { + $receiver = self::resolvedNameOf($node->class); + if ($receiver !== null && $this->inheritsMemberFromTarget($receiver, $targetName, $targetClass, false)) { + yield ['node' => $node->name, 'kind' => 'property']; + } + continue; + } + if ($node instanceof PropertyProperty + && $node->name->toString() === $targetName + ) { + $propStmt = self::findEnclosingProperty($ast, $node); + if ($propStmt === null) { + continue; + } + $declClass = self::enclosingClassFqn($ast, $propStmt); + if ($declClass !== null && ltrim($declClass, '\\') === $targetClass) { + yield ['node' => $node->name, 'kind' => 'property-decl']; + } + } + } + } + + /** + * NameResolver mutates the AST -- to avoid corrupting the + * ParsedDocumentCache's cached tree, we run it on a clone. nikic's + * trees are made of mutable nodes but cloning is cheap relative to + * the per-call file walks we're already doing. + * + * `replaceNodes: false` keeps the original Name nodes (and their + * positions) intact; resolved FQNs ride along as the `resolvedName` + * attribute. + * + * @param list $ast + * @return list + */ + private static function cloneWithResolvedNames(array $ast): array + { + // The cheapest way to deep-clone an AST is to serialize-unserialize; + // php-parser nodes implement neither __clone-deep nor a copy + // constructor. serialize() preserves position info on every node. + $clone = unserialize(serialize($ast)); + $resolver = new NameResolver(null, ['replaceNodes' => false]); + $traverser = new NodeTraverser(); + $traverser->addVisitor($resolver); + $traverser->traverse($clone); + return $clone; + } + + /** + * Decide whether a `Name` appears in a position where it names a + * FUNCTION (only `FuncCall.name`) -- every other position is a class + * reference (or won't reach this code). We don't have parent links + * after NameResolver, so we walk the AST to find the immediate + * parent. + * + * @param list $ast + */ + private static function isFunctionNameContext(array $ast, Name $name): bool + { + $found = false; + $visitor = new class($name, $found) extends NodeVisitorAbstract { + public bool $isFunc = false; + + public function __construct(private readonly Name $needle, bool &$found) + { + } + + public function enterNode(Node $node): null + { + if ($this->isFunc) { + return null; + } + if ($node instanceof FuncCall && $node->name === $this->needle) { + $this->isFunc = true; + } + return null; + } + }; + $t = new NodeTraverser(); + $t->addVisitor($visitor); + $t->traverse($ast); + return $visitor->isFunc; + } + + /** + * @param list $ast + */ + private static function findParentOfIdentifier(array $ast, Node $needle): ?Node + { + $visitor = new class($needle) extends NodeVisitorAbstract { + public ?Node $parent = null; + + public function __construct(private readonly Node $needle) + { + } + + public function enterNode(Node $node): null + { + if ($this->parent !== null) { + return null; + } + if ($node instanceof ClassLike && $node->name === $this->needle) { + $this->parent = $node; + return null; + } + if ($node instanceof Function_ && $node->name === $this->needle) { + $this->parent = $node; + return null; + } + if ($node instanceof ClassMethod && $node->name === $this->needle) { + $this->parent = $node; + return null; + } + if ($node instanceof PropertyProperty && $node->name === $this->needle) { + $this->parent = $node; + return null; + } + if (($node instanceof MethodCall + || $node instanceof NullsafeMethodCall + || $node instanceof StaticCall + || $node instanceof PropertyFetch + || $node instanceof NullsafePropertyFetch + || $node instanceof StaticPropertyFetch + ) && $node->name === $this->needle) { + $this->parent = $node; + return null; + } + if ($node instanceof Node\UseItem + && $node->alias === $this->needle + ) { + $this->parent = $node; + return null; + } + return null; + } + }; + $t = new NodeTraverser(); + $t->addVisitor($visitor); + $t->traverse($ast); + return $visitor->parent; + } + + /** + * Walk the AST to find the ClassLike that contains `$target`, then + * return its FQN. Returns null when `$target` isn't lexically inside + * any class (defensive; method declarations always are). + * + * @param list $ast + */ + private static function enclosingClassFqn(array $ast, Node $target): ?string + { + $visitor = new class($target) extends NodeVisitorAbstract { + public ?string $fqn = null; + private array $stack = []; + + public function __construct(private readonly Node $target) + { + } + + public function enterNode(Node $node): null + { + if ($node instanceof ClassLike) { + $this->stack[] = $node; + } + if ($node === $this->target && $this->stack !== [] && $this->fqn === null) { + $cls = end($this->stack); + if ($cls instanceof ClassLike) { + $this->fqn = isset($cls->namespacedName) + ? $cls->namespacedName->toString() + : ($cls->name?->toString() ?? ''); + } + } + return null; + } + + public function leaveNode(Node $node): null + { + if ($node instanceof ClassLike) { + array_pop($this->stack); + } + return null; + } + }; + $t = new NodeTraverser(); + $t->addVisitor($visitor); + $t->traverse($ast); + return $visitor->fqn !== null && $visitor->fqn !== '' ? $visitor->fqn : null; + } + + /** + * @param list $ast + */ + private static function findEnclosingProperty(array $ast, PropertyProperty $needle): ?Property + { + $visitor = new class($needle) extends NodeVisitorAbstract { + public ?Property $found = null; + + public function __construct(private readonly PropertyProperty $needle) + { + } + + public function enterNode(Node $node): null + { + if ($this->found !== null) { + return null; + } + if ($node instanceof Property) { + foreach ($node->props as $p) { + if ($p === $this->needle) { + $this->found = $node; + return null; + } + } + } + return null; + } + }; + $t = new NodeTraverser(); + $t->addVisitor($visitor); + $t->traverse($ast); + return $visitor->found; + } + + /** + * Best-effort receiver-class inference for member-access calls. Uses + * worse-reflection's offset reflection (the same machinery completion + * uses for `$x->|`) and consults `GenericResolver` to swap xphp + * generic-class placeholders for their concrete substituted type. + * + * `$byteOffset` is the position of the last byte of the receiver + * expression (one byte before the operator). + */ + private function inferReceiverClassAt(string $source, string $uri, int $byteOffset): ?string + { + $stripped = $this->parser->strip($source); + $textDoc = TextDocumentBuilder::create($stripped)->uri($uri)->language('php')->build(); + try { + $context = $this->reflector + ->reflectOffset($textDoc, ByteOffset::fromInt($byteOffset)) + ->nodeContext(); + } catch (Throwable) { + return null; + } + $typeName = (string) $context->type(); + if ($typeName === '' || $typeName === '') { + return null; + } + $lookupName = ltrim($typeName, '?'); + $swapped = $this->genericResolver->resolveMemberAccessReceiverClassAt($uri, $byteOffset); + if ($swapped !== null && $swapped !== '') { + $lookupName = $swapped; + } + return $lookupName !== '' ? $lookupName : null; + } + + /** + * Worse-reflection-backed lookup: "given a class FQN, which class + * declares this method/property?" Walks the receiver's MRO (parent + * classes, then implemented interfaces, then traits) and returns the + * first declarer's FQN -- which equals the receiver's FQN when the + * member is locally declared, and an ancestor's FQN when the member + * is inherited. Null when the class has no such member, or when + * reflection fails (closed-source class, parse error, etc.). + * + * Item 1: this is the single chokepoint for the V1 "exact FQN match" + * limitation in find-references / rename. Both the cursor side + * (`resolveTargetAt` for member access) and the collector side + * (`collectReferences` member-match) consult this to bridge calls + * on a subclass receiver back to the ancestor that actually declared + * the member. + */ + private function declaringClassOf(string $receiverFqn, string $memberName, bool $isMethod): ?string + { + $lookup = ltrim($receiverFqn, '\\'); + if ($lookup === '' || $memberName === '') { + return null; + } + try { + $class = $this->reflector->reflectClassLike($lookup); + $member = $isMethod + ? $class->methods()->get($memberName) + : $class->properties()->get($memberName); + } catch (Throwable) { + return null; + } + $declaring = (string) $member->declaringClass()->name(); + return $declaring !== '' ? ltrim($declaring, '\\') : null; + } + + /** + * "Does `$receiverFqn` reach `$targetClass` when looking up + * `$memberName`?" True when the receiver is the target, or when + * the receiver inherits the member from the target (and hasn't + * overridden it -- override would make `declaringClassOf` return the + * subclass instead). + */ + private function inheritsMemberFromTarget( + string $receiverFqn, + string $memberName, + string $targetClass, + bool $isMethod, + ): bool { + $receiverNorm = ltrim($receiverFqn, '\\'); + $targetNorm = ltrim($targetClass, '\\'); + if ($receiverNorm === $targetNorm) { + return true; + } + $declaring = $this->declaringClassOf($receiverNorm, $memberName, $isMethod); + return $declaring !== null && $declaring === $targetNorm; + } + + /** + * Resolve a `Foo::method()` left-hand side to a class FQN. After + * NameResolver has run, the `resolvedName` attribute carries it for + * static names; `self` / `static` / `parent` resolve to the enclosing + * class which we can't surface without an extra scope walk -- defer. + */ + private static function resolvedNameOf(Node $classExpr): ?string + { + if (!$classExpr instanceof Name) { + return null; + } + $resolved = $classExpr->getAttribute('resolvedName'); + if ($resolved instanceof Name) { + return $resolved->toString(); + } + if ($classExpr->isFullyQualified()) { + return ltrim($classExpr->toString(), '\\'); + } + return null; + } + + private function buildLocation(string $uri, string $source, ByteOffsetMap $offsets, array $hit): Location + { + $node = $hit['node']; + $start = $offsets->toOriginal($node->getStartFilePos()); + $end = $offsets->toOriginal($node->getEndFilePos() + 1); + if ($start < 0) { + $start = 0; + } + if ($end < $start) { + $end = $start; + } + $map = new PositionMap($source); + [$startLine, $startChar] = $map->offsetToPosition($start); + [$endLine, $endChar] = $map->offsetToPosition($end); + return new Location( + $uri, + new Range( + new Position($startLine, $startChar), + new Position($endLine, $endChar), + ), + ); + } + + /** + * @return array{0: int, 1: int} + */ + private static function lineCharFromOffset(string $source, int $byteOffset): array + { + return (new PositionMap($source))->offsetToPosition(max(0, $byteOffset)); + } + + /** + * Exclude the declaration site when context.includeDeclaration=false. + * Match by (uri, identifier start line/char) so we drop only the + * exact declaration token, not other coincident class refs. + * + * @param array{kind: 'class'|'function', fqn: string, declUri?: string, declLine?: int, declChar?: int} $target + */ + private static function isDeclarationLocation(Location $loc, array $target): bool + { + if (!isset($target['declUri'], $target['declLine'], $target['declChar'])) { + return false; + } + if ($loc->uri !== $target['declUri']) { + return false; + } + return $loc->range->start->line === $target['declLine'] + && $loc->range->start->character === $target['declChar']; + } +} diff --git a/tools/lsp/src/Resolver/RenameProvider.php b/tools/lsp/src/Resolver/RenameProvider.php new file mode 100644 index 00000000..3356be44 --- /dev/null +++ b/tools/lsp/src/Resolver/RenameProvider.php @@ -0,0 +1,259 @@ + `use App\Bar;` + * - `new Foo()` -> `new Bar()` + * - `new App\Foo()` -> `new App\Bar()` + * - `class Foo` -> `class Bar` + * - `$x->foo()` -> `$x->bar()` + * - `$x->prop` -> `$x->renamed` + * - `Util::foo()` -> `Util::bar()` + * + * MVP non-scope: + * - File rename for ClassLike targets. PSR-4 expects `Foo.php` + * to declare `Foo`; once the class is renamed, the file should + * follow. PhpStorm typically prompts the user; we leave that + * to a follow-up and let the user rename manually. + * - Renames that cross subclass-inherited members (covered by the + * same exact-FQN-match limit `ReferenceFinder` carries today). + */ +final class RenameProvider +{ + private const IDENTIFIER_PATTERN = '/^[a-zA-Z_][a-zA-Z0-9_]*$/'; + + public function __construct( + private readonly PhpactorWorkspace $workspace, + private readonly ReferenceFinder $finder, + private readonly FqnIndex $fqnIndex, + /** + * Whether the client advertised support for the `rename` resource + * operation in `workspace.workspaceEdit.resourceOperations`. Set + * by LspDispatcherFactory from `InitializeParams`. PhpStorm's + * LSP plugin (as of build 261.x) declares `["create"]` only and + * silently drops `RenameFile` ops, so we elide them to keep the + * response shape honest. VS Code supports `["create", "rename", + * "delete"]` and will apply them. + */ + private readonly bool $clientSupportsRenameFile = true, + ) { + } + + /** + * @throws InvalidRenameNameException when `$newName` is not a valid + * PHP identifier. The handler converts this to an LSP error + * response with a friendly message. + */ + public function rename(string $uri, int $byteOffset, string $newName): ?WorkspaceEdit + { + if (!self::isValidIdentifier($newName)) { + throw new InvalidRenameNameException(sprintf( + '"%s" is not a valid PHP identifier; rename aborted.', + $newName, + )); + } + + $oldShortName = $this->finder->shortNameAt($uri, $byteOffset); + if ($oldShortName === null) { + return null; + } + + $locations = $this->finder->findReferences($uri, $byteOffset, true); + if ($locations === []) { + return null; + } + + // Group locations by URI so each TextDocumentEdit carries all + // edits for its document. Source text caching avoids re-reading + // the same file once per reference within it. + /** @var array> $byUri */ + $byUri = []; + foreach ($locations as $loc) { + $byUri[$loc->uri][] = $loc; + } + + $documentChanges = []; + foreach ($byUri as $editUri => $locs) { + $source = $this->sourceFor($editUri); + if ($source === null) { + continue; + } + $positionMap = new PositionMap($source); + $edits = []; + foreach ($locs as $loc) { + $edit = self::buildEditForReference($source, $positionMap, $loc, $oldShortName, $newName); + if ($edit !== null) { + $edits[] = $edit; + } + } + if ($edits === []) { + continue; + } + $documentChanges[] = new TextDocumentEdit( + new OptionalVersionedTextDocumentIdentifier($editUri), + $edits, + ); + } + // Class rename: if the class is declared in a file whose basename + // matches the OLD short name (the PSR-4 convention), also emit a + // RenameFile op so `Foo.xphp` becomes `Bar.xphp` alongside the + // class itself. Skip when the file name doesn't follow PSR-4 + // (e.g. multiple classes per file, autoloader-less code, etc.) -- + // we'd rather under-rename than rename the wrong file. + $renameFile = $this->buildFileRenameOp($uri, $byteOffset, $oldShortName, $newName); + if ($renameFile !== null) { + $documentChanges[] = $renameFile; + } + + if ($documentChanges === []) { + return null; + } + return new WorkspaceEdit(null, $documentChanges); + } + + /** + * Emit a `RenameFile` resource operation when the target is a class + * whose declaration file basename matches the old short name. PSR-4 + * code expects `class Foo` to live in `Foo.xphp`; renaming the class + * to `Bar` should follow with `Bar.xphp`. + */ + private function buildFileRenameOp( + string $uri, + int $byteOffset, + string $oldShortName, + string $newName, + ): ?RenameFile { + if (!$this->clientSupportsRenameFile) { + return null; + } + $target = $this->finder->targetAt($uri, $byteOffset); + if ($target === null || ($target['kind'] ?? null) !== 'class') { + return null; + } + $fqn = $target['fqn'] ?? null; + if (!is_string($fqn)) { + return null; + } + $location = $this->fqnIndex->locationForFqn($fqn); + if ($location === null) { + return null; + } + $oldUri = (string) $location['uri']; + // Strip `file://` so we can manipulate the path; rebuild it for + // the new URI. Open-doc URIs may not carry the prefix; preserve + // whichever form arrived. + $hasFilePrefix = str_starts_with($oldUri, 'file://'); + $oldPath = $hasFilePrefix ? substr($oldUri, strlen('file://')) : $oldUri; + $baseDir = dirname($oldPath); + $basename = basename($oldPath); + $ext = ''; + $dotPos = strrpos($basename, '.'); + if ($dotPos !== false && $dotPos > 0) { + $ext = substr($basename, $dotPos); // includes the dot + $stem = substr($basename, 0, $dotPos); + } else { + $stem = $basename; + } + if ($stem !== $oldShortName) { + return null; + } + // dirname('/foo') is '/', not '' -- avoid the doubled slash + // when concatenating. + $newPath = rtrim($baseDir, '/') . '/' . $newName . $ext; + $newUri = $hasFilePrefix ? 'file://' . $newPath : $newPath; + return new RenameFile('rename', $oldUri, $newUri); + } + + /** + * Trim the `Range` to cover only the SHORT NAME portion of the + * reference (the last `\`-segment). For unqualified identifiers + * the range is already the short name; for qualified Name nodes + * (`App\Foo`) we shift `start` past the last `\` so the prefix + * survives the rename. + * + * Alias safety: if the location's short-name text doesn't match + * `$oldShortName`, the reference reached the target through an + * alias (`use function App\Models\{foo as bar}`; `$x = bar();`). + * Renaming the source function should preserve aliased call sites + * -- they explicitly refer to the function via `bar`, not by its + * source name. Skip the edit and let the alias keep working + * (`use function App\Models\{newName as bar}`). + */ + private static function buildEditForReference( + string $source, + PositionMap $positionMap, + Location $loc, + string $oldShortName, + string $newName, + ): ?TextEdit { + $startByte = $positionMap->positionToOffset($loc->range->start->line, $loc->range->start->character); + $endByte = $positionMap->positionToOffset($loc->range->end->line, $loc->range->end->character); + if ($endByte <= $startByte || $endByte > strlen($source)) { + return null; + } + $text = substr($source, $startByte, $endByte - $startByte); + // VarLikeIdentifier nodes (property name token) span `$name` + // including the dollar sign; the target's short name doesn't + // carry one, so strip a leading `$` from both sides of the + // comparison. Keep it OUT of the rename range -- the dollar + // must survive the rename. + $hasDollar = str_starts_with($text, '$'); + $textForCompare = $hasDollar ? substr($text, 1) : $text; + $lastBackslash = strrpos($textForCompare, '\\'); + $shortText = $lastBackslash === false ? $textForCompare : substr($textForCompare, $lastBackslash + 1); + if ($shortText !== $oldShortName) { + return null; + } + if ($lastBackslash === false && !$hasDollar) { + return new TextEdit($loc->range, $newName); + } + $offsetFromStart = ($hasDollar ? 1 : 0) + ($lastBackslash === false ? 0 : $lastBackslash + 1); + $shortStartByte = $startByte + $offsetFromStart; + [$line, $char] = $positionMap->offsetToPosition($shortStartByte); + return new TextEdit( + new Range(new Position($line, $char), $loc->range->end), + $newName, + ); + } + + private function sourceFor(string $uri): ?string + { + if ($this->workspace->has($uri)) { + return $this->workspace->get($uri)->text; + } + if (str_starts_with($uri, 'file://')) { + $path = substr($uri, strlen('file://')); + $source = @file_get_contents($path); + return $source !== false ? $source : null; + } + return null; + } + + private static function isValidIdentifier(string $name): bool + { + return preg_match(self::IDENTIFIER_PATTERN, $name) === 1; + } +} diff --git a/tools/lsp/src/Resolver/ResolvedType.php b/tools/lsp/src/Resolver/ResolvedType.php new file mode 100644 index 00000000..7d805b8c --- /dev/null +++ b/tools/lsp/src/Resolver/ResolvedType.php @@ -0,0 +1,31 @@ +nullable ? '?' : '') . $this->ref->toDisplayString(); + } +} diff --git a/tools/lsp/src/Resolver/StubsIndex.php b/tools/lsp/src/Resolver/StubsIndex.php new file mode 100644 index 00000000..c777136a --- /dev/null +++ b/tools/lsp/src/Resolver/StubsIndex.php @@ -0,0 +1,187 @@ +/.completion-index.json`. Subsequent `loadOrBuild()` calls + * read the JSON directly (~milliseconds vs. ~1-2 seconds for the cold + * walk through ~3000 phpstorm-stubs files). + * + * Cache invalidation: the JSON lives inside the stubs cache directory, + * which itself is sha-keyed by the source path in + * `ReflectorFactory::extractStubsCache()`. Plugin upgrades land a new + * extraction in a fresh sha-keyed directory and pay one-time index + * build there; older caches go orphan but harmlessly. + */ +final class StubsIndex +{ + /** Filename of the cached JSON map, kept inside the stubs cache dir. */ + public const INDEX_FILENAME = '.completion-index.json'; + + /** + * @param list $functions Sorted FQNs of every top-level function. + * @param list $classes Sorted FQNs of every class / interface / trait / enum. + */ + public function __construct( + public readonly array $functions, + public readonly array $classes, + ) { + } + + /** + * Load the cached JSON map at `/.completion-index.json` if it + * exists, otherwise build the index by walking the stubs tree and + * persist the result before returning. + * + * When `$stubsDir` is missing or empty, returns an empty index -- + * means "no native completion candidates" rather than erroring. + */ + public static function loadOrBuild(string $stubsDir): self + { + $cachePath = $stubsDir . '/' . self::INDEX_FILENAME; + if (is_file($cachePath)) { + $raw = @file_get_contents($cachePath); + if ($raw !== false) { + $data = @json_decode($raw, true); + if (is_array($data) + && isset($data['functions']) + && isset($data['classes']) + && is_array($data['functions']) + && is_array($data['classes']) + ) { + return new self( + /** @phpstan-ignore-next-line */ + functions: array_values($data['functions']), + /** @phpstan-ignore-next-line */ + classes: array_values($data['classes']), + ); + } + } + } + + return self::buildAndPersist($stubsDir, $cachePath); + } + + private static function buildAndPersist(string $stubsDir, string $cachePath): self + { + if (!is_dir($stubsDir)) { + return new self(functions: [], classes: []); + } + + $parser = (new ParserFactory())->createForHostVersion(); + /** @var array $functions */ + $functions = []; + /** @var array $classes */ + $classes = []; + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($stubsDir, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, + ); + + foreach ($iterator as $item) { + /** @var SplFileInfo $item */ + if (!$item->isFile() || $item->getExtension() !== 'php') { + continue; + } + $source = @file_get_contents($item->getPathname()); + if ($source === false) { + continue; + } + try { + $ast = $parser->parse($source); + } catch (Throwable) { + continue; + } + if ($ast === null) { + continue; + } + self::collectFromAst($ast, $functions, $classes); + } + + $functionList = array_keys($functions); + $classList = array_keys($classes); + sort($functionList); + sort($classList); + + @file_put_contents($cachePath, json_encode([ + 'functions' => $functionList, + 'classes' => $classList, + ])); + + return new self(functions: $functionList, classes: $classList); + } + + /** + * @param list $ast + * @param array $functions + * @param array $classes + */ + private static function collectFromAst(array $ast, array &$functions, array &$classes): void + { + $visitor = new class extends NodeVisitorAbstract { + /** @var array */ + public array $functions = []; + /** @var array */ + public array $classes = []; + + private string $namespace = ''; + + public function enterNode(Node $node): null + { + if ($node instanceof Namespace_) { + $this->namespace = $node->name?->toString() ?? ''; + return null; + } + if ($node instanceof Function_) { + $this->functions[$this->fqn($node->name->toString())] = true; + return null; + } + if ($node instanceof ClassLike && $node->name !== null) { + $this->classes[$this->fqn($node->name->toString())] = true; + } + return null; + } + + private function fqn(string $short): string + { + return $this->namespace !== '' + ? $this->namespace . '\\' . $short + : $short; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + + foreach ($visitor->functions as $name => $_) { + $functions[$name] = true; + } + foreach ($visitor->classes as $name => $_) { + $classes[$name] = true; + } + } +} diff --git a/tools/lsp/src/Resolver/VarBinding.php b/tools/lsp/src/Resolver/VarBinding.php new file mode 100644 index 00000000..12535c81 --- /dev/null +++ b/tools/lsp/src/Resolver/VarBinding.php @@ -0,0 +1,29 @@ +(...)` + * assignment that the LSP-side monomorphization tracker observed. + * + * - `classFqn` -- fully-qualified template name, e.g. `App\Containers\Collection`. + * - `paramMap` -- `paramName => bound TypeRef`, e.g. `['T' => TypeRef('App\Models\User')]`. + * + * Consumed by `GenericResolver::resolveMethodCall` to substitute type-param + * leaves in a method's return type via `Specializer::substituteTypeRef`. + */ +final readonly class VarBinding +{ + /** + * @param array $paramMap + */ + public function __construct( + public string $classFqn, + public array $paramMap, + ) { + } +} diff --git a/tools/lsp/src/Resolver/WorkspaceClassLikeLookup.php b/tools/lsp/src/Resolver/WorkspaceClassLikeLookup.php new file mode 100644 index 00000000..4f33054e --- /dev/null +++ b/tools/lsp/src/Resolver/WorkspaceClassLikeLookup.php @@ -0,0 +1,103 @@ +workspace as $uri => $item) { + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + continue; + } + $hit = self::findInAst($result->ast, $needle); + if ($hit !== null) { + return $hit; + } + } + return null; + } + + /** + * @param list $ast + */ + private static function findInAst(array $ast, string $needle): ?ClassLike + { + $visitor = new class($needle) extends NodeVisitorAbstract { + public ?ClassLike $found = null; + private string $currentNamespace = ''; + + public function __construct(private readonly string $needle) + { + } + + public function enterNode(Node $node): null + { + if ($this->found !== null) { + return null; + } + if ($node instanceof Namespace_) { + $this->currentNamespace = $node->name?->toString() ?? ''; + return null; + } + if (!$node instanceof ClassLike || $node->name === null) { + return null; + } + // Generic classes get ATTR_TEMPLATE_FQN stamped by + // XphpSourceParser during the marker pass; non-generic + // classes don't. We need to match both shapes, so + // reconstruct from namespace + short name as a fallback. + $fqn = $node->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + if (!is_string($fqn)) { + $short = $node->name->toString(); + $fqn = $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $short + : $short; + } + if ($fqn === $this->needle) { + $this->found = $node; + } + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return $visitor->found; + } +} diff --git a/tools/lsp/test/Handler/TypeArgPositionDetectorTest.php b/tools/lsp/test/Handler/TypeArgPositionDetectorTest.php index eaba538b..df233b0c 100644 --- a/tools/lsp/test/Handler/TypeArgPositionDetectorTest.php +++ b/tools/lsp/test/Handler/TypeArgPositionDetectorTest.php @@ -13,28 +13,31 @@ public function testDetectsImmediatelyAfterOpenBracket(): void { $source = 'new Box<'; $hit = TypeArgPositionDetector::detect($source, strlen($source)); - self::assertSame(['prefix' => ''], $hit); + self::assertSame(['prefix' => '', 'containerName' => 'Box', 'slot' => 0], $hit); } public function testDetectsWithPartialIdentifierPrefix(): void { $source = 'new Box 'Pla'], $hit); + self::assertSame(['prefix' => 'Pla', 'containerName' => 'Box', 'slot' => 0], $hit); } public function testDetectsAfterCommaInMultiArgList(): void { $source = 'new Pair ''], $hit); + // Slot 1 -- cursor sits after the first comma at depth 0. + self::assertSame(['prefix' => '', 'containerName' => 'Pair', 'slot' => 1], $hit); } public function testDetectsInsideNestedGenericsAtSameDepth(): void { $source = 'new Box, '; $hit = TypeArgPositionDetector::detect($source, strlen($source)); - self::assertSame(['prefix' => ''], $hit); + // Outermost generic is `Box`; the comma inside `List<...>` doesn't + // count toward Box's slot because it sits at depth 1. + self::assertSame(['prefix' => '', 'containerName' => 'Box', 'slot' => 1], $hit); } public function testRejectsLessThanOperator(): void @@ -72,7 +75,8 @@ public function testHandlesNestedDepthCorrectly(): void // Inside the INNER `<…>`, prefix is the partial identifier just typed. $source = 'new Box 'Pla'], $hit); + // Container is the inner `List`; slot 0 inside it. + self::assertSame(['prefix' => 'Pla', 'containerName' => 'List', 'slot' => 0], $hit); } public function testOffsetPastSourceLengthReturnsNull(): void @@ -98,7 +102,7 @@ public function testCursorAfterTabSeparatorAcceptsTypeArgContext(): void // independently is the only way to kill them. $source = "new Box ''], $hit); + self::assertSame(['prefix' => '', 'containerName' => 'Box', 'slot' => 1], $hit); } public function testCursorAfterNewlineSeparatorAcceptsTypeArgContext(): void @@ -106,7 +110,7 @@ public function testCursorAfterNewlineSeparatorAcceptsTypeArgContext(): void // Locks the `$byte === "\n"` check. $source = "new Box ''], $hit); + self::assertSame(['prefix' => '', 'containerName' => 'Box', 'slot' => 1], $hit); } public function testCursorAfterCarriageReturnSeparatorAcceptsTypeArgContext(): void @@ -114,7 +118,7 @@ public function testCursorAfterCarriageReturnSeparatorAcceptsTypeArgContext(): v // Locks the `$byte === "\r"` check. $source = "new Box ''], $hit); + self::assertSame(['prefix' => '', 'containerName' => 'Box', 'slot' => 1], $hit); } public function testCursorAfterCommaWithoutSpaceAcceptsTypeArgContext(): void @@ -122,7 +126,7 @@ public function testCursorAfterCommaWithoutSpaceAcceptsTypeArgContext(): void // Locks the `$byte === ','` check. $source = "new Box ''], $hit); + self::assertSame(['prefix' => '', 'containerName' => 'Box', 'slot' => 1], $hit); } public function testCursorAfterSpaceSeparatorAcceptsTypeArgContext(): void @@ -132,7 +136,7 @@ public function testCursorAfterSpaceSeparatorAcceptsTypeArgContext(): void // the specific char mutation.) $source = "new Box ''], $hit); + self::assertSame(['prefix' => '', 'containerName' => 'Box', 'slot' => 1], $hit); } public function testNonSeparatorAndNonIdentifierByteBreaksContext(): void @@ -155,7 +159,7 @@ public function testOpenBracketAtOffsetOnePassesIdentifierCheckAtZero(): void // With mutation `$j <= 0`, we'd return null for the 'A' case. $source = 'A<'; $hit = TypeArgPositionDetector::detect($source, strlen($source)); - self::assertSame(['prefix' => ''], $hit); + self::assertSame(['prefix' => '', 'containerName' => 'A', 'slot' => 0], $hit); } public function testOpenBracketAtOffsetZeroFailsIdentifierCheck(): void @@ -175,6 +179,39 @@ public function testDetectsAfterDepthBalancedNestedGenerics(): void // escape — still good signal). $source = 'new Box, '; $hit = TypeArgPositionDetector::detect($source, strlen($source)); - self::assertSame(['prefix' => ''], $hit); + self::assertSame(['prefix' => '', 'containerName' => 'Box', 'slot' => 1], $hit); + } + + public function testIdentifierAtReturnsFullNameAtCursorInsideGenericClause(): void + { + // Cursor sits in the middle of `User` -- prefix `Us`, suffix `er`. + $source = 'identity(new User())'; + $offset = strpos($source, 'User') + 2; // mid-identifier + self::assertSame('User', TypeArgPositionDetector::identifierAt($source, $offset)); + } + + public function testIdentifierAtReturnsNullOutsideGenericClause(): void + { + $source = '$x = new User();'; + $offset = strpos($source, 'User') + 1; + self::assertNull(TypeArgPositionDetector::identifierAt($source, $offset)); + } + + public function testIdentifierAtReturnsNullOnWhitespaceInsideGenericClause(): void + { + // Cursor on the space between `<` and `User`. No prefix to the + // left, no identifier byte at the cursor -> null. + $source = 'identity< User>(...)'; + $offset = strpos($source, '< ') + 1; // on the space + self::assertNull(TypeArgPositionDetector::identifierAt($source, $offset)); + } + + public function testIdentifierAtReturnsFqnStyleNameWithBackslashes(): void + { + // Backslashes are identifier bytes per the detector's rule, so a + // namespace-qualified type-arg comes through intact. + $source = 'identity(...)'; + $offset = strpos($source, 'User') + 1; + self::assertSame('App\\Models\\User', TypeArgPositionDetector::identifierAt($source, $offset)); } } diff --git a/tools/lsp/test/Handler/WorkspaceSymbolsTest.php b/tools/lsp/test/Handler/WorkspaceSymbolsTest.php index db75e3d9..e61eadfd 100644 --- a/tools/lsp/test/Handler/WorkspaceSymbolsTest.php +++ b/tools/lsp/test/Handler/WorkspaceSymbolsTest.php @@ -15,6 +15,27 @@ final class WorkspaceSymbolsTest extends TestCase { + public function testCollectsFunctionFqnsAcrossOpenDocuments(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/funcs.xphp', 'xphp', 1, <<<'XPHP' + open(new TextDocumentItem('/util.xphp', 'xphp', 1, <<<'XPHP' + newSymbols($workspace)->allFunctionFqns(); + + self::assertContains('App\\greet', $fqns); + self::assertContains('App\\farewell', $fqns); + self::assertContains('global_fn', $fqns); + } + public function testCollectsClassFqnsAcrossOpenDocuments(): void { $workspace = new PhpactorWorkspace(); @@ -100,6 +121,81 @@ class Loose {} self::assertContains('Loose', $fqns); } + public function testFindClassByNameLocatesNamespaceQualifiedClass(): void + { + $userSource = "open(new TextDocumentItem('/User.xphp', 'xphp', 1, $userSource)); + + $location = $this->newSymbols($workspace)->findClassByName('User'); + + self::assertNotNull($location); + self::assertSame('/User.xphp', $location->uri); + // Range targets the `User` identifier token, not the whole class + // body. In `class User {}`, `User` starts at column 6 (`strlen('class ')`). + self::assertSame(2, $location->range->start->line); + self::assertSame(strlen('class '), $location->range->start->character); + } + + public function testFindClassByNameReturnsNullForUnknown(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/Other.xphp', 'xphp', 1, "newSymbols($workspace)->findClassByName('User')); + } + + public function testFindClassByNameReturnsNullForEmptyName(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/Any.xphp', 'xphp', 1, "newSymbols($workspace)->findClassByName('')); + } + + public function testFindClassByNameMatchesShortNameAcrossDocuments(): void + { + // Two documents declaring different classes; the lookup walks + // documents in workspace insertion order and returns the first hit. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/Unrelated.xphp', 'xphp', 1, "open(new TextDocumentItem('/User.xphp', 'xphp', 1, "newSymbols($workspace)->findClassByName('User'); + + self::assertNotNull($location); + self::assertSame('/User.xphp', $location->uri); + } + + public function testFindClassByNameTieBreakPrefersNonFixtureCandidate(): void + { + // Phase 3 polish: when the same short name appears in BOTH a + // fixture path and a canonical path, the canonical wins. + // Without the tie-break, the first document iterated would + // shadow the real source -- breaking GTD into actual code. + $workspace = new PhpactorWorkspace(); + // Fixture opens first -- without the tie-break, this would win. + $workspace->open(new TextDocumentItem('/tests/Fixtures/User.xphp', 'xphp', 1, "open(new TextDocumentItem('/src/Models/User.xphp', 'xphp', 1, "newSymbols($workspace)->findClassByName('User'); + + self::assertNotNull($location); + self::assertSame('/src/Models/User.xphp', $location->uri, 'canonical src/ path must outrank tests/Fixtures'); + } + + public function testFindClassByNameTieBreakPrefersNonVendorCandidate(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/vendor/acme/lib/User.xphp', 'xphp', 1, "open(new TextDocumentItem('/src/Models/User.xphp', 'xphp', 1, "newSymbols($workspace)->findClassByName('User'); + + self::assertNotNull($location); + self::assertSame('/src/Models/User.xphp', $location->uri, 'app code must outrank vendor copy'); + } + private function newSymbols(PhpactorWorkspace $workspace): WorkspaceSymbols { $cache = new ParsedDocumentCache( diff --git a/tools/lsp/test/Handler/XphpCompletionHandlerTest.php b/tools/lsp/test/Handler/XphpCompletionHandlerTest.php index 59e64cf2..b7f22036 100644 --- a/tools/lsp/test/Handler/XphpCompletionHandlerTest.php +++ b/tools/lsp/test/Handler/XphpCompletionHandlerTest.php @@ -19,6 +19,8 @@ use XPHP\Lsp\Handler\WorkspaceSymbols; use XPHP\Lsp\Handler\XphpCompletionHandler; use XPHP\Lsp\PositionMap; +use XPHP\Lsp\Reflection\FqnIndex; +use XPHP\Lsp\Reflection\ReflectorFactory; use XPHP\Transpiler\Monomorphize\XphpSourceParser; use function Amp\Promise\wait; @@ -201,6 +203,131 @@ class Thing {} self::assertContains('Thing', $labels); } + public function testBoundedTypeArgWorksWhenContainerClassIsFilesystemOnly(): void + { + // Prod regression: bound-aware filtering used WorkspaceSymbols + // (open-only) to resolve the container Name to its FQN, so when + // the generic class's file was closed in the editor the lookup + // returned null and filtering degraded to "no bound". The + // candidate enumeration had the same gap. Both now consult + // FqnIndex (open + filesystem). + $root = sys_get_temp_dir() . '/xphp-comp-fs-' . bin2hex(random_bytes(6)); + mkdir($root, 0o755, true); + try { + // Container + candidates live only on disk -- nothing + // is in the open workspace. + file_put_contents($root . '/Box.xphp', <<<'XPHP' + {} + XPHP); + file_put_contents($root . '/Tag.xphp', <<<'XPHP' + open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); + + $list = $this->completeBoundAware($workspace, '/Use.xphp', $useSource, strlen($useSource), $root); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $list->items); + + self::assertContains('Tag', $labels, 'closed-file Stringable implementor must still surface'); + self::assertNotContains('Number', $labels, 'closed-file non-implementor must be filtered out'); + self::assertNotContains('int', $labels, 'scalars must be dropped when slot is class-bounded'); + } finally { + $this->rmrfPath($root); + } + } + + private function rmrfPath(string $dir): void + { + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $p = $dir . '/' . $entry; + if (is_dir($p)) { + $this->rmrfPath($p); + } else { + unlink($p); + } + } + rmdir($dir); + } + + public function testBoundedTypeArgFiltersToSubtypesAndDropsScalars(): void + { + // Phase 3: `Box` constrains the type arg. Completion + // at `new Box<|` must surface only candidates that satisfy the + // bound (subclasses or implementors of Stringable), drop scalars + // (a scalar can't be Stringable), and keep classes that don't. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/Box.xphp', 'xphp', 1, <<<'XPHP' + {} + XPHP)); + $workspace->open(new TextDocumentItem('/Models.xphp', 'xphp', 1, <<<'XPHP' + open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); + + $list = $this->completeBoundAware($workspace, '/Use.xphp', $useSource, strlen($useSource)); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $list->items); + + self::assertContains('Tag', $labels, 'Stringable implementor must surface'); + self::assertNotContains('Number', $labels, 'non-Stringable class must be filtered out'); + self::assertNotContains('int', $labels, 'scalars must be dropped when slot is class-bounded'); + self::assertNotContains('string', $labels, 'scalars must be dropped when slot is class-bounded'); + } + + public function testUnboundedSecondSlotStillSuggestsScalarsAndClasses(): void + { + // Slot indexing: `Pair` -- slot 0 is bounded + // and should filter; slot 1 is unbounded and should keep scalars + // plus every class. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/Pair.xphp', 'xphp', 1, <<<'XPHP' + {} + XPHP)); + $workspace->open(new TextDocumentItem('/Models.xphp', 'xphp', 1, <<<'XPHP' + open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); + + $list = $this->completeBoundAware($workspace, '/Use.xphp', $useSource, strlen($useSource)); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $list->items); + + // Slot 1 (V) is unbounded -- everything goes. + self::assertContains('Tag', $labels); + self::assertContains('Number', $labels); + self::assertContains('int', $labels); + } + public function testCompletionAdvertisesCorrectTriggerCharacters(): void { // Locks the ArrayItemRemoval on line 65: each of '<' and ',' is @@ -214,7 +341,7 @@ public function testCompletionAdvertisesCorrectTriggerCharacters(): void self::assertNotNull($capabilities->completionProvider); self::assertSame( - ['<', ','], + ['<', ',', '>', ':'], $capabilities->completionProvider->triggerCharacters, ); } @@ -251,6 +378,45 @@ private function complete( return wait($handler->complete($params)); } + /** + * Variant of `complete` that wires FqnIndex + Reflector so the bound- + * aware filtering path can run. Used by tests exercising + * `Box`-style suppression. + */ + private function completeBoundAware( + PhpactorWorkspace $workspace, + string $uri, + string $source, + int $byteOffset, + string $rootPath = '', + ): CompletionList { + [$line, $character] = (new PositionMap($source))->offsetToPosition($byteOffset); + $params = new CompletionParams( + new TextDocumentIdentifier($uri), + new Position($line, $character), + ); + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $cache = new ParsedDocumentCache(new Analyzer($parser)); + $fqnIndex = new FqnIndex($workspace, $cache, $parser, $rootPath); + $reflector = (new ReflectorFactory( + $workspace, + $cache, + $parser, + rootPath: $rootPath, + stubPath: ReflectorFactory::defaultStubPath(), + cacheDir: ReflectorFactory::defaultCacheDir(), + fqnIndex: $fqnIndex, + ))->build(); + $handler = new XphpCompletionHandler( + $workspace, + new WorkspaceSymbols($workspace, $cache), + null, + $fqnIndex, + $reflector, + ); + return wait($handler->complete($params)); + } + private function newCache(): ParsedDocumentCache { return new ParsedDocumentCache( diff --git a/tools/lsp/test/Handler/XphpDefinitionHandlerTest.php b/tools/lsp/test/Handler/XphpDefinitionHandlerTest.php index 53178323..ae1c4ee1 100644 --- a/tools/lsp/test/Handler/XphpDefinitionHandlerTest.php +++ b/tools/lsp/test/Handler/XphpDefinitionHandlerTest.php @@ -14,8 +14,10 @@ use PHPUnit\Framework\TestCase; use XPHP\Lsp\Analyzer\Analyzer; use XPHP\Lsp\Analyzer\ParsedDocumentCache; +use XPHP\Lsp\Handler\WorkspaceSymbols; use XPHP\Lsp\Handler\XphpDefinitionHandler; use XPHP\Lsp\PositionMap; +use XPHP\Lsp\Reflection\FqnIndex; use XPHP\Transpiler\Monomorphize\XphpSourceParser; use function Amp\Promise\wait; @@ -163,6 +165,256 @@ class Container { public T $item; } self::assertSame('/Container.xphp', $location->uri); } + public function testJumpsFromTypeArgInsideGenericClauseToClassDeclaration(): void + { + // The xphp-specific case: Ctrl+click on `User` inside the `<>` of + // `identity(...)` should land on `class User`. This relies on + // the second code path in `definition()` -- the inner `User` doesn't + // survive as a Name node in the AST (XphpSourceParser strips it into + // a marker entry on the outer FuncCall), so the ATTR_TEMPLATE_FQN + // lookup misses; the fall-through uses TypeArgPositionDetector + + // WorkspaceSymbols to resolve. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/User.xphp', 'xphp', 1, <<<'XPHP' + (new User('bob'));"; + $workspace->open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); + + // Cursor points at the `User` INSIDE the angle brackets, not the + // `User` in the `new User(...)` ctor. + $genericClauseStart = strpos($useSource, 'identity<') + strlen('identity<'); + $location = $this->definitionAtOffset($this->newHandler($workspace), '/Use.xphp', $useSource, $genericClauseStart + 1); + + self::assertNotNull($location); + self::assertSame('/User.xphp', $location->uri); + } + + public function testTypeArgFallthroughReturnsNullWhenClassNotInWorkspace(): void + { + // No `class Unknown` declared anywhere in the workspace -> null. + // Distinguishes "we tried Path 2 and didn't find anything" from a + // wiring bug. + $workspace = new PhpactorWorkspace(); + $useSource = "(null);"; + $workspace->open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); + + $offset = strpos($useSource, 'Unknown') + 1; + $location = $this->definitionAtOffset($this->newHandler($workspace), '/Use.xphp', $useSource, $offset); + + self::assertNull($location); + } + + public function testGtdOnGenericInstantiationJumpsToTemplateInUnopenedFile(): void + { + // Phase 2.3: template lives in an .xphp file on disk that the user + // hasn't opened. GTD must still resolve via the filesystem half of + // FqnIndex; without it the jump returns null and PhpStorm shows + // "Cannot find declaration to go to." + $root = sys_get_temp_dir() . '/xphp-def-fs-' . bin2hex(random_bytes(6)); + mkdir($root, 0o755, true); + try { + file_put_contents($root . '/Box.xphp', <<<'XPHP' + + { + public T $item; + } + XPHP); + + $workspace = new PhpactorWorkspace(); + $useSource = "();\n"; + $workspace->open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); + + // Cursor on `Box` of `new Box()`. + $byte = strpos($useSource, 'new Box') + strlen('new '); + $location = $this->definitionAtOffset($this->newHandler($workspace, $root), '/Use.xphp', $useSource, $byte); + + self::assertInstanceOf(Location::class, $location); + self::assertSame('file://' . $root . '/Box.xphp', $location->uri); + // `class Box` is on line 2 (0-indexed); `Box` starts at char 6 + // (after `class `). + self::assertSame(2, $location->range->start->line); + self::assertSame(6, $location->range->start->character); + self::assertSame(2, $location->range->end->line); + self::assertSame(6 + strlen('Box'), $location->range->end->character); + } finally { + $this->rmrfPath($root); + } + } + + public function testGtdOnTypeArgIdentifierJumpsToUnopenedFile(): void + { + // Phase 2.3: the short-name (Path 2) lookup also gets the + // filesystem fallback. `User` declaration lives on disk only. + $root = sys_get_temp_dir() . '/xphp-def-fs-' . bin2hex(random_bytes(6)); + mkdir($root, 0o755, true); + try { + file_put_contents($root . '/User.xphp', <<<'XPHP' + (...) -- the `User` identifier is INSIDE the + // generic clause, only reachable via TypeArgPositionDetector. + $useSource = "(T \$x): T { return \$x; }\n\$x = identity(null);\n"; + $workspace->open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); + + // Cursor on `User`. + $byte = strpos($useSource, '') + 1; + $location = $this->definitionAtOffset($this->newHandler($workspace, $root), '/Use.xphp', $useSource, $byte); + + self::assertInstanceOf(Location::class, $location); + self::assertSame('file://' . $root . '/User.xphp', $location->uri); + // `class User {}` is on line 2 (0-indexed); `User` is at char 6. + self::assertSame(2, $location->range->start->line); + self::assertSame(6, $location->range->start->character); + } finally { + $this->rmrfPath($root); + } + } + + public function testGtdPrefersOpenDocOverFilesystemOnCollision(): void + { + // Open-doc wins when the same FQN is declared in both places -- + // the editor's unsaved buffer beats the on-disk copy. Matches + // the cross-cutting precedence rule baked into FqnIndex. + $root = sys_get_temp_dir() . '/xphp-def-fs-' . bin2hex(random_bytes(6)); + mkdir($root, 0o755, true); + try { + file_put_contents($root . '/Box.xphp', <<<'XPHP' + {} + XPHP); + + $workspace = new PhpactorWorkspace(); + $editedSource = " {}\n"; + $workspace->open(new TextDocumentItem('/edit/Box.xphp', 'xphp', 1, $editedSource)); + + $useSource = "();\n"; + $workspace->open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); + + $byte = strpos($useSource, 'new Box') + strlen('new '); + $location = $this->definitionAtOffset($this->newHandler($workspace, $root), '/Use.xphp', $useSource, $byte); + + self::assertInstanceOf(Location::class, $location); + // Must jump to the open-doc URI, not the file:// path. + self::assertSame('/edit/Box.xphp', $location->uri); + } finally { + $this->rmrfPath($root); + } + } + + private function rmrfPath(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $p = $dir . '/' . $entry; + if (is_dir($p)) { + $this->rmrfPath($p); + } else { + unlink($p); + } + } + rmdir($dir); + } + + public function testGtdOnFunctionDeclarationReturnsCallSitesAsReferences(): void + { + // Ctrl+Click on the function's OWN declaration name -- standard + // GTD is a no-op (cursor is already at the decl), so the handler + // promotes the request to find-usages and returns the list of + // call sites instead. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/funcs.xphp', 'xphp', 1, "open(new TextDocumentItem('/Use1.xphp', 'xphp', 1, "open(new TextDocumentItem('/Use2.xphp', 'xphp', 1, "get('/funcs.xphp')->text; + $result = $this->definitionAtNeedle($workspace, '/funcs.xphp', $source, 'function greet', strlen('function ')); + + self::assertIsArray($result, 'GTD on a declaration must return an array of usage Locations'); + $uris = array_map(fn (Location $l): string => $l->uri, $result); + self::assertContains('/Use1.xphp', $uris); + self::assertContains('/Use2.xphp', $uris); + self::assertNotContains('/funcs.xphp', $uris, 'the declaration itself must NOT appear in the references list'); + } + + public function testGtdOnClassDeclarationReturnsUsages(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/User.xphp', 'xphp', 1, "open(new TextDocumentItem('/Use.xphp', 'xphp', 1, "get('/User.xphp')->text; + $result = $this->definitionAtNeedle($workspace, '/User.xphp', $source, 'class User', strlen('class ')); + + self::assertIsArray($result); + $uris = array_map(fn (Location $l): string => $l->uri, $result); + self::assertContains('/Use.xphp', $uris); + self::assertNotContains('/User.xphp', $uris); + } + + public function testGtdOnDeclarationWithNoUsagesFallsThroughToNormalGtd(): void + { + // No references exist anywhere -- the handler falls through to + // normal GTD paths (which return null for a self-targeting decl). + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/lonely.xphp', 'xphp', 1, "get('/lonely.xphp')->text; + $result = $this->definitionAtNeedle($workspace, '/lonely.xphp', $source, 'function lonely', strlen('function ')); + + self::assertNull($result); + } + + /** + * @return Location|list|null + */ + private function definitionAtNeedle( + PhpactorWorkspace $workspace, + string $uri, + string $source, + string $needle, + int $offsetInNeedle, + ): mixed { + $byte = strpos($source, $needle); + self::assertNotFalse($byte); + $byte += $offsetInNeedle; + [$line, $character] = (new PositionMap($source))->offsetToPosition($byte); + $params = new DefinitionParams( + new TextDocumentIdentifier($uri), + new Position($line, $character), + ); + return wait($this->newHandler($workspace)->definition($params)); + } + + private function definitionAtOffset( + XphpDefinitionHandler $handler, + string $uri, + string $source, + int $byte, + ): ?Location { + [$line, $character] = (new PositionMap($source))->offsetToPosition($byte); + $params = new DefinitionParams( + new TextDocumentIdentifier($uri), + new Position($line, $character), + ); + return wait($handler->definition($params)); + } + private function definitionAt( XphpDefinitionHandler $handler, string $uri, @@ -181,13 +433,32 @@ private function definitionAt( return wait($handler->definition($params)); } - private function newHandler(PhpactorWorkspace $workspace): XphpDefinitionHandler + private function newHandler(PhpactorWorkspace $workspace, ?string $rootPath = null): XphpDefinitionHandler { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $cache = new ParsedDocumentCache(new Analyzer($parser)); + $fqnIndex = new FqnIndex($workspace, $cache, $parser, $rootPath ?? ''); + $reflector = (new \XPHP\Lsp\Reflection\ReflectorFactory( + $workspace, + $cache, + $parser, + rootPath: $rootPath ?? '', + stubPath: \XPHP\Lsp\Reflection\ReflectorFactory::defaultStubPath(), + cacheDir: \XPHP\Lsp\Reflection\ReflectorFactory::defaultCacheDir(), + fqnIndex: $fqnIndex, + ))->build(); + $classLikeLookup = new \XPHP\Lsp\Resolver\CompositeClassLikeLookup( + new \XPHP\Lsp\Resolver\WorkspaceClassLikeLookup($workspace, $cache), + new \XPHP\Lsp\Resolver\FilesystemClassLikeLookup($fqnIndex), + ); + $genericResolver = new \XPHP\Lsp\Resolver\GenericResolver($workspace, $cache, $classLikeLookup, $parser, $fqnIndex); + $referenceFinder = new \XPHP\Lsp\Resolver\ReferenceFinder($workspace, $cache, $fqnIndex, $parser, $reflector, $genericResolver); return new XphpDefinitionHandler( $workspace, - new ParsedDocumentCache( - new Analyzer(new XphpSourceParser((new ParserFactory())->createForHostVersion())), - ), + $cache, + new WorkspaceSymbols($workspace, $cache), + $fqnIndex, + $referenceFinder, ); } } diff --git a/tools/lsp/test/Handler/XphpDocumentSymbolHandlerTest.php b/tools/lsp/test/Handler/XphpDocumentSymbolHandlerTest.php new file mode 100644 index 00000000..778ceac8 --- /dev/null +++ b/tools/lsp/test/Handler/XphpDocumentSymbolHandlerTest.php @@ -0,0 +1,251 @@ +collect(<<<'XPHP' + name = $name; } + public function shout(): string { return ''; } + } + XPHP); + + self::assertCount(1, $symbols); + $class = $symbols[0]; + self::assertSame('User', $class->name); + self::assertSame(SymbolKind::CLASS_, $class->kind); + + $childIndex = []; + foreach ($class->children ?? [] as $child) { + $childIndex[$child->name] = $child; + } + self::assertArrayHasKey('ROLE', $childIndex); + self::assertSame(SymbolKind::CONSTANT, $childIndex['ROLE']->kind); + self::assertArrayHasKey('$name', $childIndex); + self::assertSame(SymbolKind::PROPERTY, $childIndex['$name']->kind); + self::assertArrayHasKey('$age', $childIndex); + self::assertArrayHasKey('__construct', $childIndex); + self::assertSame(SymbolKind::CONSTRUCTOR, $childIndex['__construct']->kind); + self::assertArrayHasKey('shout', $childIndex); + self::assertSame(SymbolKind::METHOD, $childIndex['shout']->kind); + } + + public function testInterfaceUsesInterfaceKind(): void + { + $symbols = $this->collect(<<<'XPHP' + name); + self::assertSame(SymbolKind::INTERFACE, $symbols[0]->kind); + self::assertCount(1, $symbols[0]->children ?? []); + self::assertSame('greet', $symbols[0]->children[0]->name); + } + + public function testEnumUsesEnumKindAndExposesCases(): void + { + $symbols = $this->collect(<<<'XPHP' + name); + self::assertSame(SymbolKind::ENUM, $enum->kind); + + $names = array_map(fn (DocumentSymbol $s): string => $s->name, $enum->children ?? []); + self::assertContains('Hearts', $names); + self::assertContains('Spades', $names); + foreach ($enum->children ?? [] as $case) { + self::assertSame(SymbolKind::ENUM_MEMBER, $case->kind); + } + } + + public function testTopLevelFunctionEmittedAtRoot(): void + { + $symbols = $this->collect(<<<'XPHP' + $s->name, $symbols); + self::assertContains('identity', $names); + self::assertContains('Thing', $names); + + foreach ($symbols as $s) { + if ($s->name === 'identity') { + self::assertSame(SymbolKind::FUNCTION, $s->kind); + } + } + } + + public function testMultipleNamesInSinglePropertyOrConstStatementYieldSeparateSymbols(): void + { + // `public string $a, $b;` produces a single Property AST node with + // two PropertyItem entries. The outline must surface BOTH. + $symbols = $this->collect(<<<'XPHP' + $s->name, $symbols[0]->children ?? []); + self::assertContains('FIRST', $names); + self::assertContains('SECOND', $names); + self::assertContains('$left', $names); + self::assertContains('$right', $names); + } + + public function testAnonymousClassesAreSkipped(): void + { + $symbols = $this->collect(<<<'XPHP' + $s->name, $symbols); + // Only the named class survives -- the anonymous one has no entry + // point in the outline. + self::assertSame(['Named'], $names); + } + + public function testEmptyDocumentReturnsEmptyList(): void + { + self::assertSame([], $this->collect("newCache()); + $params = new DocumentSymbolParams(new TextDocumentIdentifier('/never-opened.xphp')); + self::assertNull(wait($handler->documentSymbol($params))); + } + + public function testMethodsMapRegistersEndpoint(): void + { + // Without this entry, the dispatcher never routes + // textDocument/documentSymbol to the handler. ArrayItemRemoval guard. + $methods = (new XphpDocumentSymbolHandler(new PhpactorWorkspace(), $this->newCache())) + ->methods(); + self::assertArrayHasKey('textDocument/documentSymbol', $methods); + self::assertSame('documentSymbol', $methods['textDocument/documentSymbol']); + } + + public function testTBracketPropertyEmitsOriginalSourcePositions(): void + { + // Regression for the prod trace from xphp-20260525-003910-606.log: + // properties declared with `T[]` sugar reported selectionRanges + // shifted by +2 columns because the parser rewrites `T[]` (3 bytes) + // to `array` (5 bytes). The handler must back-translate AST + // offsets through the ByteOffsetMap before constructing Ranges. + $source = "\n{\n private T[] \$items = [];\n}\n"; + $symbols = $this->collect($source); + + self::assertCount(1, $symbols); + $children = $symbols[0]->children ?? []; + self::assertCount(1, $children); + $prop = $children[0]; + self::assertSame('$items', $prop->name); + + // The original source's line 4 (0-indexed) is: + // " private T[] $items = [];" + // `$items` starts at column 16, `items` (the name) starts at column 17. + // Without the offset map the handler would emit column 18 (the byte + // position in the stripped source where `array $items` ends at 18). + self::assertSame(4, $prop->selectionRange->start->line); + self::assertSame(16, $prop->selectionRange->start->character); + } + + public function testSelectionRangeTargetsNameNotEntireClass(): void + { + // The outline panel scrolls/highlights based on selectionRange; if we + // pointed it at the whole class body the editor would highlight the + // entire block instead of the identifier. + $symbols = $this->collect(<<<'XPHP' + name); + // selectionRange.start must equal the byte offset of `Tag` in the source. + // The class statement starts further left at `class `, so range.start + // is strictly less. + $selStart = $class->selectionRange->start; + $rangeStart = $class->range->start; + $isLater = $selStart->line > $rangeStart->line + || ($selStart->line === $rangeStart->line && $selStart->character > $rangeStart->character); + self::assertTrue($isLater, 'selectionRange must start AFTER the `class ` keyword'); + } + + /** + * @return list + */ + private function collect(string $source): array + { + $workspace = new PhpactorWorkspace(); + $uri = '/doc.xphp'; + $workspace->open(new TextDocumentItem($uri, 'xphp', 1, $source)); + $handler = new XphpDocumentSymbolHandler($workspace, $this->newCache()); + $params = new DocumentSymbolParams(new TextDocumentIdentifier($uri)); + $result = wait($handler->documentSymbol($params)); + self::assertIsArray($result); + return $result; + } + + private function newCache(): ParsedDocumentCache + { + return new ParsedDocumentCache( + new Analyzer(new XphpSourceParser((new ParserFactory())->createForHostVersion())), + ); + } +} diff --git a/tools/lsp/test/Handler/XphpFileWatcherHandlerTest.php b/tools/lsp/test/Handler/XphpFileWatcherHandlerTest.php new file mode 100644 index 00000000..ad9d3af7 --- /dev/null +++ b/tools/lsp/test/Handler/XphpFileWatcherHandlerTest.php @@ -0,0 +1,138 @@ +root = sys_get_temp_dir() . '/xphp-watch-' . bin2hex(random_bytes(6)); + mkdir($this->root, 0o755, true); + } + + protected function tearDown(): void + { + if (is_dir($this->root)) { + $this->rmrf($this->root); + } + } + + public function testMethodsMapRegistersWatchedFilesNotification(): void + { + $handler = new XphpFileWatcherHandler($this->index()); + self::assertArrayHasKey('workspace/didChangeWatchedFiles', $handler->methods()); + self::assertSame('didChangeWatchedFiles', $handler->methods()['workspace/didChangeWatchedFiles']); + } + + public function testFsChangeForcesRescanSoNewlyAddedClassSurfaces(): void + { + // Initial state: one file on disk. + file_put_contents($this->root . '/Alpha.xphp', "index(); + self::assertContains('App\\Alpha', $index->allClassFqns()); + + // Add a second file -- the index hasn't seen it yet. + file_put_contents($this->root . '/Beta.xphp', "allClassFqns(), + 'pre-invalidation: stale cache should still NOT see Beta', + ); + + // Fire the notification -- forces a rewalk on next query. + $handler = new XphpFileWatcherHandler($index); + $params = new DidChangeWatchedFilesParams([ + new FileEvent('file://' . $this->root . '/Beta.xphp', FileChangeType::CREATED), + ]); + $result = wait($handler->didChangeWatchedFiles($params)); + self::assertNull($result); + + // Post-invalidation: next query picks up Beta. + self::assertContains('App\\Beta', $index->allClassFqns()); + // Alpha still there (the rewalk doesn't lose old entries). + self::assertContains('App\\Alpha', $index->allClassFqns()); + } + + public function testFsDeletionRemovesEntryAfterInvalidation(): void + { + // Two files initially. + file_put_contents($this->root . '/Keep.xphp', "root . '/Gone.xphp', "index(); + self::assertContains('App\\Gone', $index->allClassFqns()); + + unlink($this->root . '/Gone.xphp'); + // Still cached. + self::assertContains('App\\Gone', $index->allClassFqns()); + + $handler = new XphpFileWatcherHandler($index); + wait($handler->didChangeWatchedFiles(new DidChangeWatchedFilesParams([ + new FileEvent('file://' . $this->root . '/Gone.xphp', FileChangeType::DELETED), + ]))); + + // After rewalk, Gone is gone. + self::assertNotContains('App\\Gone', $index->allClassFqns()); + self::assertContains('App\\Keep', $index->allClassFqns()); + } + + public function testEmptyChangesArrayDoesNotInvalidate(): void + { + // Defensive: zero-change notifications shouldn't trigger a needless + // rescan. Some clients may forward debounce flushes as empty + // payloads; we shouldn't pay the walk cost for those. + file_put_contents($this->root . '/A.xphp', "index(); + // Warm the cache. + $index->allClassFqns(); + // Add a new file after warming -- it should NOT surface after a + // no-op notification. + file_put_contents($this->root . '/B.xphp', "didChangeWatchedFiles(new DidChangeWatchedFilesParams([]))); + + self::assertNotContains('App\\B', $index->allClassFqns()); + } + + private function index(): FqnIndex + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $cache = new ParsedDocumentCache(new Analyzer($parser)); + return new FqnIndex(new PhpactorWorkspace(), $cache, $parser, $this->root); + } + + private function rmrf(string $dir): void + { + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $p = $dir . '/' . $entry; + if (is_dir($p)) { + $this->rmrf($p); + } else { + unlink($p); + } + } + rmdir($dir); + } +} diff --git a/tools/lsp/test/Handler/XphpReferencesHandlerTest.php b/tools/lsp/test/Handler/XphpReferencesHandlerTest.php new file mode 100644 index 00000000..7d098c8a --- /dev/null +++ b/tools/lsp/test/Handler/XphpReferencesHandlerTest.php @@ -0,0 +1,544 @@ +root = sys_get_temp_dir() . '/xphp-refs-' . bin2hex(random_bytes(6)); + mkdir($this->root, 0o755, true); + } + + protected function tearDown(): void + { + if (is_dir($this->root)) { + $this->rmrf($this->root); + } + } + + public function testMethodsMapRegistersEndpoint(): void + { + $handler = $this->handler(new PhpactorWorkspace()); + self::assertArrayHasKey('textDocument/references', $handler->methods()); + self::assertSame('references', $handler->methods()['textDocument/references']); + } + + public function testFindsClassReferencesAcrossOpenDocs(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/User.xphp', 'xphp', 1, <<<'XPHP' + open(new TextDocumentItem('/Use1.xphp', 'xphp', 1, <<<'XPHP' + open(new TextDocumentItem('/Use2.xphp', 'xphp', 1, <<<'XPHP' + references($workspace, '/User.xphp', 'class User', strlen('class ')); + + $uris = array_map(fn (Location $l): string => $l->uri, $locations); + // Four matches: declaration + `use App\User;` import + `new User()` + `\App\User` type hint. + // The `use` statement IS counted as a usage -- PhpStorm's Find + // Usages groups them under "imports" but does surface them. + self::assertCount(4, $locations); + self::assertContains('/User.xphp', $uris); + self::assertContains('/Use1.xphp', $uris); + self::assertContains('/Use2.xphp', $uris); + } + + public function testIncludeDeclarationFalseDropsDeclSite(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/User.xphp', 'xphp', 1, "open(new TextDocumentItem('/Use.xphp', 'xphp', 1, "references( + $workspace, + '/User.xphp', + 'class User', + strlen('class '), + includeDeclaration: false, + ); + + $uris = array_map(fn (Location $l): string => $l->uri, $locations); + // 2 matches in /Use.xphp (the `use App\User;` import + `new User()`); + // the declaration in /User.xphp is dropped by includeDeclaration=false. + self::assertCount(2, $locations); + foreach ($uris as $u) { + self::assertSame('/Use.xphp', $u); + } + } + + public function testFindsClassReferencesFromUseCaseInsteadOfDeclaration(): void + { + // Cursor on a USE of the class -- should still find every other + // use of it, not just the cursor's own line. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/User.xphp', 'xphp', 1, "open(new TextDocumentItem('/Use1.xphp', 'xphp', 1, "open(new TextDocumentItem('/Use2.xphp', 'xphp', 1, "references($workspace, '/Use1.xphp', 'new User()', strlen('new ')); + + $uris = array_map(fn (Location $l): string => $l->uri, $locations); + // All three files contain App\User -- decl + two uses (each Use* + // contains one `new User()` reference). + sort($uris); + self::assertContains('/User.xphp', $uris); + self::assertContains('/Use1.xphp', $uris); + self::assertContains('/Use2.xphp', $uris); + } + + public function testFindsFunctionReferences(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/lib.xphp', 'xphp', 1, "open(new TextDocumentItem('/use.xphp', 'xphp', 1, "references($workspace, '/lib.xphp', 'function identity', strlen('function ')); + + $uris = array_map(fn (Location $l): string => $l->uri, $locations); + // Decl + `use function App\identity` import + 2 calls = 4. + self::assertCount(4, $locations); + self::assertContains('/lib.xphp', $uris); + self::assertContains('/use.xphp', $uris); + } + + public function testFindsFunctionRefsInsideGroupUseStmt(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/lib.xphp', 'xphp', 1, "open(new TextDocumentItem('/use.xphp', 'xphp', 1, "references($workspace, '/lib.xphp', 'function one', strlen('function ')); + $uris = array_map(fn (Location $l): string => $l->uri, $locations); + // decl + group-use entry for `one` + call in use.xphp = 3. + self::assertCount(3, $locations); + self::assertContains('/use.xphp', $uris); + } + + public function testFindsReferencesAcrossFilesystem(): void + { + file_put_contents($this->root . '/User.xphp', "root . '/Consumer.xphp', "open(new TextDocumentItem($this->root . '/User.xphp', 'xphp', 1, file_get_contents($this->root . '/User.xphp'))); + + $locations = $this->references( + $workspace, + $this->root . '/User.xphp', + 'class User', + strlen('class '), + ); + + $uris = array_map(fn (Location $l): string => $l->uri, $locations); + self::assertContains($this->root . '/User.xphp', $uris, 'declaration site'); + self::assertContains('file://' . $this->root . '/Consumer.xphp', $uris, 'unopened filesystem reference'); + } + + public function testInstanceofExtendsImplementsAllSurfaceAsClassRefs(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/Base.xphp', 'xphp', 1, "open(new TextDocumentItem('/Child.xphp', 'xphp', 1, "references($workspace, '/Base.xphp', 'class Base', strlen('class ')); + $uris = array_map(fn (Location $l): string => $l->uri, $locations); + + // Declaration + extends Base + instanceof Base. + self::assertCount(3, $locations); + self::assertContains('/Base.xphp', $uris); + self::assertContains('/Child.xphp', $uris); + $childMatches = array_filter($locations, fn ($l) => $l->uri === '/Child.xphp'); + self::assertCount(2, $childMatches, 'extends + instanceof both count'); + } + + public function testFindsMethodReferencesAcrossWorkspace(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/User.xphp', 'xphp', 1, <<<'XPHP' + open(new TextDocumentItem('/Use.xphp', 'xphp', 1, <<<'XPHP' + shout(); + $u->shout(); + XPHP)); + + // Cursor on the method declaration: `function shout`. + $locations = $this->references($workspace, '/User.xphp', 'function shout', strlen('function ')); + + // Declaration in /User.xphp + 2 calls in /Use.xphp = 3. + self::assertCount(3, $locations); + $uris = array_map(fn (Location $l): string => $l->uri, $locations); + self::assertContains('/User.xphp', $uris); + $useMatches = array_filter($locations, fn (Location $l): bool => $l->uri === '/Use.xphp'); + self::assertCount(2, $useMatches); + } + + public function testFindsMethodReferencesFromCallSiteCursor(): void + { + // Cursor on a `$x->method()` call -- should still find every + // call of the same method on the same receiver class, including + // the declaration site. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/User.xphp', 'xphp', 1, "open(new TextDocumentItem('/Use.xphp', 'xphp', 1, "shout();\n")); + + $locations = $this->references($workspace, '/Use.xphp', '->shout', strlen('->')); + + $uris = array_map(fn (Location $l): string => $l->uri, $locations); + self::assertContains('/User.xphp', $uris); + self::assertContains('/Use.xphp', $uris); + } + + public function testMethodRefsDoNotLeakAcrossClassesWithSameName(): void + { + // Two unrelated classes both define `shout()`. Cursor on User's + // declaration must NOT surface calls on a Megaphone instance. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/Both.xphp', 'xphp', 1, <<<'XPHP' + shout(); + $m = new Megaphone(); + $m->shout(); + XPHP)); + + // Cursor on User's `function shout`. + $source = $workspace->get('/Both.xphp')->text; + $byte = strpos($source, 'function shout') + strlen('function '); + [$line, $character] = (new PositionMap($source))->offsetToPosition($byte); + $handler = $this->handler($workspace); + $params = new ReferenceParams( + new ReferenceContext(true), + new TextDocumentIdentifier('/Both.xphp'), + new Position($line, $character), + ); + $locations = wait($handler->references($params)); + + // Decl + $u->shout() = 2. $m->shout() (on line index 11) must + // NOT appear. No location should sit on the Megaphone call's line. + self::assertCount(2, $locations); + foreach ($locations as $loc) { + self::assertNotSame(11, $loc->range->start->line, 'must not match the Megaphone instance call'); + } + } + + public function testFindsInheritedMethodCallsOnSubclassReceiver(): void + { + // Item 1: cursor on `Animal::speak` should also surface + // `$dog->speak()` -- Dog extends Animal and doesn't override + // `speak`, so the call inherits its behaviour. Before Item 1, + // V1's exact-FQN match dropped the Dog call. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/Animal.xphp', 'xphp', 1, <<<'XPHP' + open(new TextDocumentItem('/Dog.xphp', 'xphp', 1, <<<'XPHP' + open(new TextDocumentItem('/Use.xphp', 'xphp', 1, <<<'XPHP' + speak(); + $d = new Dog(); + $d->speak(); + XPHP)); + + // Cursor on Animal's `function speak` declaration. + $locations = $this->references($workspace, '/Animal.xphp', 'function speak', strlen('function ')); + + $uris = array_map(fn (Location $l): string => $l->uri, $locations); + self::assertContains('/Animal.xphp', $uris); + $useMatches = array_filter($locations, fn (Location $l): bool => $l->uri === '/Use.xphp'); + // Both `$a->speak()` AND `$d->speak()` must be in the result. + self::assertCount(2, $useMatches); + } + + public function testOverriddenSubclassMethodIsNotMatchedAsAncestorCall(): void + { + // Item 1 negative case: if Dog overrides `speak`, then + // `$d->speak()` resolves to `Dog::speak`, not `Animal::speak`. + // Cursor on Animal::speak must NOT surface the Dog call. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/Animal.xphp', 'xphp', 1, <<<'XPHP' + open(new TextDocumentItem('/Dog.xphp', 'xphp', 1, <<<'XPHP' + open(new TextDocumentItem('/Use.xphp', 'xphp', 1, <<<'XPHP' + speak(); + $d = new Dog(); + $d->speak(); + XPHP)); + + $locations = $this->references($workspace, '/Animal.xphp', 'function speak', strlen('function ')); + + $useMatches = array_filter($locations, fn (Location $l): bool => $l->uri === '/Use.xphp'); + // Only `$a->speak()` matches; `$d->speak()` resolves to Dog::speak + // (an override) and is a different symbol. + self::assertCount(1, $useMatches); + } + + public function testCursorOnSubclassInheritedCallResolvesToAncestor(): void + { + // Cursor on `$d->speak()` (with Dog extends Animal, no override) + // -- the target should resolve up to Animal::speak, so the + // declaration in Animal.xphp is found AND every call site + // through the chain (including `$a->speak()` on the parent). + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/Animal.xphp', 'xphp', 1, <<<'XPHP' + open(new TextDocumentItem('/Dog.xphp', 'xphp', 1, <<<'XPHP' + open(new TextDocumentItem('/Use.xphp', 'xphp', 1, <<<'XPHP' + speak(); + $d = new Dog(); + $d->speak(); + XPHP)); + + // Cursor on `$d->speak()` call. + $locations = $this->references($workspace, '/Use.xphp', '$d->speak', strlen('$d->')); + + $uris = array_map(fn (Location $l): string => $l->uri, $locations); + // Declaration must be found through the ancestor walk. + self::assertContains('/Animal.xphp', $uris); + // Both call sites in /Use.xphp must be present. + $useMatches = array_filter($locations, fn (Location $l): bool => $l->uri === '/Use.xphp'); + self::assertCount(2, $useMatches); + } + + public function testFindsInheritedPropertyAccessOnSubclassReceiver(): void + { + // Property variant of the inherited-member walk: Dog inherits + // Animal::$name and accesses it through `$d->name`. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/Animal.xphp', 'xphp', 1, <<<'XPHP' + open(new TextDocumentItem('/Dog.xphp', 'xphp', 1, <<<'XPHP' + open(new TextDocumentItem('/Use.xphp', 'xphp', 1, <<<'XPHP' + name; + $d = new Dog(); + echo $d->name; + XPHP)); + + $locations = $this->references($workspace, '/Animal.xphp', '$name', 1); + + $useMatches = array_filter($locations, fn (Location $l): bool => $l->uri === '/Use.xphp'); + self::assertCount(2, $useMatches); + } + + public function testFindsPropertyReferences(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/User.xphp', 'xphp', 1, <<<'XPHP' + open(new TextDocumentItem('/Use.xphp', 'xphp', 1, <<<'XPHP' + name; + echo $u->name; + XPHP)); + + $locations = $this->references($workspace, '/User.xphp', '$name', 1); + + $uris = array_map(fn (Location $l): string => $l->uri, $locations); + self::assertContains('/User.xphp', $uris); + $useMatches = array_filter($locations, fn (Location $l): bool => $l->uri === '/Use.xphp'); + self::assertCount(2, $useMatches); + } + + public function testEmptyResultWhenCursorIsNotOnReferenceableSymbol(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/x.xphp', 'xphp', 1, "references($workspace, '/x.xphp', '42', 0); + self::assertSame([], $locations); + } + + public function testEmptyResultForUnknownUri(): void + { + $handler = $this->handler(new PhpactorWorkspace()); + $params = new ReferenceParams( + new ReferenceContext(true), + new TextDocumentIdentifier('/never-opened.xphp'), + new Position(0, 0), + ); + self::assertSame([], wait($handler->references($params))); + } + + /** + * @return list + */ + private function references( + PhpactorWorkspace $workspace, + string $uri, + string $needle, + int $offsetInNeedle, + bool $includeDeclaration = true, + ): array { + $item = $workspace->get($uri); + $byte = strpos($item->text, $needle); + self::assertNotFalse($byte, "needle '$needle' must appear in $uri"); + $byte += $offsetInNeedle; + [$line, $character] = (new PositionMap($item->text))->offsetToPosition($byte); + $params = new ReferenceParams( + new ReferenceContext($includeDeclaration), + new TextDocumentIdentifier($uri), + new Position($line, $character), + ); + $result = wait($this->handler($workspace)->references($params)); + self::assertIsArray($result); + return $result; + } + + private function handler(PhpactorWorkspace $workspace): XphpReferencesHandler + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $cache = new ParsedDocumentCache(new Analyzer($parser)); + $fqnIndex = new FqnIndex($workspace, $cache, $parser, $this->root); + $reflector = (new ReflectorFactory( + $workspace, + $cache, + $parser, + rootPath: $this->root, + stubPath: ReflectorFactory::defaultStubPath(), + cacheDir: ReflectorFactory::defaultCacheDir(), + fqnIndex: $fqnIndex, + ))->build(); + $classLikeLookup = new CompositeClassLikeLookup( + new WorkspaceClassLikeLookup($workspace, $cache), + new FilesystemClassLikeLookup($fqnIndex), + ); + $genericResolver = new GenericResolver($workspace, $cache, $classLikeLookup, $parser, $fqnIndex); + return new XphpReferencesHandler( + $workspace, + new ReferenceFinder($workspace, $cache, $fqnIndex, $parser, $reflector, $genericResolver), + ); + } + + private function rmrf(string $dir): void + { + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $p = $dir . '/' . $entry; + if (is_dir($p)) { + $this->rmrf($p); + } else { + unlink($p); + } + } + rmdir($dir); + } +} diff --git a/tools/lsp/test/Handler/XphpRenameHandlerTest.php b/tools/lsp/test/Handler/XphpRenameHandlerTest.php new file mode 100644 index 00000000..c6cbdf2c --- /dev/null +++ b/tools/lsp/test/Handler/XphpRenameHandlerTest.php @@ -0,0 +1,497 @@ +handler(new PhpactorWorkspace()); + self::assertArrayHasKey('textDocument/rename', $handler->methods()); + self::assertSame('rename', $handler->methods()['textDocument/rename']); + } + + public function testRenamesClassAndAllReferencesIncludingShortName(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/User.xphp', 'xphp', 1, "open(new TextDocumentItem('/Use.xphp', 'xphp', 1, "renameAt($workspace, '/User.xphp', 'class User', strlen('class '), 'Customer'); + + self::assertInstanceOf(WorkspaceEdit::class, $edit); + $byUri = self::indexEdits($edit); + self::assertArrayHasKey('/User.xphp', $byUri); + self::assertArrayHasKey('/Use.xphp', $byUri); + + // /User.xphp: declaration rename only (`class User` -> `class Customer`). + self::assertCount(1, $byUri['/User.xphp']); + self::assertSame('Customer', $byUri['/User.xphp'][0]->newText); + + // /Use.xphp: two edits, `use App\User;` and `new User()` -- both + // get the short-name swap so the namespace prefix survives. + self::assertCount(2, $byUri['/Use.xphp']); + foreach ($byUri['/Use.xphp'] as $e) { + self::assertSame('Customer', $e->newText); + } + } + + public function testRenamesFunctionAcrossCalls(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/lib.xphp', 'xphp', 1, "open(new TextDocumentItem('/use.xphp', 'xphp', 1, "renameAt($workspace, '/lib.xphp', 'function old', strlen('function '), 'better'); + + self::assertNotNull($edit); + $byUri = self::indexEdits($edit); + self::assertCount(1, $byUri['/lib.xphp']); + self::assertSame('better', $byUri['/lib.xphp'][0]->newText); + // /use.xphp: 1 use-function import + 2 calls = 3 edits. The + // `use function App\old` alias is now tracked symmetric to the + // class-import behaviour (Use_::TYPE_FUNCTION branch in + // ReferenceFinder). + self::assertCount(3, $byUri['/use.xphp']); + } + + public function testRenamesMethodAcrossCallSites(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/User.xphp', 'xphp', 1, <<<'XPHP' + open(new TextDocumentItem('/Use.xphp', 'xphp', 1, "shout();\n\$u->shout();\n")); + + $edit = $this->renameAt($workspace, '/User.xphp', 'function shout', strlen('function '), 'cry'); + + $byUri = self::indexEdits($edit); + self::assertCount(1, $byUri['/User.xphp']); + self::assertCount(2, $byUri['/Use.xphp']); + foreach (array_merge($byUri['/User.xphp'], $byUri['/Use.xphp']) as $e) { + self::assertSame('cry', $e->newText); + } + } + + public function testRenamesMethodAcrossSubclassInheritedCallSites(): void + { + // Item 1: renaming Animal::speak should also rewrite `$dog->speak()` + // when Dog extends Animal without overriding -- the call inherits + // the method, so the rewrite must follow. Without the inheritance + // walk, Dog's call site silently survives as a dangling reference + // to the renamed-away symbol. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/Animal.xphp', 'xphp', 1, <<<'XPHP' + open(new TextDocumentItem('/Dog.xphp', 'xphp', 1, <<<'XPHP' + open(new TextDocumentItem('/Use.xphp', 'xphp', 1, <<<'XPHP' + speak(); + $d = new Dog(); + $d->speak(); + XPHP)); + + $edit = $this->renameAt($workspace, '/Animal.xphp', 'function speak', strlen('function '), 'bark'); + + $byUri = self::indexEdits($edit); + // Declaration in /Animal.xphp + 2 calls in /Use.xphp (both `$a` + // and `$d` flavours) = 3 edits total. + self::assertCount(1, $byUri['/Animal.xphp']); + self::assertCount(2, $byUri['/Use.xphp']); + foreach (array_merge($byUri['/Animal.xphp'], $byUri['/Use.xphp']) as $e) { + self::assertSame('bark', $e->newText); + } + } + + public function testRenamesProperty(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/User.xphp', 'xphp', 1, <<<'XPHP' + open(new TextDocumentItem('/Use.xphp', 'xphp', 1, "name;\necho \$u->name;\n")); + + $edit = $this->renameAt($workspace, '/User.xphp', '$name', 1, 'label'); + + $byUri = self::indexEdits($edit); + self::assertCount(1, $byUri['/User.xphp']); + self::assertCount(2, $byUri['/Use.xphp']); + foreach (array_merge($byUri['/User.xphp'], $byUri['/Use.xphp']) as $e) { + self::assertSame('label', $e->newText); + } + } + + public function testRejectsInvalidIdentifier(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/User.xphp', 'xphp', 1, "handler($workspace)->rename($params); + + $this->expectException(\Throwable::class); + wait($promise); + } + + public function testRejectsEmptyIdentifier(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/User.xphp', 'xphp', 1, "handler($workspace)->rename($params); + + $this->expectException(\Throwable::class); + wait($promise); + } + + public function testReturnsNullForUnknownUri(): void + { + $handler = $this->handler(new PhpactorWorkspace()); + $params = new RenameParams( + new TextDocumentIdentifier('/never-opened.xphp'), + new Position(0, 0), + 'NewName', + ); + self::assertNull(wait($handler->rename($params))); + } + + public function testReturnsNullWhenCursorIsNotOnRenameableSymbol(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/x.xphp', 'xphp', 1, "handler($workspace)->rename($params))); + } + + public function testCursorOnAliasDeclTokenRenamesAliasLocally(): void + { + // Regression for prod trace xphp-20260525-140847-285.log id=21: + // cursor on the alias token `xyz` inside `use ... as xyz` + // previously returned null because resolveTargetAt didn't + // recognise UseItem.alias as a renameable target. Same effect + // as cursor on a call site -- rename the local alias + its + // unqualified uses; leave the source function alone. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/lib.xphp', 'xphp', 1, "open(new TextDocumentItem('/use.xphp', 'xphp', 1, "renameAt($workspace, '/use.xphp', 'as xyz', strlen('as '), 'zzz'); + $byUri = self::indexEdits($edit); + + self::assertArrayNotHasKey('/lib.xphp', $byUri, 'underlying function must stay untouched'); + self::assertCount(3, $byUri['/use.xphp'], 'alias decl + 2 call sites = 3 edits'); + foreach ($byUri['/use.xphp'] as $e) { + self::assertSame('zzz', $e->newText); + } + } + + public function testCursorOnAliasedCallSiteRenamesAliasLocally(): void + { + // Regression for prod trace xphp-20260525-134748-448.log id=43: + // user puts cursor on `xyz()` (a call that resolves via + // `use function ... as xyz` to a function elsewhere) and types + // `zzz`. PhpStorm-style: rename the LOCAL alias, not the + // underlying function. The use stmt's source name and the + // function declaration must NOT change; only the alias decl and + // its call sites in this file get rewritten. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/lib.xphp', 'xphp', 1, "open(new TextDocumentItem('/use.xphp', 'xphp', 1, "renameAt($workspace, '/use.xphp', 'bar();', 0, 'zzz'); + $byUri = self::indexEdits($edit); + + // /lib.xphp MUST NOT appear -- the underlying function stays. + self::assertArrayNotHasKey('/lib.xphp', $byUri); + // /use.xphp gets 3 edits: the alias decl `as bar` -> `as zzz`, + // and the two `bar()` call sites. + self::assertCount(3, $byUri['/use.xphp']); + foreach ($byUri['/use.xphp'] as $e) { + self::assertSame('zzz', $e->newText); + } + } + + public function testAliasedCallSitesArePreservedDuringRename(): void + { + // Regression for prod trace xphp-20260525-131659-392.log id=192: + // `use function App\Models\{foo as bar};` + `bar()` call sites. + // Renaming `foo` should rewrite the SOURCE side of the alias + // (the group-use entry) but leave aliased call sites alone so + // the alias keeps working with the new function name. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/lib.xphp', 'xphp', 1, "open(new TextDocumentItem('/use.xphp', 'xphp', 1, "renameAt($workspace, '/lib.xphp', 'function foo', strlen('function '), 'fizz'); + $byUri = self::indexEdits($edit); + + self::assertCount(1, $byUri['/lib.xphp']); + self::assertSame('fizz', $byUri['/lib.xphp'][0]->newText); + // /use.xphp: ONLY the source name inside `{foo as bar}` should + // be edited. The `bar()` calls keep using the alias. + self::assertCount(1, $byUri['/use.xphp']); + self::assertSame('fizz', $byUri['/use.xphp'][0]->newText); + $useSource = $workspace->get('/use.xphp')->text; + $map = new PositionMap($useSource); + $start = $map->positionToOffset( + $byUri['/use.xphp'][0]->range->start->line, + $byUri['/use.xphp'][0]->range->start->character, + ); + $end = $map->positionToOffset( + $byUri['/use.xphp'][0]->range->end->line, + $byUri['/use.xphp'][0]->range->end->character, + ); + self::assertSame('foo', substr($useSource, $start, $end - $start)); + } + + public function testClassRenameEmitsRenameFileOpWhenBasenameMatches(): void + { + // PSR-4 convention: `class Foo` lives in `Foo.xphp`. Renaming + // `Foo` to `Bar` must emit a RenameFile op alongside the + // TextEdits so the file follows the class. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/User.xphp', 'xphp', 1, "open(new TextDocumentItem('/Use.xphp', 'xphp', 1, "renameAt($workspace, '/User.xphp', 'class User', strlen('class '), 'Customer'); + self::assertNotNull($edit); + + $renameOps = array_filter( + $edit->documentChanges ?? [], + fn ($c): bool => $c instanceof RenameFile, + ); + self::assertCount(1, $renameOps, 'class rename must emit exactly one RenameFile op'); + $renameOp = array_values($renameOps)[0]; + self::assertSame('/User.xphp', $renameOp->oldUri); + self::assertSame('/Customer.xphp', $renameOp->newUri); + self::assertSame('rename', $renameOp->kind); + } + + public function testClassRenameSkipsRenameFileWhenBasenameMismatch(): void + { + // Multiple classes per file (or any other non-PSR-4 layout) -- + // the file name doesn't match the class name, so we DON'T rename + // the file. Under-rename rather than rename the wrong file. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/Mixed.xphp', 'xphp', 1, "renameAt($workspace, '/Mixed.xphp', 'class User', strlen('class '), 'Customer'); + self::assertNotNull($edit); + $renameOps = array_filter( + $edit->documentChanges ?? [], + fn ($c): bool => $c instanceof RenameFile, + ); + self::assertCount(0, $renameOps, 'file rename must NOT fire when basename mismatches class short name'); + } + + public function testRenameFileSuppressedWhenClientDoesntSupportRenameOp(): void + { + // PhpStorm's LSP plugin advertises `resourceOperations: ["create"]` + // only -- it silently drops RenameFile ops. When the client + // capability is off, we must NOT emit RenameFile (the in-file + // TextEdits still apply, but the user does the file rename + // manually). VS Code advertises rename support and gets the op. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/User.xphp', 'xphp', 1, "createForHostVersion()); + $cache = new ParsedDocumentCache(new Analyzer($parser)); + $fqnIndex = new FqnIndex($workspace, $cache, $parser, ''); + $reflector = (new ReflectorFactory( + $workspace, + $cache, + $parser, + rootPath: '', + stubPath: ReflectorFactory::defaultStubPath(), + cacheDir: ReflectorFactory::defaultCacheDir(), + fqnIndex: $fqnIndex, + ))->build(); + $classLikeLookup = new CompositeClassLikeLookup( + new WorkspaceClassLikeLookup($workspace, $cache), + new FilesystemClassLikeLookup($fqnIndex), + ); + $genericResolver = new GenericResolver($workspace, $cache, $classLikeLookup, $parser, $fqnIndex); + $finder = new ReferenceFinder($workspace, $cache, $fqnIndex, $parser, $reflector, $genericResolver); + $provider = new RenameProvider($workspace, $finder, $fqnIndex, clientSupportsRenameFile: false); + $handler = new XphpRenameHandler($workspace, $provider); + + $params = self::paramsFor($workspace, '/User.xphp', 'class User', strlen('class '), 'Customer'); + $edit = wait($handler->rename($params)); + self::assertInstanceOf(WorkspaceEdit::class, $edit); + $renameOps = array_filter( + $edit->documentChanges ?? [], + fn ($c): bool => $c instanceof RenameFile, + ); + self::assertCount(0, $renameOps, 'must not emit RenameFile when client doesn\'t support it'); + } + + public function testMethodRenameDoesNotEmitRenameFile(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/User.xphp', 'xphp', 1, "renameAt($workspace, '/User.xphp', 'function shout', strlen('function '), 'cry'); + self::assertNotNull($edit); + $renameOps = array_filter( + $edit->documentChanges ?? [], + fn ($c): bool => $c instanceof RenameFile, + ); + self::assertCount(0, $renameOps, 'method rename must NOT touch files'); + } + + public function testFullyQualifiedRefKeepsNamespacePrefix(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/User.xphp', 'xphp', 1, "open(new TextDocumentItem('/Use.xphp', 'xphp', 1, "renameAt($workspace, '/User.xphp', 'class User', strlen('class '), 'Customer'); + $byUri = self::indexEdits($edit); + + // /Use.xphp got ONE edit that should only replace `User` -- the + // `\App\` prefix has to survive. The TextEdit range starts AFTER + // the last backslash; the newText is just the short name. + self::assertCount(1, $byUri['/Use.xphp']); + self::assertSame('Customer', $byUri['/Use.xphp'][0]->newText); + // Range should start past the last backslash; rebuild the + // original text via the range positions to confirm. + $source = $workspace->get('/Use.xphp')->text; + $map = new PositionMap($source); + $start = $map->positionToOffset( + $byUri['/Use.xphp'][0]->range->start->line, + $byUri['/Use.xphp'][0]->range->start->character, + ); + $end = $map->positionToOffset( + $byUri['/Use.xphp'][0]->range->end->line, + $byUri['/Use.xphp'][0]->range->end->character, + ); + self::assertSame('User', substr($source, $start, $end - $start)); + } + + /** + * @return array> + */ + private static function indexEdits(?WorkspaceEdit $edit): array + { + self::assertNotNull($edit); + $byUri = []; + foreach ($edit->documentChanges ?? [] as $tde) { + // RenameFile / CreateFile / DeleteFile ops may appear in the + // same documentChanges array; the existing tests focus on + // TextDocumentEdits, so filter the rest out. Dedicated + // file-rename tests inspect documentChanges directly. + if (!$tde instanceof TextDocumentEdit) { + continue; + } + $byUri[$tde->textDocument->uri] = $tde->edits; + } + return $byUri; + } + + private function renameAt( + PhpactorWorkspace $workspace, + string $uri, + string $needle, + int $offsetInNeedle, + string $newName, + ): ?WorkspaceEdit { + $params = self::paramsFor($workspace, $uri, $needle, $offsetInNeedle, $newName); + $result = wait($this->handler($workspace)->rename($params)); + self::assertTrue($result === null || $result instanceof WorkspaceEdit); + return $result; + } + + private static function paramsFor( + PhpactorWorkspace $workspace, + string $uri, + string $needle, + int $offsetInNeedle, + string $newName, + ): RenameParams { + $item = $workspace->get($uri); + $byte = strpos($item->text, $needle); + self::assertNotFalse($byte, "needle '$needle' must exist in $uri"); + $byte += $offsetInNeedle; + [$line, $character] = (new PositionMap($item->text))->offsetToPosition($byte); + return new RenameParams( + new TextDocumentIdentifier($uri), + new Position($line, $character), + $newName, + ); + } + + private function handler(PhpactorWorkspace $workspace): XphpRenameHandler + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $cache = new ParsedDocumentCache(new Analyzer($parser)); + $fqnIndex = new FqnIndex($workspace, $cache, $parser, ''); + $reflector = (new ReflectorFactory( + $workspace, + $cache, + $parser, + rootPath: '', + stubPath: ReflectorFactory::defaultStubPath(), + cacheDir: ReflectorFactory::defaultCacheDir(), + fqnIndex: $fqnIndex, + ))->build(); + $classLikeLookup = new CompositeClassLikeLookup( + new WorkspaceClassLikeLookup($workspace, $cache), + new FilesystemClassLikeLookup($fqnIndex), + ); + $genericResolver = new GenericResolver($workspace, $cache, $classLikeLookup, $parser, $fqnIndex); + $finder = new ReferenceFinder($workspace, $cache, $fqnIndex, $parser, $reflector, $genericResolver); + return new XphpRenameHandler($workspace, new RenameProvider($workspace, $finder, $fqnIndex)); + } +} diff --git a/tools/lsp/test/Handler/XphpTextDocumentHandlerTest.php b/tools/lsp/test/Handler/XphpTextDocumentHandlerTest.php new file mode 100644 index 00000000..971a29c4 --- /dev/null +++ b/tools/lsp/test/Handler/XphpTextDocumentHandlerTest.php @@ -0,0 +1,117 @@ + ['uri' => 'file:///a.xphp', 'version' => 2], + 'contentChanges' => [['text' => 'hello']], + ], true); + + self::assertCount(1, $params->contentChanges); + $change = $params->contentChanges[0]; + self::assertInstanceOf(TextDocumentContentChangeFullEvent::class, $change); + self::assertSame('hello', $change->text); + } + + public function testVendorTextDocumentHandlerThrowsOnObjectContentChanges(): void + { + // Locks in the upstream bug: array-subscript on the FullEvent object + // is what wipes out the entire didChange path in production. When + // phpactor releases a fix (and we upgrade), this test must be deleted + // along with our XphpTextDocumentHandler -- the test failing is the + // signal that the workaround is no longer needed. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('file:///a.xphp', 'xphp', 1, '')); + $dispatcher = new AggregateEventDispatcher(new WorkspaceListener($workspace)); + $vendor = new VendorTextDocumentHandler($dispatcher); + + $params = DidChangeTextDocumentParams::fromArray([ + 'textDocument' => ['uri' => 'file:///a.xphp', 'version' => 2], + 'contentChanges' => [['text' => '$repo->']], + ], true); + + self::expectException(\Error::class); + self::expectExceptionMessageMatches('/Cannot use object .* as array/'); + $vendor->didChange($params); + } + + public function testOurDidChangeUpdatesWorkspaceWhenContentChangesAreObjects(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('file:///a.xphp', 'xphp', 1, '')); + $dispatcher = new AggregateEventDispatcher(new WorkspaceListener($workspace)); + $handler = new XphpTextDocumentHandler($dispatcher); + + $params = DidChangeTextDocumentParams::fromArray([ + 'textDocument' => ['uri' => 'file:///a.xphp', 'version' => 2], + 'contentChanges' => [['text' => '$repo->']], + ], true); + + $handler->didChange($params); + + $doc = $workspace->get('file:///a.xphp'); + self::assertSame('$repo->', $doc->text); + self::assertSame(2, $doc->version); + } + + public function testOurDidChangeAlsoWorksWithRawArrayContentChanges(): void + { + // Belt-and-braces: if a future protocol revision delivers contentChanges + // as raw arrays (or some intermediate framework reverts the hydration), + // we still update. Mirrors the vendor's original array-subscript shape. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('file:///b.xphp', 'xphp', 1, '')); + $dispatcher = new AggregateEventDispatcher(new WorkspaceListener($workspace)); + $handler = new XphpTextDocumentHandler($dispatcher); + + // Skip fromArray hydration and pass raw arrays directly via reflection. + $params = new DidChangeTextDocumentParams( + new \Phpactor\LanguageServerProtocol\VersionedTextDocumentIdentifier(3, 'file:///b.xphp'), + [['text' => 'raw shape']], + ); + + $handler->didChange($params); + + self::assertSame('raw shape', $workspace->get('file:///b.xphp')->text); + } + + public function testRegisterCapabilitiesAdvertisesFullTextDocumentSync(): void + { + $handler = new XphpTextDocumentHandler(new AggregateEventDispatcher()); + $capabilities = new ServerCapabilities(); + + $handler->registerCapabiltiies($capabilities); + + self::assertSame(TextDocumentSyncKind::FULL, $capabilities->textDocumentSync); + } +} diff --git a/tools/lsp/test/Handler/XphpWorkspaceSymbolHandlerTest.php b/tools/lsp/test/Handler/XphpWorkspaceSymbolHandlerTest.php new file mode 100644 index 00000000..18968456 --- /dev/null +++ b/tools/lsp/test/Handler/XphpWorkspaceSymbolHandlerTest.php @@ -0,0 +1,271 @@ +root = sys_get_temp_dir() . '/xphp-ws-sym-' . bin2hex(random_bytes(6)); + mkdir($this->root, 0o755, true); + } + + protected function tearDown(): void + { + if (is_dir($this->root)) { + $this->rmrf($this->root); + } + } + + public function testEmptyQueryReturnsAllDeclarations(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/User.xphp', 'xphp', 1, <<<'XPHP' + open(new TextDocumentItem('/Repo.xphp', 'xphp', 1, <<<'XPHP' + {} + XPHP)); + + $results = $this->query($workspace, ''); + $names = array_map(fn (SymbolInformation $s): string => $s->name, $results); + self::assertContains('User', $names); + self::assertContains('Repository', $names); + } + + public function testQueryFiltersBySubstringOnShortName(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/Tag.xphp', 'xphp', 1, "open(new TextDocumentItem('/Pair.xphp', 'xphp', 1, "open(new TextDocumentItem('/Repo.xphp', 'xphp', 1, "query($workspace, 'tag'); + $names = array_map(fn (SymbolInformation $s): string => $s->name, $results); + self::assertContains('Tag', $names); + self::assertNotContains('Pair', $names); + self::assertNotContains('Repository', $names); + } + + public function testQueryMatchesCaseInsensitively(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/HTTPClient.xphp', 'xphp', 1, "query($workspace, 'httpc'); + $names = array_map(fn (SymbolInformation $s): string => $s->name, $results); + self::assertContains('HTTPClient', $names); + } + + public function testQueryDoesNotMatchAgainstNamespace(): void + { + // PhpStorm's "Go to Symbol" popup expects matching on the short + // name only; if a user types "App" they want classes literally + // named App*, not every class in the App namespace. The client + // re-filters anyway, but our pre-filter should be conservative. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/User.xphp', 'xphp', 1, "query($workspace, 'models'); + self::assertSame([], $results); + } + + public function testEmitsCorrectKindPerClassLikeShape(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/A.xphp', 'xphp', 1, "query($workspace, '') as $s) { + $byName[$s->name] = $s; + } + self::assertSame(SymbolKind::CLASS_, $byName['C']->kind); + self::assertSame(SymbolKind::INTERFACE, $byName['I']->kind); + // LSP has no TRAIT kind; we render with CLASS_. + self::assertSame(SymbolKind::CLASS_, $byName['Tr']->kind); + self::assertSame(SymbolKind::ENUM, $byName['E']->kind); + self::assertSame(SymbolKind::FUNCTION, $byName['f']->kind); + } + + public function testContainerNameSurfacesNamespace(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem( + '/Repo.xphp', + 'xphp', + 1, + "query($workspace, 'Repo'); + self::assertCount(1, $results); + self::assertSame('Repository', $results[0]->name); + self::assertSame('App\\Containers', $results[0]->containerName); + } + + public function testLocationPointsAtIdentifierLine(): void + { + $workspace = new PhpactorWorkspace(); + $source = "open(new TextDocumentItem('/Repo.xphp', 'xphp', 1, $source)); + + $results = $this->query($workspace, 'Repo'); + self::assertCount(1, $results); + $loc = $results[0]->location; + // `class Repository {}` is on line 3 (0-indexed), `Repository` is + // at char 6 (after "class "). + self::assertSame(3, $loc->range->start->line); + self::assertSame(6, $loc->range->start->character); + self::assertSame(3, $loc->range->end->line); + // Range covers the identifier itself. + self::assertSame(6 + strlen('Repository'), $loc->range->end->character); + } + + public function testFilesystemDeclarationSurfacedWhenNoOpenDoc(): void + { + file_put_contents($this->root . '/Hidden.xphp', <<<'XPHP' + query($workspace, 'Hidden'); + self::assertCount(1, $results); + self::assertSame('Hidden', $results[0]->name); + self::assertSame('App\\Stuff', $results[0]->containerName); + // Filesystem-only declarations get a file:// URI. + self::assertStringStartsWith('file://', $results[0]->location->uri); + } + + public function testOpenDocBeatsFilesystemOnFqnCollision(): void + { + file_put_contents($this->root . '/User.xphp', <<<'XPHP' + open(new TextDocumentItem( + '/edit/User.xphp', + 'xphp', + 7, + "query($workspace, 'User'); + self::assertCount(1, $results, 'collision must de-dupe to a single hit'); + // Open-doc URI wins; the workspace URI does NOT start with file://. + self::assertStringStartsNotWith('file://', $results[0]->location->uri); + } + + public function testClassMemberSuffixIsStripped(): void + { + // Phase 3: PhpStorm sends `Class::method` when the user types + // the qualified form in the symbol popup. We don't index methods + // at workspace level so we strip the suffix and return the class + // match. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/Box.xphp', 'xphp', 1, " $s->name, + $this->query($workspace, 'Box::get'), + ); + self::assertContains('Box', $names); + } + + public function testEmptyClassMemberStillStripsAndReturns(): void + { + // `Class::` (no member yet -- mid-typing) also resolves to the class. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/Box.xphp', 'xphp', 1, " $s->name, + $this->query($workspace, 'Box::'), + ); + self::assertContains('Box', $names); + } + + public function testResultCapPreventsRunawayPayloads(): void + { + // Cap is 250 -- emit 260 declarations and assert we cut off. + $source = "open(new TextDocumentItem('/Big.xphp', 'xphp', 1, $source)); + + $results = $this->query($workspace, ''); + self::assertCount(250, $results); + } + + public function testMethodsMapRegistersEndpoint(): void + { + $handler = $this->handler(new PhpactorWorkspace()); + self::assertArrayHasKey('workspace/symbol', $handler->methods()); + self::assertSame('symbol', $handler->methods()['workspace/symbol']); + } + + /** + * @return list + */ + private function query(PhpactorWorkspace $workspace, string $q): array + { + $handler = $this->handler($workspace); + $params = new WorkspaceSymbolParams($q); + $result = wait($handler->symbol($params)); + self::assertIsArray($result); + return $result; + } + + private function handler(PhpactorWorkspace $workspace): XphpWorkspaceSymbolHandler + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $cache = new ParsedDocumentCache(new Analyzer($parser)); + $index = new FqnIndex($workspace, $cache, $parser, $this->root); + return new XphpWorkspaceSymbolHandler($index); + } + + private function rmrf(string $dir): void + { + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $p = $dir . '/' . $entry; + if (is_dir($p)) { + $this->rmrf($p); + } else { + unlink($p); + } + } + rmdir($dir); + } +} diff --git a/tools/lsp/test/LspDispatcherFactoryTest.php b/tools/lsp/test/LspDispatcherFactoryTest.php index dc231e3b..cfec1585 100644 --- a/tools/lsp/test/LspDispatcherFactoryTest.php +++ b/tools/lsp/test/LspDispatcherFactoryTest.php @@ -39,7 +39,7 @@ public function testInitializeHandshakeReturnsServerCapabilities(): void self::assertSame( TextDocumentSyncKind::FULL, $result->capabilities->textDocumentSync, - 'textDocumentSync must be FULL — phpactor TextDocumentHandler registers this and the client needs it to know how to push updates', + 'textDocumentSync must be FULL — XphpTextDocumentHandler registers this and the client needs it to know how to push updates', ); } @@ -53,6 +53,70 @@ public function testServerInfoAdvertisesXphpName(): void self::assertSame('xphp-lsp', $result->serverInfo['name'] ?? null); } + public function testRenameProviderAdvertised(): void + { + // Phase 4.2 wires XphpRenameHandler. Without this capability + // PhpStorm's Rename refactoring (Shift+F6) won't route to the + // LSP. Same bool-not-options-object trick as every other + // capability we advertise. + $tester = $this->buildTester(); + + $result = $tester->initialize(); + + self::assertTrue( + $result->capabilities->renameProvider, + 'renameProvider must be announced as bool true', + ); + } + + public function testReferencesProviderAdvertised(): void + { + // Phase 4.1 wires XphpReferencesHandler. Without this capability + // PhpStorm's "Find Usages" (Alt+F7) won't even ask the LSP. Same + // bool-not-options-object trick used everywhere else. + $tester = $this->buildTester(); + + $result = $tester->initialize(); + + self::assertTrue( + $result->capabilities->referencesProvider, + 'referencesProvider must be announced as bool true', + ); + } + + public function testWorkspaceSymbolProviderAdvertised(): void + { + // Phase 2.2 wires XphpWorkspaceSymbolHandler. Without this + // capability PhpStorm's "Go to Symbol" popup stays empty when it + // queries the LSP for workspace-wide candidates. Same bool-not- + // options-object trick as hover / documentSymbol. + $tester = $this->buildTester(); + + $result = $tester->initialize(); + + self::assertTrue( + $result->capabilities->workspaceSymbolProvider, + 'workspaceSymbolProvider must be announced as bool true', + ); + } + + public function testDocumentSymbolProviderAdvertised(): void + { + // Without this capability, clients won't issue + // textDocument/documentSymbol and the Structure / "Go to Symbol in + // File" UIs stay empty. Phase 2.1 wires XphpDocumentSymbolHandler -- + // this assertion guards against a regression where the handler is + // present but un-announced. + $tester = $this->buildTester(); + + $result = $tester->initialize(); + + self::assertTrue( + $result->capabilities->documentSymbolProvider, + 'documentSymbolProvider must be announced as bool true (NOT a DocumentSymbolOptions object -- IntelliJ rejects the empty-object encoding)', + ); + } + private function buildTester(): LanguageServerTester { return new LanguageServerTester( diff --git a/tools/lsp/test/PositionMapTest.php b/tools/lsp/test/PositionMapTest.php index df334359..205308cb 100644 --- a/tools/lsp/test/PositionMapTest.php +++ b/tools/lsp/test/PositionMapTest.php @@ -68,11 +68,14 @@ public function testThreeByteCharacterCountedAsOneColumn(): void self::assertSame([0, 2], $map->offsetToPosition(4)); } - public function testFourByteCharacterCountedAsOneColumn(): void + public function testFourByteCharacterCountedAsSurrogatePair(): void { - // "a😀b" — 😀 is U+1F600 encoded as 0xF0 0x9F 0x98 0x80 (4 bytes). 'b' is byte 5, char 2. + // "a😀b" — 😀 is U+1F600 encoded as 0xF0 0x9F 0x98 0x80 (4 bytes + // in UTF-8 = 2 UTF-16 code units, since it lives in the + // supplementary plane). 'b' lives at byte 5; its LSP column is + // 3 (1 for 'a' + 2 for the surrogate pair). $map = new PositionMap("a\xF0\x9F\x98\x80b"); - self::assertSame([0, 2], $map->offsetToPosition(5)); + self::assertSame([0, 3], $map->offsetToPosition(5)); } public function testOffsetOnEmptyDocumentMapsToZeroZero(): void @@ -214,11 +217,16 @@ public function testPositionAtCharacterIndexInMultibyteLine(): void self::assertSame(3, $map->positionToOffset(0, 2)); } - public function testPositionWalksFourByteCharactersAsSingleColumns(): void + public function testPositionWalksFourByteCharactersAsSurrogatePairs(): void { - // "😀a" — character 1 (the 'a') is at byte 4. + // "😀a" — 😀 is a supplementary-plane codepoint = 2 UTF-16 code + // units. 'a' lives at byte 4, LSP column 2. Column 1 (mid + // surrogate pair) clamps back to the start of 😀 -- consistent + // with editors that don't allow cursor placement between the + // high and low surrogate. $map = new PositionMap("\xF0\x9F\x98\x80a"); - self::assertSame(4, $map->positionToOffset(0, 1)); + self::assertSame(0, $map->positionToOffset(0, 1)); + self::assertSame(4, $map->positionToOffset(0, 2)); } public function testInvalidUtf8LeadingByteFallsBackToOneByte(): void @@ -262,14 +270,16 @@ public function testPositionWalksDiverseThreeByteSequences(): void public function testPositionWalksDiverseFourByteSequences(): void { // Exercise the `($code & 0xF8) === 0xF0` branch. Four-byte leaders - // are 0xF0..0xF4. + // are 0xF0..0xF4. Each 4-byte UTF-8 char is 2 UTF-16 code units, + // so the 3 chars + 'z' span 7 columns total (2+2+2+1). // 😀 → 0xF0 0x9F 0x98 0x80 // 🥑 → 0xF0 0x9F 0xA5 0x91 // 𐀀 → 0xF0 0x90 0x80 0x80 (linear-B; first SMP codepoint) $map = new PositionMap("\xF0\x9F\x98\x80\xF0\x9F\xA5\x91\xF0\x90\x80\x80z"); - // 3 four-byte chars + 'z' → 4 chars; z at byte 12. - self::assertSame(12, $map->positionToOffset(0, 3)); - self::assertSame(13, $map->positionToOffset(0, 4)); + // Column 6 = past all three 4-byte chars (start of 'z'); byte 12. + self::assertSame(12, $map->positionToOffset(0, 6)); + // Column 7 = past 'z'; byte 13. + self::assertSame(13, $map->positionToOffset(0, 7)); } public function testPositionWalksOddLeaderThreeByteSequence(): void @@ -287,21 +297,34 @@ public function testPositionWalksOddLeaderFourByteSequence(): void // Locks mutations on the `($code & 0xF8) === 0xF0` line: with `0xF7` // or `0xF9` instead of `0xF8`, ODD 4-byte leaders mismatch. Synthetic // U+50000 → 0xF1 0x90 0x80 0x80 (4-byte sequence with odd leader). + // Surrogate-pair encoding: column 2 = past the 4-byte char. $map = new PositionMap("\xF1\x90\x80\x80z"); - self::assertSame(4, $map->positionToOffset(0, 1)); - self::assertSame(5, $map->positionToOffset(0, 2)); + self::assertSame(4, $map->positionToOffset(0, 2)); + self::assertSame(5, $map->positionToOffset(0, 3)); + } + + public function testOffsetToPositionEncodesSupplementaryCharsAsSurrogatePair(): void + { + // Phase 3 polish: end-to-end UTF-16 encoding for offsetToPosition. + // Emoji + ASCII: each emoji is one UTF-8 4-byte sequence and + // contributes 2 UTF-16 code units (surrogate pair). + $map = new PositionMap("// 🚀 launch"); + // '🚀' starts at byte 3, ends at byte 7; 'launch' starts at byte 8. + // LSP columns: '/'=0,'/'=1,' '=2,'🚀'=3-4 (surrogate pair),' '=5,'l'=6 + self::assertSame([0, 6], $map->offsetToPosition(8)); } public function testPositionMixedAsciiAndMultibyteSequence(): void { // Final mixed run to ensure all four branches of utf8CharLength get - // hit in the same call. "a€😀b" → bytes a (1), € (3), 😀 (4), b (1) = 9 bytes, 4 chars. + // hit in the same call. "a€😀b" — bytes a(1), €(3), 😀(4), b(1) = 9 bytes. + // UTF-16 columns: a=1, €=1 (BMP), 😀=2 (surrogate pair), b=1 = 5 cols. $map = new PositionMap("a\xE2\x82\xAC\xF0\x9F\x98\x80b"); self::assertSame(0, $map->positionToOffset(0, 0)); self::assertSame(1, $map->positionToOffset(0, 1)); // past 'a' self::assertSame(4, $map->positionToOffset(0, 2)); // past € - self::assertSame(8, $map->positionToOffset(0, 3)); // past 😀 - self::assertSame(9, $map->positionToOffset(0, 4)); // past 'b' + self::assertSame(8, $map->positionToOffset(0, 4)); // past 😀 (cols 2,3) + self::assertSame(9, $map->positionToOffset(0, 5)); // past 'b' } // ===================================================================== diff --git a/tools/lsp/test/Reflection/FilesystemSourceLocatorTest.php b/tools/lsp/test/Reflection/FilesystemSourceLocatorTest.php new file mode 100644 index 00000000..9a3543ec --- /dev/null +++ b/tools/lsp/test/Reflection/FilesystemSourceLocatorTest.php @@ -0,0 +1,142 @@ +root = sys_get_temp_dir() . '/xphp-fs-loc-' . bin2hex(random_bytes(6)); + mkdir($this->root, 0o755, true); + } + + protected function tearDown(): void + { + if (is_dir($this->root)) { + $this->rmrf($this->root); + } + } + + public function testLocatesClassInPhpFile(): void + { + $path = $this->root . '/User.php'; + file_put_contents($path, "newLocator()->locate(Name::fromString('App\\User')); + + self::assertStringEndsWith($path, (string) $document->uri()); + self::assertStringContainsString('class User', (string) $document); + } + + public function testLocatesClassInXphpFileWithGenericClauseStripped(): void + { + $path = $this->root . '/Box.xphp'; + file_put_contents($path, " { public T \$item; }\n"); + + $document = $this->newLocator()->locate(Name::fromString('App\\Box')); + $text = (string) $document; + + // The `` clause must be whitespace; class header and member still + // at original offsets (so any Location worse-reflection derives from + // the parsed source aligns with the editor's view of the .xphp file). + self::assertStringNotContainsString('', $text); + self::assertStringContainsString('class Box', $text); + self::assertStringContainsString('$item', $text); + } + + public function testLocatesFunctionInXphpFile(): void + { + $path = $this->root . '/funcs.xphp'; + file_put_contents($path, "newLocator()->locate(Name::fromString('greet')); + + self::assertStringEndsWith($path, (string) $document->uri()); + } + + public function testFindsClassInSubdirectory(): void + { + mkdir($this->root . '/src/Models', 0o755, true); + $path = $this->root . '/src/Models/User.php'; + file_put_contents($path, "newLocator()->locate(Name::fromString('App\\Models\\User')); + self::assertStringEndsWith($path, (string) $document->uri()); + } + + public function testSkipsVendorDirectory(): void + { + // vendor/ is intentionally excluded -- composer-installed code is + // worse-reflection's job via stubs, not ours via brute-force scan. + mkdir($this->root . '/vendor/some/pkg', 0o755, true); + file_put_contents($this->root . '/vendor/some/pkg/Lib.php', "expectException(SourceNotFound::class); + $this->newLocator()->locate(Name::fromString('VendorLib')); + } + + public function testThrowsForUnknownFqn(): void + { + $this->expectException(SourceNotFound::class); + $this->newLocator()->locate(Name::fromString('Nope\\Nope')); + } + + public function testReturnsEmptyMapWhenRootMissing(): void + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $cache = new ParsedDocumentCache(new Analyzer($parser)); + $workspace = new PhpactorWorkspace(); + $root = '/path/that/definitely/does/not/exist'; + $locator = new FilesystemSourceLocator( + new FqnIndex($workspace, $cache, $parser, $root), + $parser, + $root, + ); + + $this->expectException(SourceNotFound::class); + $locator->locate(Name::fromString('Anything')); + } + + private function newLocator(): FilesystemSourceLocator + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $cache = new ParsedDocumentCache(new Analyzer($parser)); + $workspace = new PhpactorWorkspace(); + return new FilesystemSourceLocator( + new FqnIndex($workspace, $cache, $parser, $this->root), + $parser, + $this->root, + ); + } + + private function rmrf(string $dir): void + { + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $p = $dir . '/' . $entry; + if (is_dir($p)) { + $this->rmrf($p); + } else { + unlink($p); + } + } + rmdir($dir); + } +} diff --git a/tools/lsp/test/Reflection/FqnIndexTest.php b/tools/lsp/test/Reflection/FqnIndexTest.php new file mode 100644 index 00000000..a7bf8566 --- /dev/null +++ b/tools/lsp/test/Reflection/FqnIndexTest.php @@ -0,0 +1,386 @@ +root = sys_get_temp_dir() . '/xphp-fqn-' . bin2hex(random_bytes(6)); + mkdir($this->root, 0o755, true); + } + + protected function tearDown(): void + { + if (is_dir($this->root)) { + $this->rmrf($this->root); + } + } + + public function testResolvesOpenDocumentDeclarationsByFqn(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem( + 'file:///workspace/Collection.xphp', + 'xphp', + 1, + " {}\n", + )); + $index = $this->index($workspace); + + self::assertSame('file:///workspace/Collection.xphp', $index->pathFor('App\\Containers\\Collection')); + self::assertContains('App\\Containers\\Collection', $index->allClassFqns()); + } + + public function testResolvesFilesystemDeclarationsByFqn(): void + { + $this->writeFile('App/Models/User.xphp', "index(new PhpactorWorkspace()); + + self::assertStringEndsWith('App/Models/User.xphp', $index->pathFor('App\\Models\\User')); + self::assertContains('App\\Models\\User', $index->allClassFqns()); + } + + public function testOpenDocWinsOverFilesystemOnCollidingFqn(): void + { + // Same FQN on disk and in open editor -- live editor view wins. + $this->writeFile('Collection.xphp', "open(new TextDocumentItem( + 'file:///workspace/Collection.xphp', + 'xphp', + 1, + " {}\n", + )); + $index = $this->index($workspace); + + self::assertSame( + 'file:///workspace/Collection.xphp', + $index->pathFor('App\\Containers\\Collection'), + ); + } + + public function testClassLikeForReturnsAstWithXphpAttributesFromFilesystem(): void + { + // Critical for GenericResolver: the ClassLike we hand back from a + // closed file MUST still carry ATTR_GENERIC_PARAMS so type-arg + // substitution works without re-parsing. + $this->writeFile('Collection.xphp', <<<'XPHP' + { + public function first(): ?T { return null; } + } + XPHP); + $index = $this->index(new PhpactorWorkspace()); + + $class = $index->classLikeFor('App\\Containers\\Collection'); + + self::assertNotNull($class); + self::assertSame('Collection', $class->name?->toString()); + $params = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + self::assertIsArray($params); + self::assertCount(1, $params); + self::assertSame('T', $params[0]->name); + } + + public function testClassLikeForReturnsAstFromOpenDocument(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem( + 'file:///workspace/Pair.xphp', + 'xphp', + 1, + " { public function key(): K { return null; } }\n", + )); + $index = $this->index($workspace); + + $class = $index->classLikeFor('App\\Containers\\Pair'); + + self::assertNotNull($class); + $params = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + self::assertCount(2, $params); + self::assertSame('K', $params[0]->name); + self::assertSame('V', $params[1]->name); + } + + public function testFunctionFqnsTracked(): void + { + $this->writeFile('helpers.xphp', "open(new TextDocumentItem( + 'file:///fn.xphp', + 'xphp', + 1, + "index($workspace); + + $functions = $index->allFunctionFqns(); + + self::assertContains('App\\greet', $functions, 'filesystem function must be indexed'); + self::assertContains('global_fn', $functions, 'open-doc function must be indexed'); + // Class declarations don't leak into the function list. + self::assertNotContains('App\\greet', $index->allClassFqns()); + } + + public function testReturnsNullForUnknownFqn(): void + { + $index = $this->index(new PhpactorWorkspace()); + + self::assertNull($index->pathFor('Mystery\\Class')); + self::assertNull($index->classLikeFor('Mystery\\Class')); + } + + public function testEmptyFqnReturnsNullEarly(): void + { + $index = $this->index(new PhpactorWorkspace()); + + self::assertNull($index->pathFor('')); + self::assertNull($index->classLikeFor('')); + } + + public function testMissingRootPathReturnsEmptyFilesystemSide(): void + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $cache = new ParsedDocumentCache(new Analyzer($parser)); + $index = new FqnIndex( + new PhpactorWorkspace(), + $cache, + $parser, + '/path/that/definitely/does/not/exist', + ); + + self::assertSame([], $index->allClassFqns()); + self::assertSame([], $index->allFunctionFqns()); + } + + public function testSkipsExcludedDirectories(): void + { + // Files under skipped dirs must NOT appear in the index. + $this->writeFile('vendor/should-skip.xphp', "writeFile('node_modules/should-skip.xphp', "writeFile('legit.xphp', "index(new PhpactorWorkspace()); + + self::assertContains('LegitClass', $index->allClassFqns()); + self::assertNotContains('VendorClass', $index->allClassFqns()); + self::assertNotContains('NodeClass', $index->allClassFqns()); + } + + public function testLocationForFqnPointsAtIdentifierInOpenDoc(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem( + '/User.xphp', + 'xphp', + 1, + "index($workspace); + + $hit = $index->locationForFqn('App\\Models\\User'); + + self::assertNotNull($hit); + self::assertSame('/User.xphp', $hit['uri']); + self::assertSame(3, $hit['line']); + self::assertSame(6, $hit['char']); + self::assertSame('User', $hit['short']); + } + + public function testLocationForFqnFallsThroughToFilesystem(): void + { + $this->writeFile('Box.xphp', " {}\n"); + $index = $this->index(new PhpactorWorkspace()); + + $hit = $index->locationForFqn('App\\Containers\\Box'); + + self::assertNotNull($hit); + self::assertSame('file://' . $this->root . '/Box.xphp', $hit['uri']); + // class Box is on line 2 (0-indexed); `Box` is at char 6. + self::assertSame(2, $hit['line']); + self::assertSame(6, $hit['char']); + } + + public function testLocationByShortNameFallsThroughToFilesystem(): void + { + $this->writeFile('User.xphp', "index(new PhpactorWorkspace()); + + $hit = $index->locationByShortName('User'); + + self::assertNotNull($hit); + self::assertSame('User', $hit['short']); + self::assertSame('file://' . $this->root . '/User.xphp', $hit['uri']); + } + + public function testLocationByShortNameMatchesUnnamespacedClass(): void + { + // A class declared outside any namespace: FQN == short name. The + // tail-suffix matcher would skip this since there's no `\` + // suffix to find; the explicit equality branch covers it. + $this->writeFile('Bare.xphp', "index(new PhpactorWorkspace()); + + $hit = $index->locationByShortName('Bare'); + + self::assertNotNull($hit); + self::assertSame('Bare', $hit['short']); + } + + public function testLocationByShortNameReturnsNullForUnknown(): void + { + $index = $this->index(new PhpactorWorkspace()); + self::assertNull($index->locationByShortName('NeverDeclared')); + } + + public function testLocationForFqnReturnsNullForEmptyOrUnknown(): void + { + $index = $this->index(new PhpactorWorkspace()); + self::assertNull($index->locationForFqn('')); + self::assertNull($index->locationForFqn('\\')); + self::assertNull($index->locationForFqn('Nope\\Mystery')); + } + + public function testFilesystemWalkSkipsNestedTestFixturesDir(): void + { + // Phase 3 polish: `test/fixture/...` declarations were polluting + // workspace symbol search + closed-file GTD in the xphp repo + // itself (confirmed in prod traces 2.2 + 2.3). The walk now + // skips `fixture` / `fixtures` when nested under a `test` / + // `tests` directory. + $this->writeFile('src/User.xphp', "writeFile('test/fixture/source/User.xphp', "writeFile('test/fixtures/another/User.xphp', "writeFile('tests/fixture/yetmore/User.xphp', "index(new PhpactorWorkspace()); + + $location = $index->locationForFqn('App\\Models\\User'); + self::assertNotNull($location); + self::assertSame('file://' . $this->root . '/src/User.xphp', $location['uri']); + } + + public function testFilesystemWalkDoesNotSkipNonTestFixtureDirs(): void + { + // A `fixture` directory NOT under `test` (e.g. someone's domain + // model) must still be walked. + $this->writeFile('src/fixture/Item.xphp', "index(new PhpactorWorkspace()); + + self::assertContains('App\\Item', $index->allClassFqns()); + } + + public function testShortNameTiebreakPrefersShorterPath(): void + { + // When two non-fixture files declare the same short-named class + // across different namespaces, deterministic tiebreak picks the + // shortest URI. + $this->writeFile('a/User.xphp', "writeFile('deep/path/here/User.xphp', "index(new PhpactorWorkspace()); + + $hit = $index->locationByShortName('User'); + self::assertNotNull($hit); + self::assertSame('file://' . $this->root . '/a/User.xphp', $hit['uri']); + } + + public function testShortNameLookupOpenDocBeatsLongerFsPath(): void + { + // Open-doc precedence isn't disrupted by the tiebreak refactor: + // even when a shorter filesystem path exists, the open buffer + // wins. + $this->writeFile('a/User.xphp', "open(new TextDocumentItem( + '/edit/deeper/path/User.xphp', + 'xphp', + 1, + "index($workspace)->locationByShortName('User'); + self::assertNotNull($hit); + self::assertSame('/edit/deeper/path/User.xphp', $hit['uri']); + } + + public function testInvalidateFilesystemForcesRebuildOnNextQuery(): void + { + $this->writeFile('Alpha.xphp', "index(new PhpactorWorkspace()); + + // Warm the cache. + $first = $index->allClassFqns(); + self::assertContains('App\\Alpha', $first); + + // Add a file post-warming. The cached map doesn't know about it. + $this->writeFile('Beta.xphp', "allClassFqns()); + + // Invalidate -- next query re-walks. + $index->invalidateFilesystem(); + $after = $index->allClassFqns(); + self::assertContains('App\\Beta', $after); + self::assertContains('App\\Alpha', $after); + } + + public function testHandlesUnparseableFilesGracefully(): void + { + // A garbage file shouldn't blow up the whole index build. + $this->writeFile('garbage.xphp', "writeFile('ok.xphp', "index(new PhpactorWorkspace()); + + self::assertContains('App\\Ok', $index->allClassFqns()); + } + + private function index(PhpactorWorkspace $workspace): FqnIndex + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $cache = new ParsedDocumentCache(new Analyzer($parser)); + return new FqnIndex($workspace, $cache, $parser, $this->root); + } + + private function writeFile(string $relativePath, string $contents): void + { + $path = $this->root . '/' . $relativePath; + $dir = dirname($path); + if (!is_dir($dir)) { + mkdir($dir, 0o755, true); + } + file_put_contents($path, $contents); + } + + private function rmrf(string $dir): void + { + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $p = $dir . '/' . $entry; + if (is_dir($p)) { + $this->rmrf($p); + } else { + unlink($p); + } + } + rmdir($dir); + } +} diff --git a/tools/lsp/test/Reflection/ReflectorFactoryTest.php b/tools/lsp/test/Reflection/ReflectorFactoryTest.php new file mode 100644 index 00000000..8bf48032 --- /dev/null +++ b/tools/lsp/test/Reflection/ReflectorFactoryTest.php @@ -0,0 +1,174 @@ +open(new TextDocumentItem( + '/User.xphp', + 'xphp', + 1, + "newFactory($workspace, rootPath: $this->emptyRoot())->build(); + + $class = $reflector->reflectClass('App\\Models\\User'); + self::assertSame('App\\Models\\User', (string) $class->name()); + } + + public function testReflectsClassFromFilesystem(): void + { + $root = sys_get_temp_dir() . '/xphp-rf-' . bin2hex(random_bytes(6)); + mkdir($root . '/src', 0o755, true); + file_put_contents( + $root . '/src/Plastic.xphp', + "newFactory(new PhpactorWorkspace(), rootPath: $root)->build(); + + $class = $reflector->reflectClass('App\\Models\\Plastic'); + self::assertSame('App\\Models\\Plastic', (string) $class->name()); + } finally { + $this->rmrf($root); + } + } + + public function testExtractStubsCacheCopiesFromRegularDirectory(): void + { + // We can't easily build a real PHAR fixture for the test, but the + // extractor handles a regular source directory identically to a + // phar:// source (PHP's stream wrapper transparency). Cover the + // recursive copy + sentinel + idempotency contract with an + // on-disk fixture. + $source = sys_get_temp_dir() . '/xphp-rf-stub-src-' . bin2hex(random_bytes(6)); + mkdir($source . '/Reflection', 0o755, true); + mkdir($source . '/standard', 0o755, true); + file_put_contents($source . '/Reflection/Class.php', "rmrf($source); + } + } + + public function testReflectsNativeFunctionFromStubs(): void + { + $stubPath = ReflectorFactory::defaultStubPath(); + if (!is_dir($stubPath)) { + self::markTestSkipped( + 'jetbrains/phpstorm-stubs is not installed at the expected path: ' . $stubPath + ); + } + + $reflector = $this->newFactory( + new PhpactorWorkspace(), + rootPath: $this->emptyRoot(), + )->build(); + + $function = $reflector->reflectFunction('strlen'); + self::assertSame('strlen', (string) $function->name()); + } + + public function testWorkspaceShadowsFilesystemAtSameFqn(): void + { + // When both workspace AND filesystem could answer the FQN, workspace + // wins (priority 100 > priority 50 in the builder). The user's + // unsaved edits in the open buffer are the truth, not the stale + // on-disk version. + $root = sys_get_temp_dir() . '/xphp-rf-' . bin2hex(random_bytes(6)); + mkdir($root, 0o755, true); + file_put_contents( + $root . '/Live.xphp', + "open(new TextDocumentItem( + 'file://' . $root . '/Live.xphp', + 'xphp', + 2, + "newFactory($workspace, rootPath: $root)->build(); + + $class = $reflector->reflectClass('App\\Live'); + $propertyNames = []; + foreach ($class->properties() as $property) { + $propertyNames[] = (string) $property->name(); + } + + self::assertContains('buffer', $propertyNames, 'workspace edits must shadow on-disk source'); + self::assertNotContains('disk', $propertyNames); + } finally { + $this->rmrf($root); + } + } + + private function newFactory(PhpactorWorkspace $workspace, string $rootPath): ReflectorFactory + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $cache = new ParsedDocumentCache(new Analyzer($parser)); + return new ReflectorFactory( + $workspace, + $cache, + $parser, + $rootPath, + ReflectorFactory::defaultStubPath(), + ReflectorFactory::defaultCacheDir(), + new \XPHP\Lsp\Reflection\FqnIndex($workspace, $cache, $parser, $rootPath), + ); + } + + private function emptyRoot(): string + { + // A root that contains nothing -- forces lookups to skip the + // filesystem locator and hit workspace / stubs. + $path = sys_get_temp_dir() . '/xphp-rf-empty'; + if (!is_dir($path)) { + mkdir($path, 0o755, true); + } + return $path; + } + + private function rmrf(string $dir): void + { + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $p = $dir . '/' . $entry; + is_dir($p) ? $this->rmrf($p) : unlink($p); + } + rmdir($dir); + } +} diff --git a/tools/lsp/test/Reflection/WorkspaceSourceLocatorTest.php b/tools/lsp/test/Reflection/WorkspaceSourceLocatorTest.php new file mode 100644 index 00000000..f5790c45 --- /dev/null +++ b/tools/lsp/test/Reflection/WorkspaceSourceLocatorTest.php @@ -0,0 +1,122 @@ +open(new TextDocumentItem('/User.xphp', 'xphp', 1, $source)); + + $document = $this->newLocator($workspace)->locate(Name::fromString('App\\Models\\User')); + + self::assertStringEndsWith('/User.xphp', (string) $document->uri()); + self::assertStringContainsString('class User', (string) $document); + } + + public function testLocatesFunctionDeclaredInOpenXphpDocument(): void + { + $workspace = new PhpactorWorkspace(); + $source = <<<'XPHP' + open(new TextDocumentItem('/funcs.xphp', 'xphp', 1, $source)); + + $document = $this->newLocator($workspace)->locate(Name::fromString('App\\greet')); + + self::assertStringEndsWith('/funcs.xphp', (string) $document->uri()); + } + + public function testStripsGenericClausesFromTheReturnedSource(): void + { + // The whole point of this locator: worse-reflection sees PHP-shaped + // source even though the workspace doc contains xphp clauses. + // Equal-length whitespace strip means byte offsets still match the + // original .xphp file -- editor navigation lands on the right token. + $workspace = new PhpactorWorkspace(); + $source = <<<'XPHP' + { public T $item; } + XPHP; + $workspace->open(new TextDocumentItem('/Box.xphp', 'xphp', 1, $source)); + + $document = $this->newLocator($workspace)->locate(Name::fromString('App\\Containers\\Box')); + $text = (string) $document; + + // The `` clause must NOT appear in the returned text -- it's been + // replaced by whitespace. But the `class Box` token AND the `T $item` + // member still appear at their original byte offsets. + self::assertStringNotContainsString('', $text); + self::assertSame(strpos($source, 'class Box'), strpos($text, 'class Box')); + self::assertSame(strpos($source, '$item'), strpos($text, '$item')); + } + + public function testThrowsSourceNotFoundForUnknownFqn(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/A.xphp', 'xphp', 1, "expectException(SourceNotFound::class); + $this->newLocator($workspace)->locate(Name::fromString('App\\Unknown')); + } + + public function testThrowsSourceNotFoundForEmptyName(): void + { + $this->expectException(SourceNotFound::class); + $this->newLocator(new PhpactorWorkspace())->locate(Name::fromString('')); + } + + public function testSkipsDocumentsThatFailToParse(): void + { + // Locks the `if ($result->ast === null) continue` guard. With it, + // we walk past the broken file and find the good one. Without it, + // we'd hit a null AST in the traverser and crash. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/Broken.xphp', 'xphp', 1, "open(new TextDocumentItem('/Clean.xphp', 'xphp', 1, "newLocator($workspace)->locate(Name::fromString('App\\Plastic')); + self::assertStringEndsWith('/Clean.xphp', (string) $document->uri()); + } + + public function testHandlesLeadingBackslashOnFqn(): void + { + // Worse-reflection sometimes hands us names with a leading backslash + // (fully-qualified marker); strip before comparing against the + // namespace-qualified form we derive from the AST. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/Plain.xphp', 'xphp', 1, "newLocator($workspace)->locate(Name::fromString('\\Plain')); + self::assertStringEndsWith('/Plain.xphp', (string) $document->uri()); + } + + private function newLocator(PhpactorWorkspace $workspace): WorkspaceSourceLocator + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $cache = new ParsedDocumentCache(new Analyzer($parser)); + return new WorkspaceSourceLocator($workspace, $cache, $parser); + } +} diff --git a/tools/lsp/test/Resolver/CompletionIndexTest.php b/tools/lsp/test/Resolver/CompletionIndexTest.php new file mode 100644 index 00000000..c36dabee --- /dev/null +++ b/tools/lsp/test/Resolver/CompletionIndexTest.php @@ -0,0 +1,98 @@ +stubsDir = sys_get_temp_dir() . '/xphp-ci-' . bin2hex(random_bytes(6)); + mkdir($this->stubsDir, 0o755, true); + file_put_contents( + $this->stubsDir . '/stubs.php', + "stubsDir)) { + foreach (scandir($this->stubsDir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + unlink($this->stubsDir . '/' . $entry); + } + rmdir($this->stubsDir); + } + } + + public function testMergesWorkspaceAndStubClasses(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/User.xphp', 'xphp', 1, <<<'XPHP' + index($workspace); + $classes = $index->classFqns(); + + self::assertContains('App\\Models\\User', $classes); + self::assertContains('App\\Models\\Plastic', $classes); + self::assertContains('StubClass', $classes); + } + + public function testMergesWorkspaceAndStubFunctions(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/funcs.xphp', 'xphp', 1, <<<'XPHP' + index($workspace); + $functions = $index->functionFqns(); + + self::assertContains('App\\greet', $functions); + self::assertContains('stub_func', $functions); + } + + public function testStubsAbsenceLeavesWorkspaceOnlyResults(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/A.xphp', 'xphp', 1, "createForHostVersion()))); + $symbols = new WorkspaceSymbols($workspace, $cache); + $index = new CompletionIndex($symbols, '/path/that/does/not/exist'); + + $classes = $index->classFqns(); + self::assertContains('A', $classes); + self::assertNotContains('StubClass', $classes); + } + + private function index(PhpactorWorkspace $workspace): CompletionIndex + { + $cache = new ParsedDocumentCache(new Analyzer(new XphpSourceParser((new ParserFactory())->createForHostVersion()))); + $symbols = new WorkspaceSymbols($workspace, $cache); + return new CompletionIndex($symbols, $this->stubsDir); + } +} diff --git a/tools/lsp/test/Resolver/CompositeClassLikeLookupTest.php b/tools/lsp/test/Resolver/CompositeClassLikeLookupTest.php new file mode 100644 index 00000000..9795310c --- /dev/null +++ b/tools/lsp/test/Resolver/CompositeClassLikeLookupTest.php @@ -0,0 +1,58 @@ + $first]), + new FixedLookup(['Foo' => $second, 'Bar' => $second]), + ); + + self::assertSame($first, $lookup->find('Foo'), 'first lookup wins on collision'); + self::assertSame($second, $lookup->find('Bar'), 'second lookup serves uncovered FQNs'); + } + + public function testReturnsNullWhenNoLookupKnowsFqn(): void + { + $lookup = new CompositeClassLikeLookup( + new FixedLookup([]), + new FixedLookup([]), + ); + + self::assertNull($lookup->find('Nothing')); + } + + public function testEmptyChainReturnsNull(): void + { + $lookup = new CompositeClassLikeLookup(); + self::assertNull($lookup->find('Anything')); + } +} + +final class FixedLookup implements ClassLikeLookup +{ + /** @param array $entries */ + public function __construct(private readonly array $entries) + { + } + + public function find(string $fqn): ?ClassLike + { + return $this->entries[$fqn] ?? null; + } +} diff --git a/tools/lsp/test/Resolver/FilesystemClassLikeLookupTest.php b/tools/lsp/test/Resolver/FilesystemClassLikeLookupTest.php new file mode 100644 index 00000000..543db975 --- /dev/null +++ b/tools/lsp/test/Resolver/FilesystemClassLikeLookupTest.php @@ -0,0 +1,85 @@ +root = sys_get_temp_dir() . '/xphp-fs-cll-' . bin2hex(random_bytes(6)); + mkdir($this->root, 0o755, true); + } + + protected function tearDown(): void + { + if (is_dir($this->root)) { + $this->rmrf($this->root); + } + } + + public function testReturnsClassLikeFromFilesystemFile(): void + { + file_put_contents($this->root . '/Collection.xphp', <<<'XPHP' + { + public function first(): ?T { return null; } + } + XPHP); + + $lookup = $this->lookup(); + $class = $lookup->find('App\\Containers\\Collection'); + + self::assertNotNull($class); + self::assertSame('Collection', $class->name?->toString()); + // ClassLike must carry the xphp generic-params attribute -- without + // it, GenericResolver can't substitute T at the call site. + $params = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + self::assertIsArray($params); + self::assertCount(1, $params); + self::assertSame('T', $params[0]->name); + } + + public function testReturnsNullForUnknownFqn(): void + { + $lookup = $this->lookup(); + self::assertNull($lookup->find('Mystery\\Class')); + } + + private function lookup(): FilesystemClassLikeLookup + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $cache = new ParsedDocumentCache(new Analyzer($parser)); + $index = new FqnIndex(new PhpactorWorkspace(), $cache, $parser, $this->root); + return new FilesystemClassLikeLookup($index); + } + + private function rmrf(string $dir): void + { + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $p = $dir . '/' . $entry; + if (is_dir($p)) { + $this->rmrf($p); + } else { + unlink($p); + } + } + rmdir($dir); + } +} diff --git a/tools/lsp/test/Resolver/GenericParamRegistryTest.php b/tools/lsp/test/Resolver/GenericParamRegistryTest.php new file mode 100644 index 00000000..fbb43ef6 --- /dev/null +++ b/tools/lsp/test/Resolver/GenericParamRegistryTest.php @@ -0,0 +1,202 @@ +registry($workspace); + $this->openCollection($workspace); + + self::assertSame('T', $registry->prettify('App\\Containers\\T')); + self::assertSame('?T', $registry->prettify('?App\\Containers\\T')); + } + + public function testLeavesRealClassReferencesUnchanged(): void + { + $workspace = new PhpactorWorkspace(); + $registry = $this->registry($workspace); + $this->openCollection($workspace); + + // User isn't a generic param of any class -- keep the FQN. + self::assertSame('App\\Models\\User', $registry->prettify('App\\Models\\User')); + self::assertSame('?App\\Models\\User', $registry->prettify('?App\\Models\\User')); + } + + public function testLeavesScalarTypesAlone(): void + { + $workspace = new PhpactorWorkspace(); + $registry = $this->registry($workspace); + $this->openCollection($workspace); + + self::assertSame('int', $registry->prettify('int')); + self::assertSame('string', $registry->prettify('string')); + self::assertSame('array', $registry->prettify('array')); + self::assertSame('', $registry->prettify('')); + self::assertSame('', $registry->prettify('')); + } + + public function testHandlesMultiplePlaceholdersInOneTypeExpression(): void + { + $workspace = new PhpactorWorkspace(); + $registry = $this->registry($workspace); + // Map: two generic params at App\Containers level. + $workspace->open(new TextDocumentItem('/Map.xphp', 'xphp', 1, <<<'XPHP' + {} + XPHP)); + + // Composite types with both placeholders get both stripped. + self::assertSame( + 'array', + $registry->prettify('array'), + ); + } + + public function testIgnoresPlaceholderNamesInUnrelatedNamespaces(): void + { + // Class T in App\Models declares NO generic params, so a reference + // to App\Models\T is a real class reference, not a placeholder. + // App\Containers\T IS a placeholder via Collection. + $workspace = new PhpactorWorkspace(); + $registry = $this->registry($workspace); + $this->openCollection($workspace); + $workspace->open(new TextDocumentItem('/RealT.xphp', 'xphp', 1, <<<'XPHP' + prettify('App\\Containers\\T')); + self::assertSame('App\\Models\\T', $registry->prettify('App\\Models\\T')); + } + + public function testReturnsInputUnchangedWhenNoGenericClassesOpen(): void + { + $workspace = new PhpactorWorkspace(); + $registry = $this->registry($workspace); + $workspace->open(new TextDocumentItem('/User.xphp', 'xphp', 1, "prettify('App\\Containers\\T')); + } + + public function testStripsPlaceholderFromFilesystemOnlyGenericClass(): void + { + // Phase 0.5: the registry now pulls placeholder names from BOTH + // open documents and filesystem-indexed classes via FqnIndex. + // Before this migration, hovering a variable whose type traced to + // a closed generic-class file showed the unstripped form + // (`?App\Containers\T`). Now it strips correctly even with + // Collection.xphp closed -- matching the user's real-world + // scenario from xphp-20260524-231944-855.log. + $root = sys_get_temp_dir() . '/xphp-fs-reg-' . bin2hex(random_bytes(6)); + mkdir($root, 0o755, true); + try { + file_put_contents($root . '/Collection.xphp', <<<'XPHP' + { + public function first(): ?T { return null; } + } + XPHP); + + $workspace = new PhpactorWorkspace(); + $registry = $this->registry($workspace, $root); + + self::assertSame('T', $registry->prettify('App\\Containers\\T')); + self::assertSame('?T', $registry->prettify('?App\\Containers\\T')); + self::assertSame( + 'array', + $registry->prettify('array'), + ); + } finally { + $this->rmrf($root); + } + } + + public function testOpenDocWinsOverFilesystemPlaceholders(): void + { + // If the same generic class is declared both on disk and open in + // the editor (e.g. unsaved edits adding a second type-param), + // the open-doc params take precedence. + $root = sys_get_temp_dir() . '/xphp-fs-reg-collision-' . bin2hex(random_bytes(6)); + mkdir($root, 0o755, true); + try { + // Disk: Collection + file_put_contents($root . '/Collection.xphp', <<<'XPHP' + { } + XPHP); + + $workspace = new PhpactorWorkspace(); + $registry = $this->registry($workspace, $root); + // Editor: Collection (mid-edit adding a second param) + $workspace->open(new TextDocumentItem( + '/Collection.xphp', + 'xphp', + 1, + " { }\n", + )); + + // Both K and V (open-doc shape) are placeholders; T (disk + // shape) is also still recognised because filesystem entries + // that DON'T collide on FQN still contribute. But for this + // single FQN, open-doc wins -- the prior T placeholder + // doesn't leak through. K and V are recognised. + self::assertSame('K', $registry->prettify('App\\Containers\\K')); + self::assertSame('V', $registry->prettify('App\\Containers\\V')); + } finally { + $this->rmrf($root); + } + } + + private function rmrf(string $dir): void + { + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $p = $dir . '/' . $entry; + if (is_dir($p)) { + $this->rmrf($p); + } else { + unlink($p); + } + } + rmdir($dir); + } + + private function registry(PhpactorWorkspace $workspace, string $rootPath = ''): GenericParamRegistry + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $cache = new ParsedDocumentCache(new Analyzer($parser)); + $fqnIndex = new \XPHP\Lsp\Reflection\FqnIndex($workspace, $cache, $parser, $rootPath); + return new GenericParamRegistry($fqnIndex); + } + + private function openCollection(PhpactorWorkspace $workspace): void + { + $workspace->open(new TextDocumentItem('/Collection.xphp', 'xphp', 1, <<<'XPHP' + { + public function first(): ?T { return null; } + } + XPHP)); + } +} diff --git a/tools/lsp/test/Resolver/GenericResolverTest.php b/tools/lsp/test/Resolver/GenericResolverTest.php new file mode 100644 index 00000000..bdcf99bd --- /dev/null +++ b/tools/lsp/test/Resolver/GenericResolverTest.php @@ -0,0 +1,810 @@ +::first(): ?T + // -> hover on `$user` resolves to `?App\Models\User`. + $workspace = $this->workspace(); + $this->openCollection($workspace, returnType: '?T'); + $this->openUser($workspace); + $this->open($workspace, '/Use.xphp', <<<'XPHP' + (); + $user = $users->first(); + XPHP); + + $resolver = $this->resolver($workspace); + + self::assertSame('?App\\Models\\User', $resolver->resolveVariable('/Use.xphp', 'user', PHP_INT_MAX)); + } + + public function testRendersReceiverVariableWithTypeArgList(): void + { + // Hovering the receiver itself benefits too: `$users` of type + // Collection renders as the qualified form including + // the type-arg list, more informative than worse-reflection's + // base-class-only `App\Containers\Collection`. + $workspace = $this->workspace(); + $this->openCollection($workspace, returnType: '?T'); + $this->openUser($workspace); + $this->open($workspace, '/Use.xphp', <<<'XPHP' + (); + XPHP); + + $resolver = $this->resolver($workspace); + + self::assertSame( + 'App\\Containers\\Collection', + $resolver->resolveVariable('/Use.xphp', 'users', PHP_INT_MAX), + ); + } + + public function testSubstitutesScalarBoundType(): void + { + // Wrapper::value(): T -> `int`. + $workspace = $this->workspace(); + $this->open($workspace, '/Wrapper.xphp', <<<'XPHP' + { + public function value(): T { return null; } + } + XPHP); + $this->open($workspace, '/Use.xphp', <<<'XPHP' + (); + $v = $w->value(); + XPHP); + + $resolver = $this->resolver($workspace); + + self::assertSame('int', $resolver->resolveVariable('/Use.xphp', 'v', PHP_INT_MAX)); + } + + public function testSubstitutesMultiParamBinding(): void + { + // Pair: each method picks a different param. + $workspace = $this->workspace(); + $this->open($workspace, '/Pair.xphp', <<<'XPHP' + { + public function key(): K { return null; } + public function value(): V { return null; } + } + XPHP); + $this->openUser($workspace); + $this->open($workspace, '/Use.xphp', <<<'XPHP' + (); + $k = $p->key(); + $v = $p->value(); + XPHP); + + $resolver = $this->resolver($workspace); + + self::assertSame('string', $resolver->resolveVariable('/Use.xphp', 'k', PHP_INT_MAX)); + self::assertSame('App\\Models\\User', $resolver->resolveVariable('/Use.xphp', 'v', PHP_INT_MAX)); + } + + public function testNonGenericInstantiationReturnsNull(): void + { + // `$x = new User()` (no type-args) -> resolver yields to fallback. + $workspace = $this->workspace(); + $this->openUser($workspace); + $this->open($workspace, '/Use.xphp', <<<'XPHP' + resolver($workspace); + + self::assertNull($resolver->resolveVariable('/Use.xphp', 'x', PHP_INT_MAX)); + } + + public function testUnknownReceiverClassReturnsNull(): void + { + // `$x = new Mystery()` where Mystery isn't declared in any + // open document -- ClassLikeLookup misses, resolver yields. + $workspace = $this->workspace(); + $this->open($workspace, '/Use.xphp', <<<'XPHP' + (); + XPHP); + + $resolver = $this->resolver($workspace); + + self::assertNull($resolver->resolveVariable('/Use.xphp', 'x', PHP_INT_MAX)); + } + + public function testStaticMethodCallSubstitutesReturnTypeAtCallSite(): void + { + // Phase 1.2: `Util::identity(new User())` -- the method's + // type-param T is bound to User at the call site, so the + // substituted return type is User. The resolver previously + // returned null for this shape; this test pins the new behavior. + $workspace = $this->workspace(); + $this->open($workspace, '/Util.xphp', <<<'XPHP' + (T $x): T { return $x; } + } + XPHP); + $this->openUser($workspace); + $this->open($workspace, '/Use.xphp', <<<'XPHP' + (new User()); + XPHP); + + $resolver = $this->resolver($workspace); + + self::assertSame( + 'App\\Models\\User', + $resolver->resolveVariable('/Use.xphp', 'u', PHP_INT_MAX), + ); + } + + public function testStaticMethodCallWithQualifiedClassName(): void + { + // `\App\Util::identity(...)` -- already-qualified class + // names bypass the use map. + $workspace = $this->workspace(); + $this->open($workspace, '/Util.xphp', <<<'XPHP' + (T $x): T { return $x; } + } + XPHP); + $this->openUser($workspace); + $this->open($workspace, '/Use.xphp', <<<'XPHP' + (new \App\Models\User()); + XPHP); + + $resolver = $this->resolver($workspace); + + self::assertSame( + 'App\\Models\\User', + $resolver->resolveVariable('/Use.xphp', 'u', PHP_INT_MAX), + ); + } + + public function testStaticMethodCallWithoutGenericArgsReturnsNull(): void + { + // A static call without `<...>` (regular static method) is NOT + // tracked -- the resolver only fires when type-args are present. + $workspace = $this->workspace(); + $this->open($workspace, '/Util.xphp', <<<'XPHP' + open($workspace, '/Use.xphp', <<<'XPHP' + resolver($workspace); + + self::assertNull($resolver->resolveVariable('/Use.xphp', 'g', PHP_INT_MAX)); + } + + public function testGenericFunctionCallSubstitutesReturnType(): void + { + // Phase 1.3: free-function generic `identity(new User())`. + // The FuncCall carries ATTR_TEMPLATE_FQN + ATTR_METHOD_GENERIC_ARGS; + // resolver locates the function via FqnIndex and substitutes T -> User. + $workspace = $this->workspace(); + $this->open($workspace, '/fn.xphp', <<<'XPHP' + (T $x): T { return $x; } + XPHP); + $this->openUser($workspace); + $this->open($workspace, '/Use.xphp', <<<'XPHP' + (new User()); + XPHP); + + $resolver = $this->resolver($workspace); + + self::assertSame( + 'App\\Models\\User', + $resolver->resolveVariable('/Use.xphp', 'u', PHP_INT_MAX), + ); + } + + public function testGenericFunctionCallWithFilesystemOnlyDeclaration(): void + { + // Phase 1.3 + Phase 0: the generic function lives on disk, not in + // the editor. FqnIndex's filesystem fallback parses it on demand. + $root = sys_get_temp_dir() . '/xphp-fn-' . bin2hex(random_bytes(6)); + mkdir($root, 0o755, true); + try { + file_put_contents($root . '/identity.xphp', <<<'XPHP' + (T $x): T { return $x; } + XPHP); + $workspace = $this->workspace(); + $this->openUser($workspace); + $this->open($workspace, '/Use.xphp', <<<'XPHP' + (new User()); + XPHP); + + $resolver = $this->resolverWithFilesystem($workspace, $root); + + self::assertSame( + 'App\\Models\\User', + $resolver->resolveVariable('/Use.xphp', 'u', PHP_INT_MAX), + ); + } finally { + $this->rmrf($root); + } + } + + public function testNonGenericFunctionCallReturnsNull(): void + { + // A regular function call (no `<...>` args) is NOT a generic-call + // site -- the resolver yields and lets worse-reflection's own + // inference handle it. + $workspace = $this->workspace(); + $this->open($workspace, '/fn.xphp', <<<'XPHP' + open($workspace, '/Use.xphp', <<<'XPHP' + resolver($workspace); + + self::assertNull($resolver->resolveVariable('/Use.xphp', 'g', PHP_INT_MAX)); + } + + public function testSubstitutesTypeArgsWhenClassDeclarationIsFilesystemOnly(): void + { + // Phase-0 payoff: Collection.xphp is NOT open in the workspace -- + // it lives only on disk. The new FilesystemClassLikeLookup + + // FqnIndex pair should still resolve `$users->first()` to + // `?App\Models\User` via on-demand parsing. + $root = sys_get_temp_dir() . '/xphp-fs-gen-' . bin2hex(random_bytes(6)); + mkdir($root, 0o755, true); + try { + file_put_contents($root . '/Collection.xphp', <<<'XPHP' + { + public function first(): ?T { return null; } + } + XPHP); + + $workspace = $this->workspace(); + // User.xphp + Use.xphp open in editor; Collection.xphp NOT open. + $this->open($workspace, '/User.xphp', "open($workspace, '/Use.xphp', <<<'XPHP' + (); + $user = $users->first(); + XPHP); + + $resolver = $this->resolverWithFilesystem($workspace, $root); + + self::assertSame( + '?App\\Models\\User', + $resolver->resolveVariable('/Use.xphp', 'user', PHP_INT_MAX), + 'GenericResolver must resolve via filesystem when Collection.xphp is closed', + ); + } finally { + $this->rmrf($root); + } + } + + public function testParamTypedScopeEntrySeedsBinding(): void + { + // Phase 1.1: `function f(Collection $users) { ... }` -- + // inside the body, `$users` is bound to Collection, and + // `$users->first()` substitutes to ?App\Models\User. No `new` + // expression in scope: the binding comes from the parameter type. + $workspace = $this->workspace(); + $this->openCollection($workspace, returnType: '?T'); + $this->openUser($workspace); + $source = <<<'XPHP' + $users) { + $first = $users->first(); + } + XPHP; + $this->open($workspace, '/Use.xphp', $source); + + $resolver = $this->resolver($workspace); + $offset = strpos($source, '$first = $users->first();') + 1; + self::assertIsInt($offset); + + self::assertSame( + '?App\\Models\\User', + $resolver->resolveVariable('/Use.xphp', 'first', $offset), + ); + self::assertSame( + 'App\\Containers\\Collection', + $resolver->resolveVariable('/Use.xphp', 'users', $offset), + ); + } + + public function testParamTypedScopeEntryDoesNotLeakAcrossFunctions(): void + { + // Two functions with conflicting `$x` param types -- each scope + // wins on lookup at its own offset. + $workspace = $this->workspace(); + $this->openCollection($workspace, returnType: '?T'); + $this->openUser($workspace); + $this->open($workspace, '/Other.xphp', <<<'XPHP' + $items) { + $a = $items->first(); + } + function withPosts(Collection $items) { + $b = $items->first(); + } + XPHP; + $this->open($workspace, '/Use.xphp', $source); + + $resolver = $this->resolver($workspace); + $aOffset = strpos($source, '$a = $items') + 1; + $bOffset = strpos($source, '$b = $items') + 1; + + // Inside withUsers, $items is Collection -> $a: ?User. + self::assertSame( + '?App\\Models\\User', + $resolver->resolveVariable('/Use.xphp', 'a', $aOffset), + ); + // Inside withPosts, $items is Collection -> $b: ?Post. + self::assertSame( + '?App\\Models\\Post', + $resolver->resolveVariable('/Use.xphp', 'b', $bOffset), + ); + } + + public function testParamTypedScopeEntryOnClassMethod(): void + { + // Methods inside a class get the same Param treatment as free + // functions. Covers the constructor / method shape that real + // user code hits more often than free functions. + $workspace = $this->workspace(); + $this->openCollection($workspace, returnType: '?T'); + $this->openUser($workspace); + $source = <<<'XPHP' + $users): void + { + $first = $users->first(); + } + } + XPHP; + $this->open($workspace, '/Use.xphp', $source); + + $resolver = $this->resolver($workspace); + $offset = strpos($source, '$first = $users') + 1; + + self::assertSame( + '?App\\Models\\User', + $resolver->resolveVariable('/Use.xphp', 'first', $offset), + ); + } + + public function testNullableParamTypeStillSeedsBinding(): void + { + // `?Collection $users` -- the leading `?` doesn't change + // the receiver class. The seed should still establish the + // Collection binding for method calls inside the body. + $workspace = $this->workspace(); + $this->openCollection($workspace, returnType: '?T'); + $this->openUser($workspace); + $source = <<<'XPHP' + $users) { + $first = $users->first(); + } + XPHP; + $this->open($workspace, '/Use.xphp', $source); + + $resolver = $this->resolver($workspace); + $offset = strpos($source, '$first = $users') + 1; + + self::assertSame( + '?App\\Models\\User', + $resolver->resolveVariable('/Use.xphp', 'first', $offset), + ); + } + + public function testNonGenericParamSeedsNothing(): void + { + // `function f(User $u)` -- User isn't a generic class, so the + // resolver has nothing to seed. worse-reflection covers this + // through its own inference path; the resolver yields. + $workspace = $this->workspace(); + $this->openUser($workspace); + $source = <<<'XPHP' + open($workspace, '/Use.xphp', $source); + + $resolver = $this->resolver($workspace); + $offset = strpos($source, '$x = $u') + 1; + + self::assertNull($resolver->resolveVariable('/Use.xphp', 'u', $offset)); + self::assertNull($resolver->resolveVariable('/Use.xphp', 'x', $offset)); + } + + public function testParamScopeDoesNotLeakIntoTopLevel(): void + { + // A `$users` parameter inside a function MUST NOT shadow a + // top-level usage of the same variable name. Cursor outside the + // function body resolves only against the top-level scope (which + // here has nothing for `$users`). + $workspace = $this->workspace(); + $this->openCollection($workspace, returnType: '?T'); + $this->openUser($workspace); + $source = <<<'XPHP' + $users) { + $a = $users->first(); + } + echo $users; + XPHP; + $this->open($workspace, '/Use.xphp', $source); + + $resolver = $this->resolver($workspace); + $topLevelOffset = strpos($source, 'echo $users') + strlen('echo '); + + self::assertNull( + $resolver->resolveVariable('/Use.xphp', 'users', $topLevelOffset), + 'top-level cursor must not see the inner function param', + ); + } + + public function testTwoStepChainSubstitutesThroughIntermediate(): void + { + // Phase 1.4: `$repo->items()->first()` where Repository::items() + // returns Collection and Collection::first() returns ?T. + // The intermediate `items()` produces Collection; the outer + // `first()` then substitutes T -> User against that. + $workspace = $this->workspace(); + $this->open($workspace, '/Collection.xphp', <<<'XPHP' + { + public function first(): ?T { return null; } + } + XPHP); + $this->open($workspace, '/Repository.xphp', <<<'XPHP' + { + public function items(): Collection { return new Collection(); } + } + XPHP); + $this->openUser($workspace); + $this->open($workspace, '/Use.xphp', <<<'XPHP' + (); + $user = $repo->items()->first(); + XPHP); + + $resolver = $this->resolver($workspace); + + self::assertSame( + '?App\\Models\\User', + $resolver->resolveVariable('/Use.xphp', 'user', PHP_INT_MAX), + ); + } + + public function testThreeStepChainStillSubstitutes(): void + { + // Phase 1.4 stress test: three method calls in one statement. + // n-step recursion through inferType should compose cleanly. + $workspace = $this->workspace(); + $this->open($workspace, '/Wrap.xphp', <<<'XPHP' + { + public function inner(): Inner { return new Inner(); } + } + class Inner { + public function items(): Collection { return new Collection(); } + } + class Collection { + public function first(): ?T { return null; } + } + XPHP); + $this->openUser($workspace); + $this->open($workspace, '/Use.xphp', <<<'XPHP' + (); + $u = $w->inner()->items()->first(); + XPHP); + + $resolver = $this->resolver($workspace); + + self::assertSame( + '?App\\Models\\User', + $resolver->resolveVariable('/Use.xphp', 'u', PHP_INT_MAX), + ); + } + + public function testClosureCapturesOuterBinding(): void + { + // Phase 1.5: `function () use ($users) { $first = $users->first(); }` + // -- the closure's `use ($users)` propagates the outer binding + // into the closure's own scope. Without this propagation the + // closure body would see an empty scope. + $workspace = $this->workspace(); + $this->openCollection($workspace, returnType: '?T'); + $this->openUser($workspace); + $source = <<<'XPHP' + (); + $fn = function () use ($users) { + $first = $users->first(); + }; + XPHP; + $this->open($workspace, '/Use.xphp', $source); + + $resolver = $this->resolver($workspace); + $offset = strpos($source, '$first = $users') + 1; + + self::assertSame( + '?App\\Models\\User', + $resolver->resolveVariable('/Use.xphp', 'first', $offset), + ); + } + + public function testClosureWithoutUseClauseCannotSeeOuterBinding(): void + { + // Without `use ($users)`, the closure's scope is isolated from + // the outer scope -- accessing `$users` inside is undefined. + // The resolver yields, matching PHP's runtime behaviour where + // the variable is unbound. + $workspace = $this->workspace(); + $this->openCollection($workspace, returnType: '?T'); + $this->openUser($workspace); + $source = <<<'XPHP' + (); + $fn = function () { + $first = $users->first(); + }; + XPHP; + $this->open($workspace, '/Use.xphp', $source); + + $resolver = $this->resolver($workspace); + $offset = strpos($source, '$first = $users') + 1; + + self::assertNull( + $resolver->resolveVariable('/Use.xphp', 'first', $offset), + 'closure without use clause must not see outer bindings', + ); + } + + public function testClosureParamSeededAlongsideCapture(): void + { + // Params + captured uses both seed the closure scope. Mixed + // usage: a generic-typed param plus a captured outer variable. + $workspace = $this->workspace(); + $this->openCollection($workspace, returnType: '?T'); + $this->openUser($workspace); + $source = <<<'XPHP' + (); + $fn = function (Collection $param) use ($outer) { + $a = $param->first(); + $b = $outer->first(); + }; + XPHP; + $this->open($workspace, '/Use.xphp', $source); + + $resolver = $this->resolver($workspace); + $aOffset = strpos($source, '$a = $param') + 1; + $bOffset = strpos($source, '$b = $outer') + 1; + + self::assertSame( + '?App\\Models\\User', + $resolver->resolveVariable('/Use.xphp', 'a', $aOffset), + ); + self::assertSame( + '?App\\Models\\User', + $resolver->resolveVariable('/Use.xphp', 'b', $bOffset), + ); + } + + public function testRebuildsBindingsOnDocumentVersionBump(): void + { + // Cache is version-keyed -- a `didChange` on the file declaring + // the generic class must surface in the next hover. + $workspace = $this->workspace(); + $this->openCollection($workspace, returnType: '?T', uri: '/Collection.xphp', version: 1); + $this->openUser($workspace); + $this->open($workspace, '/Use.xphp', <<<'XPHP' + (); + $user = $users->first(); + XPHP); + + $resolver = $this->resolver($workspace); + self::assertSame('?App\\Models\\User', $resolver->resolveVariable('/Use.xphp', 'user', PHP_INT_MAX)); + + // Re-publish Collection.xphp at v2 with first() now returning T + // (no `?`). Bindings live on /Use.xphp's cache; since the + // ClassLikeLookup walks current workspace state every call, the + // method-return read picks up the new shape on the next request. + $newCollection = <<<'XPHP' + { + public function first(): T { return null; } + } + XPHP; + $workspace->update( + new VersionedTextDocumentIdentifier(2, '/Collection.xphp'), + $newCollection, + ); + // Force /Use.xphp to rebuild too -- otherwise the resolver's + // own cache returns the v1 result. In production a didChange + // on /Use.xphp would bump its version; tests can do the same + // manually. + $useSource = $workspace->get('/Use.xphp')->text; + $workspace->update(new VersionedTextDocumentIdentifier(2, '/Use.xphp'), $useSource); + + self::assertSame('App\\Models\\User', $resolver->resolveVariable('/Use.xphp', 'user', PHP_INT_MAX)); + } + + private function resolver(PhpactorWorkspace $workspace): GenericResolver + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $cache = new ParsedDocumentCache(new Analyzer($parser)); + $lookup = new WorkspaceClassLikeLookup($workspace, $cache); + $index = new FqnIndex($workspace, $cache, $parser, ''); + return new GenericResolver($workspace, $cache, $lookup, $parser, $index); + } + + private function resolverWithFilesystem(PhpactorWorkspace $workspace, string $rootPath): GenericResolver + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $cache = new ParsedDocumentCache(new Analyzer($parser)); + $index = new FqnIndex($workspace, $cache, $parser, $rootPath); + $lookup = new CompositeClassLikeLookup( + new WorkspaceClassLikeLookup($workspace, $cache), + new FilesystemClassLikeLookup($index), + ); + return new GenericResolver($workspace, $cache, $lookup, $parser, $index); + } + + private function rmrf(string $dir): void + { + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $p = $dir . '/' . $entry; + if (is_dir($p)) { + $this->rmrf($p); + } else { + unlink($p); + } + } + rmdir($dir); + } + + private function workspace(): PhpactorWorkspace + { + return new PhpactorWorkspace(); + } + + private function open(PhpactorWorkspace $workspace, string $uri, string $source): void + { + $workspace->open(new TextDocumentItem($uri, 'xphp', 1, $source)); + } + + private function openCollection( + PhpactorWorkspace $workspace, + string $returnType, + string $uri = '/Collection.xphp', + int $version = 1, + ): void { + $workspace->open(new TextDocumentItem($uri, 'xphp', $version, << { + public function first(): {$returnType} { return null; } + } + XPHP)); + } + + private function openUser(PhpactorWorkspace $workspace): void + { + $workspace->open(new TextDocumentItem( + '/User.xphp', + 'xphp', + 1, + "'; + $hit = PhpCompletionContext::detect($source, strlen($source)); + self::assertSame(['kind' => 'member', 'receiverEnd' => 4, 'prefix' => ''], $hit); + } + + public function testDetectsMemberAccessWithPrefix(): void + { + $source = '$obj->sho'; + $hit = PhpCompletionContext::detect($source, strlen($source)); + self::assertSame(['kind' => 'member', 'receiverEnd' => 4, 'prefix' => 'sho'], $hit); + } + + public function testDetectsStaticAccessImmediatelyAfterColons(): void + { + $source = 'User::'; + $hit = PhpCompletionContext::detect($source, strlen($source)); + self::assertSame(['kind' => 'static', 'receiverEnd' => 4, 'prefix' => ''], $hit); + } + + public function testDetectsStaticAccessWithPrefix(): void + { + $source = 'User::frO'; + $hit = PhpCompletionContext::detect($source, strlen($source)); + self::assertSame(['kind' => 'static', 'receiverEnd' => 4, 'prefix' => 'frO'], $hit); + } + + public function testRejectsOutsideMemberOrStaticContext(): void + { + self::assertNull(PhpCompletionContext::detect('$x = 1;', 6)); + self::assertNull(PhpCompletionContext::detect('echo "hi";', 5)); + } + + public function testReturnsNullForOffsetPastEndOfSource(): void + { + self::assertNull(PhpCompletionContext::detect('abc', 99)); + } + + public function testSuppressesCompletionInsideSingleQuotedString(): void + { + // Phase 3 polish: cursor between the quotes of '$user->' must + // not fire member completion -- the whole thing is a string + // literal. + $source = "';\n"; + $offset = strpos($source, "->'") + 2; // cursor between `->` and `'` + self::assertNull(PhpCompletionContext::detect($source, $offset)); + } + + public function testSuppressesCompletionInsideLineComment(): void + { + $source = "name will become...\n"; + $offset = strpos($source, '->name') + 2; + self::assertNull(PhpCompletionContext::detect($source, $offset)); + } + + public function testSuppressesCompletionInsideDocblock(): void + { + $source = "name\n */\nclass Foo {}\n"; + $offset = strpos($source, '->name') + 2; + self::assertNull(PhpCompletionContext::detect($source, $offset)); + } + + public function testStillFiresCompletionInsideDoubleQuotedStringInterpolation(): void + { + // Inside `"$user->name"` the tokenizer splits `$user->name` into + // real code tokens (T_VARIABLE / T_OBJECT_OPERATOR / T_STRING), + // not literal text -- completion must still fire there. + $source = "\";\n"; + $offset = strpos($source, '->"') + 2; // cursor after `->` + $hit = PhpCompletionContext::detect($source, $offset); + self::assertSame('member', $hit['kind'] ?? null, 'interpolated `->` should still classify as member'); + } + + public function testReturnsNullForNegativeOffset(): void + { + self::assertNull(PhpCompletionContext::detect('abc', -1)); + } + + public function testHandlesIdentifierOnlyAfterArrow(): void + { + // Cursor mid-identifier following `->`; the partial identifier + // becomes the prefix. + $source = '$obj->getNa'; + $hit = PhpCompletionContext::detect($source, strlen($source)); + self::assertSame(['kind' => 'member', 'receiverEnd' => 4, 'prefix' => 'getNa'], $hit); + } + + public function testRejectsLoneColonNotPrecededByAnotherColon(): void + { + // Single `:` is not `::` — could be ternary, label, etc. + // Returns null (no completion context). + $source = '$x :'; + self::assertNull(PhpCompletionContext::detect($source, strlen($source))); + } + + public function testRejectsAngleBracketBeingMistakenForArrow(): void + { + // `=>` looks like `->` if you only check the last two bytes. + // The detector must reject this case (a `=>` arrow is part of + // array literals / arrow functions, not a member access). + $source = '$arr =>'; + self::assertNull(PhpCompletionContext::detect($source, strlen($source))); + } + + public function testDetectsVariableContextAfterDollar(): void + { + // Cursor right after `$repo` -- variable completion candidate + // shape (`$re|`, `$repo|`). + $source = '$repo'; + self::assertSame( + ['kind' => 'variable', 'prefix' => 'repo'], + PhpCompletionContext::detect($source, strlen($source)), + ); + } + + public function testDetectsVariableContextWithEmptyPrefixRightAfterDollar(): void + { + // Cursor immediately after `$` (no identifier chars yet) -- still + // variable context, empty prefix. + $source = 'echo $'; + self::assertSame( + ['kind' => 'variable', 'prefix' => ''], + PhpCompletionContext::detect($source, strlen($source)), + ); + } + + public function testDetectsNewKeywordContext(): void + { + // After `new ` -- class-only completion. + $source = '$x = new Us'; + self::assertSame( + ['kind' => 'new', 'prefix' => 'Us'], + PhpCompletionContext::detect($source, strlen($source)), + ); + } + + public function testDetectsNewWithMultipleSpaces(): void + { + // Whitespace after `new` is collapsed -- still recognised as a + // `new` keyword context (PHP grammar allows arbitrary whitespace + // between `new` and the class name). + $source = "\$x = new\n Us"; + $hit = PhpCompletionContext::detect($source, strlen($source)); + self::assertSame('new', $hit['kind'] ?? null); + self::assertSame('Us', $hit['prefix'] ?? null); + } + + public function testNewKeywordEmbeddedInIdentifierIsNotMatched(): void + { + // `mynew Foo` -- the `new` is part of the prior identifier, not + // the keyword. Must classify as `expression`, not `new`. + $source = 'mynew Fo'; + $hit = PhpCompletionContext::detect($source, strlen($source)); + self::assertSame('expression', $hit['kind'] ?? null); + self::assertSame('Fo', $hit['prefix'] ?? null); + } + + public function testDetectsExpressionContextForBareIdentifier(): void + { + // Cursor on a bare identifier with no preceding operator -- could + // be a function or class reference (both are suggested at this + // shape). + $source = 'echo str'; + self::assertSame( + ['kind' => 'expression', 'prefix' => 'str'], + PhpCompletionContext::detect($source, strlen($source)), + ); + } + + public function testNumberLiteralIsNotAnIdentifier(): void + { + // PHP identifiers can't start with a digit; `$x = 1;` with cursor + // mid-number-literal mustn't classify as `expression` with + // prefix `1`. Returns null so no completion fires. + $source = '$x = 1'; + self::assertNull(PhpCompletionContext::detect($source, strlen($source))); + } + + public function testRejectsCursorAfterSemicolon(): void + { + // Just after `;` and a newline -- no identifier prefix; null. + $source = "\$x = 1;\n"; + self::assertNull(PhpCompletionContext::detect($source, strlen($source))); + } +} diff --git a/tools/lsp/test/Resolver/PhpCompletionResolverTest.php b/tools/lsp/test/Resolver/PhpCompletionResolverTest.php new file mode 100644 index 00000000..7983a7af --- /dev/null +++ b/tools/lsp/test/Resolver/PhpCompletionResolverTest.php @@ -0,0 +1,926 @@ +workspace(); + $this->open($workspace, '/User.xphp', <<<'XPHP' + \n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $items = $this->completeAt($workspace, '/Use.xphp', $useSource, '$u->', 4); + + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + self::assertContains('shout', $labels); + self::assertContains('whisper', $labels); + self::assertNotContains('_secret', $labels, 'private methods must be filtered out'); + } + + public function testPrivateAndProtectedMembersVisibleInsideSameClass(): void + { + // Phase 3 polish: when the cursor is inside the same class as + // the receiver, private + protected members are visible. The + // common case: writing `$this->_helper()` from inside the class + // body itself. Source has a valid existing method so the file + // parses cleanly; the cursor sits on the existing `$this->` of + // that method (not mid-edit), simulating the user re-typing + // after the dot. + $workspace = $this->workspace(); + $this->open($workspace, '/Account.xphp', <<<'XPHP' + label; + return $marker; + } + } + XPHP); + + $source = $workspace->get('/Account.xphp')->text; + // Cursor on the `->` AFTER $this in `$marker = $this->label;`, + // simulating member completion fired at that position. + $items = $this->completeAt($workspace, '/Account.xphp', $source, '$this->', strlen('$this->')); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + self::assertContains('balance', $labels, 'private prop visible from same class'); + self::assertContains('owner', $labels, 'protected prop visible from same class'); + self::assertContains('label', $labels); + self::assertContains('describe', $labels); + } + + public function testPrivatePropertyVisibleWhenFileIsMidEditAndRegularParseFails(): void + { + // Regression for prod trace xphp-20260525-092358-161.log: + // user types `$th` inside Collection::first() before triggering + // completion at `$this->|`. The mid-edit `$th\n return ...` + // makes the non-tolerant nikic parse throw, so the cached + // ParseResult.ast is null. enclosingClassFqnAt previously + // returned null on cache miss -> isSameClass=false -> private + // $items dropped silently. Fix: fall back to parseTolerant. + // + // Worse-reflection still finds the class via FilesystemSourceLocator + // (the on-disk copy is clean even when the in-memory buffer is + // dirty), so the regression hinges purely on whether our + // enclosingClassFqnAt recovers the enclosing class. Use a + // filesystem fixture so worse-reflection can reflect Collection + // the same way it does in prod. + $root = sys_get_temp_dir() . '/xphp-vis-' . bin2hex(random_bytes(6)); + mkdir($root, 0o755, true); + try { + $cleanSource = "\n{\n private T[] \$items;\n\n public function __construct(T ...\$items)\n {\n \$this->items = \$items;\n }\n\n public function first(): ?T\n {\n return \$this->items[0] ?? null;\n }\n}\n"; + file_put_contents($root . '/Collection.xphp', $cleanSource); + + $workspace = $this->workspace(); + // In-memory buffer with the mid-edit `$th` that breaks the + // non-tolerant parse. + $dirtySource = "\n{\n private T[] \$items;\n\n public function __construct(T ...\$items)\n {\n \$this->items = \$items;\n }\n\n public function first(): ?T\n {\n \$th\n return \$this->items[0] ?? null;\n }\n}\n"; + $this->open($workspace, $root . '/Collection.xphp', $dirtySource); + + $items = $this->resolverWithRoot($workspace, $root)->complete( + $root . '/Collection.xphp', + ...$this->lineCharFor($dirtySource, 'return $this->items[0]', strlen('return $this->')), + ); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + self::assertContains( + 'items', + $labels, + 'private $items must surface even when the regular parse failed mid-edit', + ); + } finally { + if (is_dir($root)) { + foreach (scandir($root) ?: [] as $e) { + if ($e !== '.' && $e !== '..') { + unlink($root . '/' . $e); + } + } + rmdir($root); + } + } + } + + /** + * @return array{0: int, 1: int} + */ + private function lineCharFor(string $source, string $needle, int $offsetInNeedle): array + { + $byte = strpos($source, $needle); + self::assertNotFalse($byte); + $byte += $offsetInNeedle; + return (new PositionMap($source))->offsetToPosition($byte); + } + + private function resolverWithRoot(PhpactorWorkspace $workspace, string $rootPath): PhpCompletionResolver + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $cache = new ParsedDocumentCache(new Analyzer($parser)); + $workspaceSymbols = new \XPHP\Lsp\Handler\WorkspaceSymbols($workspace, $cache); + $completionIndex = new \XPHP\Lsp\Resolver\CompletionIndex( + $workspaceSymbols, + ReflectorFactory::defaultStubPath(), + ); + $fqnIndex = new \XPHP\Lsp\Reflection\FqnIndex($workspace, $cache, $parser, $rootPath); + $reflector = (new ReflectorFactory( + $workspace, + $cache, + $parser, + rootPath: $rootPath, + stubPath: ReflectorFactory::defaultStubPath(), + cacheDir: ReflectorFactory::defaultCacheDir(), + fqnIndex: $fqnIndex, + ))->build(); + $classLikeLookup = new \XPHP\Lsp\Resolver\WorkspaceClassLikeLookup($workspace, $cache); + return new PhpCompletionResolver( + $workspace, + $parser, + $reflector, + $completionIndex, + $cache, + new \XPHP\Lsp\Resolver\GenericParamRegistry($fqnIndex), + new \XPHP\Lsp\Resolver\GenericResolver($workspace, $cache, $classLikeLookup, $parser, $fqnIndex), + ); + } + + public function testPrivatePropertyVisibleInsideGenericClassWithTBracketSugar(): void + { + // Regression for the prod trace from xphp-20260525-015638-815.log + // id=82/117 -- the `$this->|` completion inside Collection + // returned only the 3 public methods, dropping the private + // `$items`. T[] -> array rewrites the source (+2 bytes); the AST + // class END is in stripped coords while enclosingClassFqnAt + // receives the original-source cursor offset. Reproduces the + // EXACT prod file layout (declare + blank lines + two T[] + // occurrences) so any byte-coord shift surfaces. + $workspace = $this->workspace(); + $this->open($workspace, '/Collection.xphp', "\n{\n private T[] \$items;\n\n public function __construct(T ...\$items)\n {\n \$this->items = \$items;\n }\n\n public function first(): ?T\n {\n return \$this->items[0] ?? null;\n }\n\n public function all(): T[]\n {\n return \$this->items;\n }\n\n public function count(): int\n {\n return count(\$this->items);\n }\n}\n"); + + $source = $workspace->get('/Collection.xphp')->text; + // Cursor right after the `->` of `return $this->items[0]` (inside + // first() -- this is the position that broke in prod id=82/117). + $items = $this->completeAt( + $workspace, + '/Collection.xphp', + $source, + 'return $this->items[0]', + strlen('return $this->'), + ); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + self::assertContains('items', $labels, 'private $items must surface from inside same class even with T[] sugar'); + } + + public function testPrivateAndProtectedMembersHiddenFromOutside(): void + { + // Cursor inside a DIFFERENT class -- the previous default + // (public-only) still applies. No regression. + $workspace = $this->workspace(); + $this->open($workspace, '/Account.xphp', <<<'XPHP' + \n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $items = $this->completeAt($workspace, '/Use.xphp', $useSource, '$a->', 4); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + self::assertContains('label', $labels); + self::assertNotContains('balance', $labels, 'private prop must NOT leak across classes'); + } + + public function testStaticPropertyCompletionAfterColonColonDollar(): void + { + // Phase 3: `Cls::$|` -- only static properties surface. Methods, + // instance properties, and constants must NOT appear. + $workspace = $this->workspace(); + $this->open($workspace, '/Counter.xphp', <<<'XPHP' + open($workspace, '/Use.xphp', $useSource); + + $items = $this->completeAt($workspace, '/Use.xphp', $useSource, 'Counter::$', strlen('Counter::$')); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + // Labels carry the `$` prefix so PhpStorm's popup filter (which + // matches against the text typed since the last word boundary) + // accepts them when the user has typed `Stats::$` -- without the + // `$`, PhpStorm drops every item. insertText is the bare name + // (`total`) so the `$` already in source isn't double-inserted. + self::assertContains('$total', $labels); + self::assertContains('$label', $labels); + self::assertNotContains('instance', $labels, 'instance prop must not surface on static-prop completion'); + self::assertNotContains('$instance', $labels, 'instance prop must not surface on static-prop completion'); + self::assertNotContains('VERSION', $labels, 'constants must not surface on static-prop completion'); + self::assertNotContains('tick', $labels, 'methods must not surface on static-prop completion'); + + // Each item must carry a textEdit so PhpStorm doesn't extend the + // replacement range backwards through the `$` -- the regression + // diagnosed via prod log id=28 (xphp-20260525-172338-536.log). + // newText is the bare property name; range stays at the cursor + // when the typed prefix is empty. + foreach ($items as $item) { + self::assertNotNull($item->textEdit, 'static-prop item must carry a textEdit anchor'); + self::assertSame( + ltrim($item->label, '$'), + $item->textEdit->newText, + 'textEdit.newText must be the bare property name (no leading $)', + ); + } + } + + public function testStaticPropertyCompletionFiltersByPrefix(): void + { + // `Cls::$la|` -- prefix filter narrows to props matching `la*`. + $workspace = $this->workspace(); + $this->open($workspace, '/Counter.xphp', <<<'XPHP' + open($workspace, '/Use.xphp', $useSource); + + // Cursor inside `$label` after the `la` prefix. + $items = $this->completeAt($workspace, '/Use.xphp', $useSource, '$label', strlen('$la')); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + self::assertContains('$label', $labels); + self::assertContains('$latest', $labels); + self::assertNotContains('$total', $labels, 'prefix `la` must exclude `total`'); + } + + public function testProtectedMembersVisibleInsideSubclass(): void + { + // Phase 3 polish: subclass-protected -- when the cursor is inside + // a class that extends the receiver, the receiver's protected + // members must surface. Private members must NOT (that's the + // declaring-class-only gate). + $workspace = $this->workspace(); + $this->open($workspace, '/Animal.xphp', <<<'XPHP' + ` -- the receiver class FQN is + // `Animal`, and the caller's enclosing class is `Dog`. + $this->open($workspace, '/Dog.xphp', <<<'XPHP' + label; + } + } + XPHP); + $useSource = $workspace->get('/Dog.xphp')->text; + + $items = $this->completeAt($workspace, '/Dog.xphp', $useSource, '$this->', strlen('$this->')); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + // Protected members of the ancestor surface in the subclass. + self::assertContains('age', $labels, 'protected prop must surface in subclass'); + self::assertContains('sniff', $labels, 'protected method must surface in subclass'); + // Private members stay invisible -- subclass can't see private. + self::assertNotContains('secret', $labels, 'private prop must stay hidden in subclass'); + self::assertNotContains('dream', $labels, 'private method must stay hidden in subclass'); + } + + public function testCompletesPublicPropertiesAfterArrow(): void + { + $workspace = $this->workspace(); + $this->open($workspace, '/User.xphp', <<<'XPHP' + \n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $items = $this->completeAt($workspace, '/Use.xphp', $useSource, '$u->', 4); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + self::assertContains('name', $labels); + self::assertNotContains('age', $labels); + } + + public function testFiltersMembersByPrefix(): void + { + $workspace = $this->workspace(); + $this->open($workspace, '/User.xphp', <<<'XPHP' + sh"; + $this->open($workspace, '/Use.xphp', $useSource); + + $items = $this->completeAt($workspace, '/Use.xphp', $useSource, '$u->sh', strlen('$u->sh')); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + self::assertContains('shout', $labels); + self::assertNotContains('whisper', $labels); + self::assertNotContains('murmur', $labels); + } + + public function testCompletesStaticMethodsAfterDoubleColon(): void + { + $workspace = $this->workspace(); + $this->open($workspace, '/Util.xphp', <<<'XPHP' + open($workspace, '/Use.xphp', $useSource); + + $items = $this->completeAt($workspace, '/Use.xphp', $useSource, 'Util::', strlen('Util::')); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + self::assertContains('shout', $labels, 'static methods must be included'); + self::assertNotContains('bark', $labels, 'instance methods must be excluded on static access'); + } + + public function testCompletesClassConstantsAfterDoubleColon(): void + { + $workspace = $this->workspace(); + $this->open($workspace, '/Cfg.xphp', <<<'XPHP' + open($workspace, '/Use.xphp', $useSource); + + $items = $this->completeAt($workspace, '/Use.xphp', $useSource, 'Cfg::', strlen('Cfg::')); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + self::assertContains('MAX', $labels); + self::assertContains('MIN', $labels); + } + + public function testReturnsEmptyForNonMemberContext(): void + { + $workspace = $this->workspace(); + $useSource = "open($workspace, '/Use.xphp', $useSource); + + $items = $this->completeAt($workspace, '/Use.xphp', $useSource, '$x = ', strlen('$x = ')); + self::assertSame([], $items); + } + + public function testReturnsEmptyForUnknownReceiverType(): void + { + $workspace = $this->workspace(); + $useSource = ""; + $this->open($workspace, '/Use.xphp', $useSource); + + $items = $this->completeAt($workspace, '/Use.xphp', $useSource, '$mystery->', strlen('$mystery->')); + self::assertSame([], $items); + } + + public function testReturnsEmptyForUnknownDocument(): void + { + $resolver = $this->resolver($this->workspace()); + self::assertSame([], $resolver->complete('/never-opened.xphp', 0, 0)); + } + + public function testCompletesVariablesInScopeAfterDollar(): void + { + // Use an already-syntactically-valid completion site (inside an `if` + // condition) so the source parses cleanly -- a cursor in the middle + // of an unterminated `echo $re` would have nikic refuse the document + // and our resolver fall back to empty. + $workspace = $this->workspace(); + $source = "open($workspace, '/doc.xphp', $source); + + $items = $this->completeAt($workspace, '/doc.xphp', $source, 'if ($re', strlen('if ($re')); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + self::assertContains('$repo', $labels); + self::assertContains('$report', $labels); + // `re` doesn't prefix-match `items` or `item`, so they're excluded. + self::assertNotContains('$items', $labels); + self::assertNotContains('$item', $labels); + } + + public function testVariableCompletionRespectsFunctionScopeBoundary(): void + { + // Phase 3 polish: function bodies are scope barriers in PHP -- + // variables from outside don't leak in. Before this commit the + // collector dumped every variable in the document; cursor inside + // `inner()` saw `$outer` despite that being inaccessible. + $workspace = $this->workspace(); + $source = "open($workspace, '/doc.xphp', $source); + + // Cursor on `$local` inside `inner()`. + $items = $this->completeAt($workspace, '/doc.xphp', $source, 'return $local', strlen('return $')); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + self::assertContains('$local', $labels); + self::assertContains('$param', $labels); + // $outer is at top-level scope; the function scope barrier hides it. + self::assertNotContains('$outer', $labels); + } + + public function testVariableCompletionInClosureRespectsUseClause(): void + { + // PHP closures only see the outer scope variables they explicitly + // import via `use (...)`. Other outer-scope vars stay hidden. + $workspace = $this->workspace(); + $source = "open($workspace, '/doc.xphp', $source); + + // Cursor inside the closure body. + $items = $this->completeAt($workspace, '/doc.xphp', $source, 'return $inner', strlen('return $')); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + self::assertContains('$inner', $labels); + self::assertContains('$visible', $labels); + // $hidden was NOT imported via use; closures don't auto-capture. + self::assertNotContains('$hidden', $labels); + // $cb is OUTSIDE the closure (top-level), but it's the closure's + // own assignment target -- arguably visible from outside the + // closure but we're inside it: stays hidden. + self::assertNotContains('$cb', $labels); + } + + public function testVariableCompletionInArrowFnAutoCapturesOuter(): void + { + // PHP arrow functions auto-capture all outer-scope vars by value. + // Inside `fn () => ...`, the user should see outer vars without + // needing an explicit use clause. + $workspace = $this->workspace(); + $source = " \$x * \$factor;\n"; + $this->open($workspace, '/doc.xphp', $source); + + // Cursor on `$factor` inside the arrow's body. + $items = $this->completeAt($workspace, '/doc.xphp', $source, '* $factor', strlen('* $')); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + self::assertContains('$x', $labels); + self::assertContains('$factor', $labels); + self::assertContains('$outer', $labels, 'arrow fn auto-captures outer scope vars'); + } + + public function testVariableCompletionInMethodHidesTopLevelVars(): void + { + // Methods are scope barriers -- top-level vars from above the + // class declaration must NOT surface inside method bodies. + $workspace = $this->workspace(); + $source = "open($workspace, '/doc.xphp', $source); + + $items = $this->completeAt($workspace, '/doc.xphp', $source, 'return $inMethod', strlen('return $')); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + self::assertContains('$inMethod', $labels); + self::assertContains('$arg', $labels); + self::assertNotContains('$topLevel', $labels, 'method scope hides top-level vars'); + } + + public function testCompletesVariablesAfterBareDollarSign(): void + { + // Cursor immediately after `$` -- we seek inside an existing + // `$alpha` reference so the source still parses. The detector + // sees prefix="" and char-before-prefix="$" -> variable context. + $workspace = $this->workspace(); + $source = "open($workspace, '/doc.xphp', $source); + + $items = $this->completeAt($workspace, '/doc.xphp', $source, 'echo $', strlen('echo $')); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + self::assertContains('$alpha', $labels); + self::assertContains('$beta', $labels); + } + + public function testCompletesUserClassesInExpressionPosition(): void + { + $workspace = $this->workspace(); + $this->open($workspace, '/User.xphp', "open($workspace, '/Use.xphp', $useSource); + + $items = $this->completeAt($workspace, '/Use.xphp', $useSource, 'new Use', strlen('new Use')); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + // Short-name match `User` against prefix `Use`. + self::assertContains('User', $labels); + + // After `new`, only classes -- no functions. + $kinds = array_map(static fn (CompletionItem $i): int => $i->kind ?? -1, $items); + self::assertNotContains(\Phpactor\LanguageServerProtocol\CompletionItemKind::FUNCTION, $kinds); + } + + public function testNewWithEmptyPrefixReturnsEmpty(): void + { + // Empty prefix in `new ` would otherwise dump every class FQN in + // the workspace + stubs into the popup. Resolver guards against + // it; user has to type at least one char. + $workspace = $this->workspace(); + $this->open($workspace, '/User.xphp', "open($workspace, '/Use.xphp', $useSource); + + $items = $this->completeAt($workspace, '/Use.xphp', $useSource, 'new ', strlen('new ')); + self::assertSame([], $items); + } + + public function testCompletesWorkspaceFunctionsByPrefix(): void + { + $workspace = $this->workspace(); + $this->open($workspace, '/funcs.xphp', <<<'XPHP' + open($workspace, '/Use.xphp', $useSource); + + $items = $this->completeAt($workspace, '/Use.xphp', $useSource, 'echo gr', strlen('echo gr')); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + self::assertContains('greet', $labels); + self::assertContains('gravity', $labels); + self::assertNotContains('unrelated', $labels); + } + + public function testCompletesNativeFunctionsFromStubsByPrefix(): void + { + if (!is_dir(ReflectorFactory::defaultStubPath())) { + self::markTestSkipped('jetbrains/phpstorm-stubs not installed'); + } + $workspace = $this->workspace(); + $source = "open($workspace, '/doc.xphp', $source); + + $items = $this->completeAt($workspace, '/doc.xphp', $source, 'echo strl', strlen('echo strl')); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + // `strl` prefix matches `strlen` (and possibly nothing else in stubs). + self::assertContains('strlen', $labels); + } + + public function testCompletesMembersOnReceiverFromGenericInstantiation(): void + { + // Mirrors the user-reported failing case in + // xphp-20260524-150655-167.log: `$users = new Collection(...)` + // followed by `$users->|` on the next line. The xphp strip turns + // `` into whitespace; worse-reflection should still infer + // `$users: App\Containers\Collection` from the ctor and surface + // Collection's methods. Production returned empty here; if this + // test passes locally, the gap is environmental rather than logic. + $workspace = $this->workspace(); + $this->open($workspace, '/Collection.xphp', <<<'XPHP' + + { + private T[] $items; + public function __construct(T ...$items) + { + $this->items = $items; + } + public function first(): ?T + { + return $this->items[0] ?? null; + } + public function count(): int + { + return count($this->items); + } + } + XPHP); + $this->open($workspace, '/User.xphp', "(new User('a'));\n\$users->\necho 'done';\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $items = $this->completeAt($workspace, '/Use.xphp', $useSource, '$users->', strlen('$users->')); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + // Methods of Collection + self::assertContains('first', $labels); + self::assertContains('count', $labels); + // __construct is suppressed by the magic-method filter + self::assertNotContains('__construct', $labels); + } + + public function testCompletesMembersOnNullableReceiverType(): void + { + // worse-reflection surfaces a nullable return type like + // function getMaybeUser(): ?User + // as the receiver type `?App\Models\User` for `getMaybeUser()?->|` + // chained access. Without stripping the `?`, reflectClassLike + // treats it as part of the FQN and throws SourceNotFound -- the + // exact shape captured in xphp-20260524-202152-019.log. + $workspace = $this->workspace(); + $this->open($workspace, '/User.xphp', <<<'XPHP' + open($workspace, '/Repo.xphp', <<<'XPHP' + getMaybeUser()?->\necho 'x';\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $items = $this->completeAt( + $workspace, + '/Use.xphp', + $useSource, + '?->', + strlen('?->'), + ); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + self::assertContains('shout', $labels); + self::assertContains('name', $labels); + } + + public function testCompletesVariablesWhenSourceMidEditDoesNotParseStrictly(): void + { + // The user types `$us` with cursor on the `s` -- nikic refuses the + // document because `$us` isn't a complete statement. Before the + // fix the resolver returned [] outright; now we fall back to a + // tolerant parse so variables declared on PRIOR lines still + // surface. This is the exact shape captured in + // xphp-20260524-202152-019.log at line 13 char 2-3. + $workspace = $this->workspace(); + $source = "open($workspace, '/doc.xphp', $source); + + $items = $this->completeAt($workspace, '/doc.xphp', $source, '$us', strlen('$us')); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + self::assertContains('$users', $labels, 'prior `$users = []` must surface'); + self::assertContains('$usage', $labels, 'prior `$usage = 1` must surface'); + // `other` doesn't prefix-match `us`. + self::assertNotContains('$other', $labels); + } + + public function testMemberCompletionSubstitutesGenericReceiverViaResolver(): void + { + // Mirrors the user-reported gap in xphp-20260524-214251-685.log: + // $users = new Collection(); + // $u = $users->first(); // $u is ?T -> ?App\Models\User + // $u->| // member completion here returned 0 + // worse-reflection sees `?App\Containers\T` for the receiver, so + // reflectClassLike fails. The completion path now consults + // GenericResolver to swap the placeholder for the substituted + // concrete -- the user gets User's members instead of empty. + $workspace = $this->workspace(); + $this->open($workspace, '/Collection.xphp', <<<'XPHP' + { + public function first(): ?T { return null; } + } + XPHP); + $this->open($workspace, '/User.xphp', <<<'XPHP' + ();\n\$u = \$users->first();\n\$u->\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $items = $this->completeAt($workspace, '/Use.xphp', $useSource, '$u->', strlen('$u->')); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + self::assertContains('shout', $labels, 'User::shout must surface via resolver-substituted receiver'); + self::assertContains('name', $labels, 'User::$name must surface'); + self::assertNotContains('first', $labels, 'Collection::first must NOT leak through (receiver was swapped)'); + } + + public function testMemberCompletionThroughChainedMethodCallReceiver(): void + { + // Phase 0.7-completion: cursor at `$repo->first()?->|` -- the + // receiver of the chained `?->` is a MethodCall (not a Variable), + // so the old variable-symbolType-only swap didn't fire. The + // generalised resolveMemberAccessReceiverClassAt walks the + // receiver expression and substitutes via inferType. + $workspace = $this->workspace(); + $this->open($workspace, '/Repository.xphp', <<<'XPHP' + { + public function first(): ?T { return null; } + } + XPHP); + $this->open($workspace, '/User.xphp', <<<'XPHP' + ();\n\$repo->first()?->\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $items = $this->completeAt( + $workspace, + '/Use.xphp', + $useSource, + '?->', + strlen('?->'), + ); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $items); + + self::assertContains('name', $labels, 'User::$name must surface via chained-receiver substitution'); + self::assertContains('shout', $labels, 'User::shout must surface'); + } + + public function testMemberCompletionDetailRendersGenericPlaceholderAsBareName(): void + { + // The user reported -- xphp-20260524-204801-302.log id=9 -- that the + // `first` completion entry's `detail` read `(): ?App\Containers\T`, + // exposing the post-strip placeholder qualification. Wiring the + // GenericParamRegistry through CompletionItem assembly should + // collapse that to `?T`. + $workspace = $this->workspace(); + $this->open($workspace, '/Collection.xphp', <<<'XPHP' + { + public function first(): ?T { return null; } + public function count(): int { return 0; } + } + XPHP); + $this->open($workspace, '/User.xphp', "();\n\$users->\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $items = $this->completeAt($workspace, '/Use.xphp', $useSource, '$users->', strlen('$users->')); + $byLabel = []; + foreach ($items as $i) { + $byLabel[$i->label] = $i; + } + + self::assertArrayHasKey('first', $byLabel); + self::assertSame('(): ?T', $byLabel['first']->detail); + // Non-placeholder returns are left untouched. + self::assertArrayHasKey('count', $byLabel); + self::assertSame('(): int', $byLabel['count']->detail); + } + + public function testExpressionPositionEmptyPrefixReturnsEmpty(): void + { + // Same guard as `new` -- no completion without a prefix at + // expression position; the alternative is dumping ~6000 stub + // FQNs on the user. + $workspace = $this->workspace(); + $source = "open($workspace, '/doc.xphp', $source); + + $items = $this->completeAt($workspace, '/doc.xphp', $source, 'echo ', strlen('echo ')); + self::assertSame([], $items); + } + + private function completeAt( + PhpactorWorkspace $workspace, + string $uri, + string $source, + string $needle, + int $offsetInNeedle, + ): array { + $byte = strpos($source, $needle); + self::assertNotFalse($byte, "fixture needle '$needle' must exist"); + $byte += $offsetInNeedle; + [$line, $character] = (new PositionMap($source))->offsetToPosition($byte); + return $this->resolver($workspace)->complete($uri, $line, $character); + } + + private function resolver(PhpactorWorkspace $workspace): PhpCompletionResolver + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $cache = new ParsedDocumentCache(new Analyzer($parser)); + $workspaceSymbols = new \XPHP\Lsp\Handler\WorkspaceSymbols($workspace, $cache); + $completionIndex = new \XPHP\Lsp\Resolver\CompletionIndex( + $workspaceSymbols, + ReflectorFactory::defaultStubPath(), + ); + $fqnIndex = new \XPHP\Lsp\Reflection\FqnIndex($workspace, $cache, $parser, ''); + $reflector = (new ReflectorFactory( + $workspace, + $cache, + $parser, + rootPath: '', + stubPath: ReflectorFactory::defaultStubPath(), + cacheDir: ReflectorFactory::defaultCacheDir(), + fqnIndex: $fqnIndex, + ))->build(); + $classLikeLookup = new \XPHP\Lsp\Resolver\WorkspaceClassLikeLookup($workspace, $cache); + return new PhpCompletionResolver( + $workspace, + $parser, + $reflector, + $completionIndex, + $cache, + new \XPHP\Lsp\Resolver\GenericParamRegistry($fqnIndex), + new \XPHP\Lsp\Resolver\GenericResolver($workspace, $cache, $classLikeLookup, $parser, $fqnIndex), + ); + } + + private function workspace(): PhpactorWorkspace + { + return new PhpactorWorkspace(); + } + + private function open(PhpactorWorkspace $workspace, string $uri, string $source): void + { + $workspace->open(new TextDocumentItem($uri, 'xphp', 1, $source)); + } +} diff --git a/tools/lsp/test/Resolver/PhpDefinitionResolverTest.php b/tools/lsp/test/Resolver/PhpDefinitionResolverTest.php new file mode 100644 index 00000000..51e03bad --- /dev/null +++ b/tools/lsp/test/Resolver/PhpDefinitionResolverTest.php @@ -0,0 +1,439 @@ +workspace(); + $this->open($workspace, '/User.xphp', <<<'XPHP' + open($workspace, '/Use.xphp', $useSource); + + $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, 'use App\\Models\\User', strlen('use App\\Models\\')); + $this->assertResolves($location, '/User.xphp', 'User'); + } + + public function testJumpsFromNewCtorToClass(): void + { + $workspace = $this->workspace(); + $this->open($workspace, '/User.xphp', "open($workspace, '/Use.xphp', $useSource); + + $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, 'new User', strlen('new ')); + $this->assertResolves($location, '/User.xphp', 'User'); + } + + public function testJumpsFromTypeHintToClass(): void + { + $workspace = $this->workspace(); + $this->open($workspace, '/User.xphp', "open($workspace, '/Use.xphp', $useSource); + + $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, '(User ', 1); + $this->assertResolves($location, '/User.xphp', 'User'); + } + + public function testJumpsFromStaticCallToMethod(): void + { + $workspace = $this->workspace(); + $this->open($workspace, '/Util.xphp', <<<'XPHP' + open($workspace, '/Use.xphp', $useSource); + + $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, '::shout', 2); + $this->assertResolves($location, '/Util.xphp', 'shout'); + } + + public function testJumpsFromInstanceMethodCallToMethod(): void + { + // Inference comes from worse-reflection: `new User()->name(...)` + // -- the receiver type is inferred from the ctor call. + $workspace = $this->workspace(); + $this->open($workspace, '/User.xphp', <<<'XPHP' + name); } + } + XPHP); + $useSource = "shout();\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, '->shout', 2); + $this->assertResolves($location, '/User.xphp', 'shout'); + } + + public function testJumpsFromPropertyAccessToProperty(): void + { + $workspace = $this->workspace(); + $this->open($workspace, '/User.xphp', <<<'XPHP' + name;\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, '->name', 2); + $this->assertResolves($location, '/User.xphp', 'name'); + } + + public function testJumpsFromStaticPropertyAccessToProperty(): void + { + // Follow-up item 4: GTD on `Foo::$prop` should jump to the + // static property declaration, symmetric to instance-property + // GTD. worse-reflection's containerType on a StaticPropertyFetch + // resolves to the LHS class, so locateProperty already works. + $workspace = $this->workspace(); + $this->open($workspace, '/Counter.xphp', <<<'XPHP' + open($workspace, '/Use.xphp', $useSource); + + $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, '::$count', 2); + $this->assertResolves($location, '/Counter.xphp', 'count'); + } + + public function testJumpsFromUseFunctionImportToFunctionDeclaration(): void + { + // Regression: cursor on the function name inside a + // `use function App\foo;` statement should GTD to the function + // declaration. Worse-reflection misclassifies the imported + // name as Symbol::CLASS_ -- a fallback in PhpDefinitionResolver + // detects the AST context (Use_::TYPE_FUNCTION) and routes to + // locateFunction() instead. + $workspace = $this->workspace(); + $this->open($workspace, '/funcs.xphp', "open($workspace, '/Use.xphp', $useSource); + + // Cursor on `greet` inside `use function App\greet`. + $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, 'App\\greet', strlen('App\\')); + $this->assertResolves($location, '/funcs.xphp', 'greet'); + } + + public function testJumpsFromUseFunctionGroupImportToFunctionDeclaration(): void + { + $workspace = $this->workspace(); + $this->open($workspace, '/funcs.xphp', "open($workspace, '/Use.xphp', $useSource); + + // Cursor on `greet` inside `use function App\{greet, wave}`. + $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, '{greet', strlen('{')); + $this->assertResolves($location, '/funcs.xphp', 'greet'); + } + + public function testJumpsFromUserFunctionCallToFunctionDeclaration(): void + { + $workspace = $this->workspace(); + $this->open($workspace, '/funcs.xphp', "open($workspace, '/Use.xphp', $useSource); + + $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, 'echo greet', strlen('echo ')); + $this->assertResolves($location, '/funcs.xphp', 'greet'); + } + + public function testJumpsFromNativeFunctionCallToStub(): void + { + if (!is_dir(ReflectorFactory::defaultStubPath())) { + self::markTestSkipped('jetbrains/phpstorm-stubs not installed at expected path'); + } + $workspace = $this->workspace(); + $useSource = "open($workspace, '/Use.xphp', $useSource); + + $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, 'strlen', 1); + + self::assertNotNull($location, 'native function should resolve via stubs'); + self::assertStringContainsString('phpstorm-stubs', $location->uri); + } + + public function testJumpsFromStaticConstantToDeclaration(): void + { + $workspace = $this->workspace(); + $this->open($workspace, '/Cfg.xphp', <<<'XPHP' + open($workspace, '/Use.xphp', $useSource); + + $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, '::MAX', 2); + $this->assertResolves($location, '/Cfg.xphp', 'MAX'); + } + + public function testUnknownClassReturnsNull(): void + { + $workspace = $this->workspace(); + $useSource = "open($workspace, '/Use.xphp', $useSource); + + $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, 'TotallyUnknownClass', 1); + self::assertNull($location); + } + + public function testUnknownDocumentReturnsNull(): void + { + $resolver = $this->resolver($this->workspace()); + self::assertNull($resolver->resolve('/never-opened.xphp', 0, 0)); + } + + public function testPropertyAccessOnSubstitutedReceiverFromStaticCall(): void + { + // Originally a crash-safety test: pre-hotfix code crashed when + // dispatching with `containerType=MissingType` (from + // `$asUser = Util::identity(...)` whose return-type + // resolved to a bare `T` worse-reflection couldn't find). + // After Phase 1.2 (static-call substitution) + Phase 0.7 + // (property-receiver substitution), the chain now resolves + // correctly and GTD jumps to User::$name. + // + // No-crash guarantee is still in place (resolveInner has a + // top-level try/catch); this test now also asserts the + // positive resolution. + $workspace = $this->workspace(); + $this->open($workspace, '/Util.xphp', <<<'XPHP' + (T $x): T { return $x; } + } + XPHP); + $this->open($workspace, '/User.xphp', "(new User());\necho \$asUser->name;\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, '$asUser->name', strlen('$asUser->')); + + self::assertNotNull($location, 'GTD must resolve to User::$name through Phase 1.2 + 0.7'); + self::assertStringEndsWith('/User.xphp', $location->uri); + } + + public function testJumpsFromVariableUseToInitialAssignment(): void + { + // PhpStorm's native PHP GTD on a variable jumps to the variable's + // first introduction in the enclosing scope. We replicate that + // by walking the AST for the first Param / Assign / Foreach / Use + // node naming this variable. + $workspace = $this->workspace(); + $useSource = "open($workspace, '/Use.xphp', $useSource); + + $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, 'echo $x', strlen('echo $')); + + self::assertNotNull($location); + // The first `$x` lives on line 1 (0-indexed) at the start of the document. + self::assertSame(1, $location->range->start->line); + self::assertSame(0, $location->range->start->character); + } + + public function testJumpsFromVariableUseToFunctionParameter(): void + { + $workspace = $this->workspace(); + $useSource = "open($workspace, '/Use.xphp', $useSource); + + $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, 'return $name', strlen('return $')); + + self::assertNotNull($location); + // `$name` parameter is on line 1 (`function greet(string $name): string {`). + self::assertSame(1, $location->range->start->line); + } + + public function testJumpsFromVariableUseToForeachLoopVariable(): void + { + $workspace = $this->workspace(); + $useSource = "open($workspace, '/Use.xphp', $useSource); + + $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, 'echo $item', strlen('echo $')); + + self::assertNotNull($location); + // The loop's `$item` is on line 1, after `foreach ($items as `. + self::assertSame(1, $location->range->start->line); + } + + public function testJumpsFromVariableUseToClosureUseClause(): void + { + $workspace = $this->workspace(); + $useSource = "open($workspace, '/Use.xphp', $useSource); + + // Inside the closure body, $captured refers to the use-imported + // variable. Our resolver currently isn't scope-aware so it + // resolves to the FIRST introduction in document order -- the + // top-level assignment on line 1. That's still a useful jump + // and matches PhpStorm's PHP GTD when both sites are visible + // in the document. + $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, 'return $captured', strlen('return $')); + + self::assertNotNull($location); + self::assertSame(1, $location->range->start->line); + } + + public function testNeverIntroducedVariableReturnsNull(): void + { + // `$x` is used but never assigned / declared as a param. No + // introduction site -> null. + $workspace = $this->workspace(); + $useSource = "open($workspace, '/Use.xphp', $useSource); + + $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, 'echo $x', strlen('echo $')); + self::assertNull($location); + } + + public function testResolvesAcrossXphpSourceWithGenericClause(): void + { + // A use-statement reference in an xphp file with generic clauses + // elsewhere must still resolve. Validates that the strip pipeline + // doesn't break worse-reflection's view of the document. + $workspace = $this->workspace(); + $this->open($workspace, '/User.xphp', " { public T \$value; }\n\$u = new User();\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, 'new User', strlen('new ')); + $this->assertResolves($location, '/User.xphp', 'User'); + } + + public function testGotoDefinitionOnPropertyThroughChainedMethodCall(): void + { + // Phase 0.7: GTD on `$repo->first()?->name` jumps to User::$name + // by way of substituting `?T` -> `?User` on the receiver. + $workspace = $this->workspace(); + $this->open($workspace, '/Repository.xphp', <<<'XPHP' + { + public function first(): ?T { return null; } + } + XPHP); + $this->open($workspace, '/User.xphp', "();\necho \$repo->first()?->name;\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, '?->name', strlen('?->')); + + self::assertNotNull($location); + self::assertStringEndsWith('/User.xphp', $location->uri); + } + + public function testGotoDefinitionOnPropertyThroughTrackedVariable(): void + { + // GTD on `$user?->name` where `$user` came from a chained + // method call. Hits the Variable branch of inferType. + $workspace = $this->workspace(); + $this->open($workspace, '/Repository.xphp', <<<'XPHP' + { + public function first(): ?T { return null; } + } + XPHP); + $this->open($workspace, '/User.xphp', "();\n\$user = \$repo->first();\necho \$user?->name;\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, '?->name', strlen('?->')); + + self::assertNotNull($location); + self::assertStringEndsWith('/User.xphp', $location->uri); + } + + private function resolveAt( + PhpactorWorkspace $workspace, + string $uri, + string $source, + string $needle, + int $offsetInNeedle, + ): ?Location { + $byte = strpos($source, $needle); + self::assertNotFalse($byte, "fixture needle '$needle' must exist"); + $byte += $offsetInNeedle; + [$line, $character] = (new PositionMap($source))->offsetToPosition($byte); + return $this->resolver($workspace)->resolve($uri, $line, $character); + } + + private function assertResolves(?Location $location, string $expectedUriSuffix, string $expectedSymbol): void + { + self::assertNotNull($location, "expected a location for '$expectedSymbol'"); + self::assertStringEndsWith($expectedUriSuffix, $location->uri); + // Spot-check the range looks like a name target -- a few characters + // wide, not the full body. We can't assert exact line/col without + // hard-coding fixture geometry, so check the basic shape. + self::assertGreaterThanOrEqual(0, $location->range->start->line); + self::assertGreaterThanOrEqual(0, $location->range->start->character); + self::assertLessThanOrEqual(80, $location->range->end->character - $location->range->start->character); + } + + private function resolver(PhpactorWorkspace $workspace): PhpDefinitionResolver + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $cache = new ParsedDocumentCache(new Analyzer($parser)); + $fqnIndex = new \XPHP\Lsp\Reflection\FqnIndex($workspace, $cache, $parser, ''); + $reflector = (new ReflectorFactory( + $workspace, + $cache, + $parser, + rootPath: '', + stubPath: ReflectorFactory::defaultStubPath(), + cacheDir: ReflectorFactory::defaultCacheDir(), + fqnIndex: $fqnIndex, + ))->build(); + $classLikeLookup = new \XPHP\Lsp\Resolver\WorkspaceClassLikeLookup($workspace, $cache); + $generic = new \XPHP\Lsp\Resolver\GenericResolver($workspace, $cache, $classLikeLookup, $parser, $fqnIndex); + return new PhpDefinitionResolver($workspace, $parser, $reflector, $cache, $generic); + } + + private function workspace(): PhpactorWorkspace + { + return new PhpactorWorkspace(); + } + + private function open(PhpactorWorkspace $workspace, string $uri, string $source): void + { + $workspace->open(new TextDocumentItem($uri, 'xphp', 1, $source)); + } +} diff --git a/tools/lsp/test/Resolver/PhpHoverResolverTest.php b/tools/lsp/test/Resolver/PhpHoverResolverTest.php new file mode 100644 index 00000000..a7536d41 --- /dev/null +++ b/tools/lsp/test/Resolver/PhpHoverResolverTest.php @@ -0,0 +1,851 @@ +workspace(); + $this->open($workspace, '/User.xphp', "open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, 'new User', 4); + + $markdown = $this->markdown($hover); + self::assertStringContainsString('class App\\User', $markdown); + self::assertStringContainsString('A user.', $markdown); + } + + public function testHoversUserFunctionWithSignature(): void + { + $workspace = $this->workspace(); + $this->open($workspace, '/fn.xphp', "open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, 'echo greet', strlen('echo ')); + + $markdown = $this->markdown($hover); + self::assertStringContainsString('function App\\greet', $markdown); + self::assertStringContainsString('Greet someone', $markdown); + } + + public function testHoversMethodWithReceiverContext(): void + { + $workspace = $this->workspace(); + $this->open($workspace, '/User.xphp', <<<'XPHP' + name); } + } + XPHP); + $useSource = "shout();\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, '->shout', 2); + + $markdown = $this->markdown($hover); + self::assertStringContainsString('function shout', $markdown); + // The class FQN appears as context above the signature. + self::assertStringContainsString('App\\User', $markdown); + } + + public function testHoversPropertyWithReceiverContext(): void + { + $workspace = $this->workspace(); + $this->open($workspace, '/User.xphp', <<<'XPHP' + name;\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, '->name', 2); + + $markdown = $this->markdown($hover); + self::assertStringContainsString('$name', $markdown); + } + + public function testMethodHoverSubstitutesParameterTypesAtCallSite(): void + { + // Phase 0.6: cursor on a method call whose receiver is a tracked + // generic-instantiated variable -- the method's parameter types + // get substituted with the receiver's type-arg bindings. + // Before this commit, hover on `$users->save($user)` would show + // `save(T $item): void`; after Phase 0.6, it shows + // `save(App\Models\User $item): void`. + $workspace = $this->workspace(); + $this->open($workspace, '/Collection.xphp', <<<'XPHP' + { + public function save(T $item): void {} + public function first(): ?T { return null; } + } + XPHP); + $this->open($workspace, '/User.xphp', "();\n\$users->save(new User());\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, '$users->save', strlen('$users->save')); + $markdown = $this->markdown($hover); + + self::assertStringContainsString('save(App\\Models\\User $item)', $markdown); + self::assertStringNotContainsString('save(T $item)', $markdown); + } + + public function testMethodHoverSubstitutesMultipleParameters(): void + { + // Pair::put(K, V): each param gets a different substituted type. + $workspace = $this->workspace(); + $this->open($workspace, '/Pair.xphp', <<<'XPHP' + { + public function put(K $key, V $value): void {} + } + XPHP); + $this->open($workspace, '/User.xphp', "();\n\$p->put('x', new User());\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, '$p->put', strlen('$p->put')); + $markdown = $this->markdown($hover); + + // Both params substituted. + self::assertStringContainsString('put(string $key, App\\Models\\User $value)', $markdown); + // Neither placeholder leaks through. + self::assertStringNotContainsString('K $key', $markdown); + self::assertStringNotContainsString('V $value', $markdown); + } + + public function testStaticMethodHoverSubstitutesParameterTypesAtCallSite(): void + { + // Item 5: static-call param substitution. Symmetric to the + // instance-method Phase 0.6 path, exercising the same machinery + // through `resolveStaticCallSubstitutionAt`. + $workspace = $this->workspace(); + $this->open($workspace, '/Factory.xphp', <<<'XPHP' + (T $seed): T { return $seed; } + } + XPHP); + $this->open($workspace, '/User.xphp', "(new User());\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, 'Factory::make', strlen('Factory::make')); + $markdown = $this->markdown($hover); + + self::assertStringContainsString('make(App\\Models\\User $seed)', $markdown); + self::assertStringNotContainsString('make(T $seed)', $markdown); + } + + public function testFreeFunctionHoverSubstitutesParameterTypesAtCallSite(): void + { + // Item 5: free-function param substitution. Reaches the same + // substitution path through `resolveFunctionCallSubstitutionAt`, + // bridging into `renderFunction`'s new substitution-aware + // signature. + $workspace = $this->workspace(); + $this->open($workspace, '/identity.xphp', <<<'XPHP' + (T $value): T { return $value; } + XPHP); + $this->open($workspace, '/User.xphp', "(new User());\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, 'identity', strlen('identity')); + $markdown = $this->markdown($hover); + + self::assertStringContainsString('identity(App\\Models\\User $value)', $markdown); + self::assertStringNotContainsString('identity(T $value)', $markdown); + // Return type also gets substituted. + self::assertStringContainsString(': App\\Models\\User', $markdown); + } + + public function testFunctionDeclarationHoverStripsNamespaceFromMethodScopeTemplate(): void + { + // Hover at a call site of a generic free function WITHOUT a type + // argument: substitution path returns null, prettify fallback + // runs. Without function-scope template tracking, worse-reflection's + // namespace-doubled `App\Demos\T` leaks through; with it, the + // prefix is stripped to bare `T`. + $workspace = $this->workspace(); + $declSource = "(T \$x): T { return \$x; }\n"; + $this->open($workspace, '/identity.xphp', $declSource); + + $useSource = "open($workspace, '/Use.xphp', $useSource); + // Cursor on the unqualified call `identity(...)` -- no `` arg, + // no inference path, so renderFunction runs without a substitution. + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, "\nidentity(", strlen("\nidentity")); + $markdown = $this->markdown($hover); + + self::assertStringContainsString('identity(T $x): T', $markdown); + self::assertStringNotContainsString('App\\Demos\\T', $markdown); + } + + public function testStaticMethodDeclarationHoverStripsNamespaceFromMethodScopeTemplate(): void + { + // Same gap, method-scope side: `Util::first(...)` declared in + // `namespace App\Containers`. Bare `T` in the body resolves to + // `App\Containers\T`; prettify must strip back to `T`. + $workspace = $this->workspace(); + $this->open($workspace, '/Util.xphp', <<<'XPHP' + (array $items): ?T { return $items[0] ?? null; } + } + XPHP); + $useSource = "open($workspace, '/Use.xphp', $useSource); + // Hover on `first` without a `` type-arg -> substitution path + // returns null, prettify fallback runs. + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, 'Util::first', strlen('Util::first')); + $markdown = $this->markdown($hover); + + self::assertStringContainsString('first(array $items): ?T', $markdown); + self::assertStringNotContainsString('App\\Containers\\T', $markdown); + } + + public function testMethodHoverParamsFallBackToPrettifyWhenNoBinding(): void + { + // Cursor on a method call where no generic-instantiation binding + // is in scope (a closed-over receiver, say). Substitution can't + // help; renderMethod falls back to prettify, which strips the + // namespace from the placeholder. Result: `T $item` (NOT + // `App\Containers\T $item`, NOT the substituted form either -- + // because there's no binding to substitute with). + $workspace = $this->workspace(); + $this->open($workspace, '/Collection.xphp', <<<'XPHP' + { + public function save(T $item): void {} + } + XPHP); + // No `new Collection(...)` in scope -- just hovering a + // method call on a param-typed-without-generics receiver. + $useSource = "save('x');\n}\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, '$c->save', strlen('$c->save')); + $markdown = $this->markdown($hover); + + self::assertStringContainsString('save(T $item)', $markdown); + self::assertStringNotContainsString('App\\Containers\\T', $markdown); + } + + public function testPropertyHoverThroughChainedMethodCall(): void + { + // Phase 0.7 headline: `$repo->first()?->name` where + // `Repository::first(): ?T` and `$repo: Repository`. + // Property hover at `name` should resolve to User's `$name` + // (not return null as it did before this phase). + $workspace = $this->workspace(); + $this->open($workspace, '/Repository.xphp', <<<'XPHP' + { + public function first(): ?T { return null; } + } + XPHP); + $this->open($workspace, '/User.xphp', <<<'XPHP' + ();\necho \$repo->first()?->name;\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, '?->name', strlen('?->')); + $markdown = $this->markdown($hover); + + self::assertStringContainsString('$name', $markdown); + self::assertStringContainsString('App\\Models\\User', $markdown); + self::assertStringContainsString('The displayed name.', $markdown); + } + + public function testPropertyHoverThroughDirectVariableReceiver(): void + { + // Variant: `$user->name` where `$user` is a tracked variable + // assigned from a chained method call. The receiver is a + // Variable, not a chained call -- ensures inferType handles + // both shapes. + $workspace = $this->workspace(); + $this->open($workspace, '/Repository.xphp', <<<'XPHP' + { + public function first(): ?T { return null; } + } + XPHP); + $this->open($workspace, '/User.xphp', "();\n\$user = \$repo->first();\necho \$user?->name;\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, '?->name', strlen('?->')); + $markdown = $this->markdown($hover); + + self::assertStringContainsString('$name', $markdown); + self::assertStringContainsString('App\\Models\\User', $markdown); + } + + public function testHoverOnFunctionDeclarationNameShowsSignature(): void + { + // Regression: cursor on the function name in its OWN declaration + // (`function originalCount(...)`) previously returned null + // because worse-reflection has no useful symbol classification + // for the declaration name token. AST-based fallback now + // identifies the enclosing Function_ and renders its signature. + $workspace = $this->workspace(); + $useSource = "open($workspace, '/funcs.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/funcs.xphp', $useSource, 'originalCount', 3); + + $markdown = $this->markdown($hover); + self::assertStringContainsString('originalCount', $markdown); + self::assertStringContainsString('Counts items', $markdown); + } + + public function testHoverOnClassDeclarationNameShowsSignature(): void + { + $workspace = $this->workspace(); + $useSource = "open($workspace, '/Widget.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Widget.xphp', $useSource, 'class Widget', strlen('class ')); + + $markdown = $this->markdown($hover); + self::assertStringContainsString('Widget', $markdown); + self::assertStringContainsString('A widget', $markdown); + } + + public function testHoverOnMethodDeclarationNameShowsSignature(): void + { + $workspace = $this->workspace(); + $useSource = "open($workspace, '/Widget.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Widget.xphp', $useSource, 'function shout', strlen('function ')); + + $markdown = $this->markdown($hover); + self::assertStringContainsString('shout', $markdown); + self::assertStringContainsString('Shouts loudly', $markdown); + } + + public function testHoverInsideUseFunctionImportShowsFunctionSignature(): void + { + // Regression: cursor on the function name inside + // `use function App\foo;` should show the function signature. + // Worse-reflection misclassifies the imported name as + // Symbol::CLASS_ -- the AST-context override routes to + // renderFunction() instead so the hover surfaces the actual + // signature + docblock. + $workspace = $this->workspace(); + $this->open($workspace, '/funcs.xphp', <<<'XPHP' + open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, 'App\\greet', strlen('App\\')); + + $markdown = $this->markdown($hover); + self::assertStringContainsString('greet', $markdown); + self::assertStringContainsString('Greet someone', $markdown, 'docblock must surface'); + } + + public function testStaticPropertyHoverShowsType(): void + { + // Follow-up item 4: cursor on `Foo::$prop` should render the + // property type / docblock, symmetric to instance-property + // hover. worse-reflection's containerType resolves to the + // class on the LHS of `::`, so the existing renderProperty + // path should work without modification. + $workspace = $this->workspace(); + $this->open($workspace, '/Counter.xphp', <<<'XPHP' + open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, '::$count', 2); + + $markdown = $this->markdown($hover); + self::assertStringContainsString('$count', $markdown); + self::assertStringContainsString('static', $markdown, 'static modifier must appear in the rendered signature'); + } + + public function testPropertyHoverFallsBackToWorseReflectionWhenNoBinding(): void + { + // Boundary lock: when no binding is in scope, the resolver + // returns null and worse-reflection's containerType takes over. + // For non-generic receivers worse-reflection already works, so + // this should still render the property. + $workspace = $this->workspace(); + $this->open($workspace, '/User.xphp', <<<'XPHP' + name;\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, '->name', 2); + $markdown = $this->markdown($hover); + + self::assertStringContainsString('$name', $markdown); + } + + public function testHoversNativeFunctionFromStubs(): void + { + if (!is_dir(ReflectorFactory::defaultStubPath())) { + self::markTestSkipped('jetbrains/phpstorm-stubs not installed'); + } + $workspace = $this->workspace(); + $useSource = "open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, 'strlen', 1); + + $markdown = $this->markdown($hover); + self::assertStringContainsString('function strlen', $markdown); + } + + public function testHoversVariableWithInferredScalarType(): void + { + $workspace = $this->workspace(); + $useSource = "open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, 'echo $x', strlen('echo ')); + $markdown = $this->markdown($hover); + self::assertStringContainsString('int', $markdown); + self::assertStringContainsString('$x', $markdown); + } + + public function testHoversVariableWithInferredClassType(): void + { + // The exact gap noted in xphp-20260524-204801-302.log id=11: + // hover on `$users` showed null because we didn't dispatch + // Symbol::VARIABLE. Now we render the inferred type. + $workspace = $this->workspace(); + $this->open($workspace, '/User.xphp', "open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, 'echo $u', strlen('echo ')); + $markdown = $this->markdown($hover); + self::assertStringContainsString('App\\User', $markdown); + self::assertStringContainsString('$u', $markdown); + } + + public function testVariableHoverSubstitutesGenericReturnType(): void + { + // The user's exact production case: `$user` is assigned from a + // generic method whose return type involves the type-param `T`, + // and the receiver was instantiated as `Collection` -- + // so hovering `$user` must show `?App\Models\User $user`, NOT + // `?T $user` (the unresolved placeholder). + $workspace = $this->workspace(); + $this->open($workspace, '/Collection.xphp', <<<'XPHP' + { + public function first(): ?T { return null; } + } + XPHP); + $this->open($workspace, '/User.xphp', "();\n\$user = \$users->first();\necho \$user;\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, 'echo $user', strlen('echo ')); + $markdown = $this->markdown($hover); + + // Substituted form is present. + self::assertStringContainsString('?App\\Models\\User $user', $markdown); + // Neither the placeholder nor its qualified form leaked through. + self::assertStringNotContainsString('?T $user', $markdown); + self::assertStringNotContainsString('App\\Containers\\T', $markdown); + } + + public function testMethodHoverSubstitutesReturnTypeAtCallSite(): void + { + // Cursor on the `first` token in `$users->first()` -- the method + // hover signature should reflect `Collection`'s binding and + // show `?App\Models\User` rather than `?T`. Parallel to the + // variable-hover fix but applied one statement earlier in the chain. + $workspace = $this->workspace(); + $this->open($workspace, '/Collection.xphp', <<<'XPHP' + { + public function first(): ?T { return null; } + } + XPHP); + $this->open($workspace, '/User.xphp', "();\n\$user = \$users->first();\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, '$users->first', strlen('$users->first')); + $markdown = $this->markdown($hover); + + self::assertStringContainsString('): ?App\\Models\\User', $markdown); + self::assertStringNotContainsString('): ?T', $markdown); + self::assertStringNotContainsString('App\\Containers\\T', $markdown); + } + + public function testParamTypedScopeEntrySubstitutesInFunctionBody(): void + { + // Phase 1.1 e2e: cursor on a variable assigned from a method call + // INSIDE a function whose parameter is generic-typed -- the param + // type seeds the binding, the method call substitutes through it. + $workspace = $this->workspace(); + $this->open($workspace, '/Collection.xphp', <<<'XPHP' + { + public function first(): ?T { return null; } + } + XPHP); + $this->open($workspace, '/User.xphp', " \$users) {\n \$first = \$users->first();\n echo \$first;\n}\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, 'echo $first', strlen('echo ')); + $markdown = $this->markdown($hover); + + self::assertStringContainsString('?App\\Models\\User $first', $markdown); + self::assertStringNotContainsString('?T $first', $markdown); + } + + public function testClassNameHoverResolvesAgainstFilesystemOnlyTarget(): void + { + // Phase 2.3: hover on a class identifier (`new Box()`) where the + // class declaration lives only on disk -- never opened in the + // editor. Worse-reflection reaches it through FilesystemSourceLocator + // -> FqnIndex. Before Phase 0, this returned null because the + // open-doc workspace was the only source. + $root = sys_get_temp_dir() . '/xphp-hover-fs-' . bin2hex(random_bytes(6)); + mkdir($root, 0o755, true); + try { + file_put_contents($root . '/Box.xphp', <<<'XPHP' + workspace(); + $useSource = "open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAtWithRoot($workspace, '/Use.xphp', $useSource, 'new Box', strlen('new '), $root); + $markdown = $this->markdown($hover); + + self::assertStringContainsString('class App\\Containers\\Box', $markdown); + } finally { + $this->rmrf($root); + } + } + + public function testMethodHoverResolvesAgainstFilesystemOnlyClass(): void + { + // Phase 2.3: hover on a method call when the receiver's class + // declaration lives only on disk. The hover dispatch reaches + // through worse-reflection's offset reflection which in turn + // consults FilesystemSourceLocator (the FqnIndex-backed adapter). + $root = sys_get_temp_dir() . '/xphp-hover-fs-' . bin2hex(random_bytes(6)); + mkdir($root, 0o755, true); + try { + file_put_contents($root . '/Greeter.xphp', <<<'XPHP' + workspace(); + $useSource = "hello('x');\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAtWithRoot($workspace, '/Use.xphp', $useSource, '$g->hello', strlen('$g->'), $root); + $markdown = $this->markdown($hover); + + self::assertStringContainsString('hello(string $name): string', $markdown); + } finally { + $this->rmrf($root); + } + } + + public function testVariableHoverPrettifyWorksWithFilesystemOnlyGenericClass(): void + { + // Phase 0.5 e2e: Collection.xphp is closed (only on disk). + // GenericResolver can't substitute the chain (no binding in scope), + // so the fallback path goes through worse-reflection + prettify. + // Before Phase 0.5, prettify saw no `Collection` in open docs + // and left `?App\Containers\T` un-stripped. Now FqnIndex's + // filesystem index contributes the placeholder pair. + $root = sys_get_temp_dir() . '/xphp-hover-fs-' . bin2hex(random_bytes(6)); + mkdir($root, 0o755, true); + try { + file_put_contents($root . '/Collection.xphp', <<<'XPHP' + { + public function first(): ?T { return null; } + } + XPHP); + + $workspace = $this->workspace(); + // Closure-captured variable so GenericResolver doesn't model + // it (closure body sees an isolated scope without the outer + // binding flowing through here -- mimicking the production + // scenario where the resolver returned null and prettify + // had to handle rendering). + $useSource = "first();\n echo \$x;\n}\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAtWithRoot($workspace, '/Use.xphp', $useSource, 'echo $x', strlen('echo '), $root); + $markdown = $this->markdown($hover); + + // The placeholder must be stripped to `?T`, not surface as + // `?App\Containers\T` -- which is the un-prettified form that + // production showed before Phase 0.5. + self::assertStringNotContainsString( + '?App\\Containers\\T', + $markdown, + 'Phase 0.5 must strip placeholder namespace even when Collection.xphp is closed', + ); + self::assertStringContainsString('?T $x', $markdown); + } finally { + $this->rmrf($root); + } + } + + public function testVariableHoverFallsBackToPrettifyForUnmodeledShapes(): void + { + // GenericResolver only handles same-file `new Generic<...>()` + + // `$var = $other->method()` chains. For shapes it doesn't + // model (here: a bare variable whose worse-reflection-inferred + // type still carries a generic placeholder, with NO `new` + // assignment in scope to bind against), the fallback path + // through GenericParamRegistry::prettify continues to strip the + // namespace and produce `?T`. + $workspace = $this->workspace(); + $this->open($workspace, '/Collection.xphp', <<<'XPHP' + { + public function first(): ?T { return null; } + public function getMaybeFirst(?T $fallback): ?T { return $fallback; } + } + XPHP); + // No `new Collection(...)` in this snippet: `$x` is a + // closure parameter we can't trace. GenericResolver returns null, + // worse-reflection surfaces `?App\Containers\T`, prettify strips + // the namespace. + $useSource = "first(); echo \$x; };\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, 'echo $x', strlen('echo ')); + $markdown = $this->markdown($hover); + + self::assertStringNotContainsString('App\\Containers\\T', $markdown); + self::assertStringContainsString('?T $x', $markdown); + } + + public function testReturnsNullOnVariableWithNoInferableType(): void + { + // Undeclared variable referenced bare -- worse-reflection has + // nothing to infer, so we suppress the hover rather than show + // an empty tooltip. + $workspace = $this->workspace(); + $useSource = "open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, 'echo $undeclared', strlen('echo ')); + self::assertNull($hover); + } + + public function testReturnsNullForUnknownDocument(): void + { + $resolver = $this->resolver($this->workspace()); + self::assertNull($resolver->resolve('/never-opened.xphp', 0, 0)); + } + + public function testPropertyHoverOnSubstitutedReceiverFromStaticCall(): void + { + // This test originally asserted null because pre-Phase-1.2 the + // static call `Util::identity(...)` couldn't substitute, + // and pre-Phase-0.7 the property hover couldn't find User. Now + // both work in combination: the static call binds `$asUser` to + // `App\User`, and the property hover at `$asUser->name` + // consults GenericResolver to find User's `$name` property. + // + // The hover still doesn't crash on MissingType -- that + // robustness check is now covered by the per-symbol catch in + // resolveInner and the null-check in renderProperty. + $workspace = $this->workspace(); + $this->open($workspace, '/Util.xphp', <<<'XPHP' + (T $x): T { return $x; } + } + XPHP); + $this->open($workspace, '/User.xphp', "(new User());\necho \$asUser->name;\n"; + $this->open($workspace, '/Use.xphp', $useSource); + + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, '$asUser->name', strlen('$asUser->')); + $markdown = $this->markdown($hover); + + self::assertStringContainsString('$name', $markdown); + self::assertStringContainsString('App\\User', $markdown); + } + + private function hoverAt( + PhpactorWorkspace $workspace, + string $uri, + string $source, + string $needle, + int $offsetInNeedle, + ): ?Hover { + $byte = strpos($source, $needle); + self::assertNotFalse($byte, "fixture needle '$needle' must exist"); + $byte += $offsetInNeedle; + [$line, $character] = (new PositionMap($source))->offsetToPosition($byte); + return $this->resolver($workspace)->resolve($uri, $line, $character); + } + + private function hoverAtWithRoot( + PhpactorWorkspace $workspace, + string $uri, + string $source, + string $needle, + int $offsetInNeedle, + string $rootPath, + ): ?Hover { + $byte = strpos($source, $needle); + self::assertNotFalse($byte, "fixture needle '$needle' must exist"); + $byte += $offsetInNeedle; + [$line, $character] = (new PositionMap($source))->offsetToPosition($byte); + return $this->resolverWithRoot($workspace, $rootPath)->resolve($uri, $line, $character); + } + + private function rmrf(string $dir): void + { + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $p = $dir . '/' . $entry; + if (is_dir($p)) { + $this->rmrf($p); + } else { + unlink($p); + } + } + rmdir($dir); + } + + private function markdown(?Hover $hover): string + { + self::assertNotNull($hover); + $content = $hover->contents; + self::assertInstanceOf(MarkupContent::class, $content); + return $content->value; + } + + private function resolver(PhpactorWorkspace $workspace): PhpHoverResolver + { + return $this->resolverWithRoot($workspace, ''); + } + + private function resolverWithRoot(PhpactorWorkspace $workspace, string $rootPath): PhpHoverResolver + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $cache = new ParsedDocumentCache(new Analyzer($parser)); + $reflector = (new ReflectorFactory( + $workspace, + $cache, + $parser, + rootPath: $rootPath, + stubPath: ReflectorFactory::defaultStubPath(), + cacheDir: ReflectorFactory::defaultCacheDir(), + fqnIndex: $fqnIndex = new \XPHP\Lsp\Reflection\FqnIndex($workspace, $cache, $parser, $rootPath), + ))->build(); + $classLikeLookup = new \XPHP\Lsp\Resolver\CompositeClassLikeLookup( + new WorkspaceClassLikeLookup($workspace, $cache), + new \XPHP\Lsp\Resolver\FilesystemClassLikeLookup($fqnIndex), + ); + $generic = new GenericResolver($workspace, $cache, $classLikeLookup, $parser, $fqnIndex); + return new PhpHoverResolver( + $workspace, + $parser, + $reflector, + new GenericParamRegistry($fqnIndex), + $generic, + ); + } + + private function workspace(): PhpactorWorkspace + { + return new PhpactorWorkspace(); + } + + private function open(PhpactorWorkspace $workspace, string $uri, string $source): void + { + $workspace->open(new TextDocumentItem($uri, 'xphp', 1, $source)); + } +} diff --git a/tools/lsp/test/Resolver/StubsIndexTest.php b/tools/lsp/test/Resolver/StubsIndexTest.php new file mode 100644 index 00000000..29af3ded --- /dev/null +++ b/tools/lsp/test/Resolver/StubsIndexTest.php @@ -0,0 +1,113 @@ +dir = sys_get_temp_dir() . '/xphp-si-' . bin2hex(random_bytes(6)); + mkdir($this->dir . '/Reflection', 0o755, true); + mkdir($this->dir . '/standard', 0o755, true); + } + + protected function tearDown(): void + { + if (is_dir($this->dir)) { + $this->rmrf($this->dir); + } + } + + public function testCollectsTopLevelFunctionsAndClassesFromAllNestedFiles(): void + { + file_put_contents($this->dir . '/Reflection/ReflectionMethod.php', "dir . '/standard/strings.php', "dir); + + self::assertContains('ReflectionMethod', $index->classes); + self::assertContains('ReflectionParameter', $index->classes); + self::assertContains('strlen', $index->functions); + self::assertContains('str_replace', $index->functions); + } + + public function testCollectsNamespacedDeclarations(): void + { + file_put_contents( + $this->dir . '/namespaced.php', + "dir); + self::assertContains('Foo\\Bar\\Baz', $index->classes); + self::assertContains('Foo\\Bar\\qux', $index->functions); + } + + public function testPersistsJsonCacheAndShortCircuitsOnSecondCall(): void + { + file_put_contents($this->dir . '/x.php', "dir); + self::assertContains('first', $first->functions); + self::assertFileExists($this->dir . '/' . StubsIndex::INDEX_FILENAME); + + // Mutate the JSON to prove the second call READS from cache + // rather than re-scanning. If the second call re-scanned, it + // would pick up `first()` from x.php again -- but instead it + // returns what's in the JSON. + file_put_contents( + $this->dir . '/' . StubsIndex::INDEX_FILENAME, + json_encode(['functions' => ['mutated_marker'], 'classes' => []]), + ); + + $second = StubsIndex::loadOrBuild($this->dir); + self::assertSame(['mutated_marker'], $second->functions); + self::assertSame([], $second->classes); + } + + public function testIgnoresUnparseableFilesGracefully(): void + { + // A stub file that fails to parse must not prevent collection + // of valid declarations in sibling files. + file_put_contents($this->dir . '/broken.php', "dir . '/good.php', "dir); + self::assertContains('good', $index->functions); + } + + public function testReturnsEmptyForMissingDirectory(): void + { + $index = StubsIndex::loadOrBuild('/path/that/definitely/does/not/exist'); + self::assertSame([], $index->functions); + self::assertSame([], $index->classes); + } + + public function testRebuildsWhenJsonIsCorrupted(): void + { + file_put_contents($this->dir . '/y.php', "dir . '/' . StubsIndex::INDEX_FILENAME, 'not valid json'); + + $index = StubsIndex::loadOrBuild($this->dir); + self::assertContains('Y', $index->classes); + } + + private function rmrf(string $dir): void + { + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $p = $dir . '/' . $entry; + is_dir($p) ? $this->rmrf($p) : unlink($p); + } + rmdir($dir); + } +} diff --git a/tools/lsp/vscode-extension/syntaxes/xphp.tmLanguage.json b/tools/lsp/vscode-extension/syntaxes/xphp.tmLanguage.json index 30701757..c959c500 100644 --- a/tools/lsp/vscode-extension/syntaxes/xphp.tmLanguage.json +++ b/tools/lsp/vscode-extension/syntaxes/xphp.tmLanguage.json @@ -2,6 +2,7 @@ "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", "name": "xphp", "scopeName": "source.xphp", + "fileTypes": ["xphp"], "patterns": [ { "include": "#xphp-extras" @@ -14,9 +15,9 @@ "xphp-extras": { "patterns": [ { - "comment": "Generic argument clause after a class-like name: `Box<...>`. Highlights the angle brackets as operators so they stand out from the bundled PHP grammar that would otherwise miss them.", + "comment": "Generic argument clause after a class-like name: `Box<...>`. Highlights the angle brackets as operators so they stand out from the bundled PHP grammar that would otherwise miss them. The look-behind is intentionally single-character (fixed-length) because IntelliJ's TextMate plugin uses Joni, which rejects variable-length look-behinds (a `*`-quantified character class fails to compile, the whole rule is skipped, and generic-clause highlighting silently doesn't fire). VS Code's Oniguruma accepts both forms; a single-char identifier-class match here is enough to distinguish `Box<` from a comparison ` < `.", "name": "meta.generic.xphp", - "begin": "(?<=[A-Za-z_\\\\][A-Za-z0-9_\\\\]*)\\s*(<)(?=[A-Za-z_\\\\\\s])", + "begin": "(?<=[A-Za-z0-9_\\\\])\\s*(<)(?=[A-Za-z_\\\\\\s])", "end": "(>)", "beginCaptures": { "1": { "name": "punctuation.definition.generic.begin.xphp" } diff --git a/tools/phpstorm-plugin/.gitignore b/tools/phpstorm-plugin/.gitignore new file mode 100644 index 00000000..60834218 --- /dev/null +++ b/tools/phpstorm-plugin/.gitignore @@ -0,0 +1,18 @@ +# Gradle artifacts. +.gradle/ +build/ + +# IntelliJ Platform Gradle Plugin local artifact cache + plugin sandbox -- +# generated by `prepareSandbox` / `prepareTestSandbox` and the IDE-installer +# resolution machinery. Regenerated on demand by `./gradlew build`. +.intellijPlatform/ + +# IDE workspace. +.idea/ +*.iml + +# Generated resources -- chunk 3 onward copies tmLanguage.json + chunk 5 copies +# the xphp-lsp PHAR into src/main/resources/ at build time. Source of truth +# is tools/lsp/, not a checked-in duplicate here. +src/main/resources/textmate/xphp.tmLanguage.json +src/main/resources/bin/xphp-lsp.phar diff --git a/tools/phpstorm-plugin/Makefile b/tools/phpstorm-plugin/Makefile new file mode 100644 index 00000000..baf9fe3f --- /dev/null +++ b/tools/phpstorm-plugin/Makefile @@ -0,0 +1,95 @@ +# Makefile for the xphp PhpStorm plugin. +# +# Mirrors the per-package shape from tools/lsp/Makefile -- short, package- +# relative target names, no root delegation. Run from this directory with +# `make ` or from the repo root via `make -C tools/phpstorm-plugin +# `. See CONTRIBUTING.md "Adding a new package" for the convention. +# +# Dev (local) gradle invocation routes through the `jdk` service declared +# in docker-compose.override.yml at the repo root: no host JDK install is +# required for contributors (or supported -- the build is reproducible +# against the exact JDK + X11 stack inside the container). Rootless +# docker maps the container's UID back to the host user, so build outputs +# come out owned by you and don't need a follow-up chown. +# +# CI takes the other branch: GitHub Actions installs JDK 21 via +# actions/setup-java, and the runner has no docker compose stack. When +# `CI=true` (GitHub Actions sets this by default) the Makefile uses the +# host-side gradlew so the same targets work in both environments. +# Local overrides also welcome: `make CI=1 build` forces host-side too. +# +# `--project-directory ../..` makes `docker compose` see the repo-root +# compose files without an actual `cd`, so $(MAKE)'s working directory +# (this package's dir) stays unchanged. `-p tools/phpstorm-plugin` tells +# gradle which sub-project to act on, since the container's working_dir +# is the repo root (/opt/app), not the plugin package. +ifdef CI +# Make's working directory is `tools/phpstorm-plugin/` (the package +# dir), so `./gradlew` resolves against it; no `-p ` needed. +GRADLEW := ./gradlew +GRADLE_OPTS := +else +# Inside the jdk container the working_dir is `/opt/app` (repo root), +# so the path to the wrapper AND the `-p ` are relative to that. +GRADLEW := docker compose --project-directory ../.. run --rm jdk \ + tools/phpstorm-plugin/gradlew +GRADLE_OPTS := -p tools/phpstorm-plugin +endif + +.PHONY: build +build: + $(GRADLEW) $(GRADLE_OPTS) build + +.PHONY: test +test: + $(GRADLEW) $(GRADLE_OPTS) test + +.PHONY: verify +verify: + # Plugin Verifier compatibility check. Catches binary-incompat issues + # (removed/changed IntelliJ Platform APIs) against the IDE matrix the + # IntelliJ Platform Gradle Plugin's `pluginVerification.ides {}` block + # resolves -- currently the `recommended()` set for since-build 261. + $(GRADLEW) $(GRADLE_OPTS) verifyPlugin + +.PHONY: run-ide +run-ide: x11-grant + # Boots a sandboxed PhpStorm with this plugin loaded. Used for the + # manual smoke tests: open a .xphp file, confirm diagnostics / hover / + # go-to-definition / completion. Sandbox is fully isolated from any + # PhpStorm install on the host (separate config/system dirs, separate + # JVM process); safe to run alongside your daily IDE. + $(GRADLEW) $(GRADLE_OPTS) runIde + +# Allow the jdk container's GUI clients to reach the host's X server so +# the sandbox PhpStorm window from `run-ide` actually displays. +# `xhost +local:docker` is idempotent (re-adding an existing rule is a +# no-op), so we can call it before every `run-ide` without leaking ACL +# entries. Pre-req only for `run-ide`; headless targets don't need it. +# Guarded with `command -v` so contributors on macOS / Windows / WSL +# (where xhost may be absent or unnecessary) don't hit a confusing +# error before `runIde` even gets a chance. Linux+X11 is the +# documented build host; this just degrades gracefully off it. +.PHONY: x11-grant +x11-grant: + @command -v xhost >/dev/null 2>&1 && xhost +local:docker || \ + echo "(skipping xhost: not on X11 or xhost not installed)" + +.PHONY: dist +dist: + # Produce the installable plugin zip at build/distributions/. Use this + # when you want to install into your *existing* PhpStorm (not the + # sandbox `run-ide` boots): once the zip exists, in your running IDE go + # to Settings -> Plugins -> gear icon -> "Install Plugin from Disk..." + # and pick the file. Requires PhpStorm 2026.1 or newer (since-build=261); + # older builds reject the install with an "incompatible build number" + # error. Restart of PhpStorm required after install. + $(GRADLEW) $(GRADLE_OPTS) buildPlugin + @echo + @echo "Installable plugin distribution:" + @ls -la build/distributions/ 2>/dev/null || \ + echo " (build/distributions/ not created; check gradle output above)" + +.PHONY: clean +clean: + $(GRADLEW) $(GRADLE_OPTS) clean diff --git a/tools/phpstorm-plugin/README.md b/tools/phpstorm-plugin/README.md new file mode 100644 index 00000000..5d55d435 --- /dev/null +++ b/tools/phpstorm-plugin/README.md @@ -0,0 +1,135 @@ +# xphp PhpStorm plugin + +Editing intelligence for `.xphp` files inside PhpStorm -- diagnostics, hover, +go-to-definition, completion -- driven by the same Language Server Protocol +implementation that backs the VS Code extension at +[`tools/lsp/vscode-extension/`](../lsp/vscode-extension/). One server, one +TextMate grammar, two editor integrations. + +| Feature | How | +|---|---| +| Diagnostics (parse errors, generic-bound violations, duplicate templates) | LSP `textDocument/publishDiagnostics` | +| Hover (specialized FQN + bound info) | LSP `textDocument/hover` | +| Go-to-definition (across files, into specialized classes) | LSP `textDocument/definition` | +| Completion (class names inside `<...>` type-arg positions) | LSP `textDocument/completion` | +| File-type recognition (`.xphp`) | IntelliJ `com.intellij.fileType` extension | +| Zero-config server install | Bundled PHAR auto-extracted on first plugin load | + +## Requirements + +- **PhpStorm 2026.1 or later** -- `since-build` is `261`. The IntelliJ Platform + LSP API went free across all editions in 2025.2 and rounded out its feature + set in 2026.1 (Code Lens, range formatting, Optimize Imports). Older + baselines would mean shipping through LSP4IJ as a compatibility shim -- + significantly more code for an MVP than the two-versions-behind userbase + saves. +- **JDK 21** for building the plugin. The Gradle wrapper picks up your + toolchain automatically. +- **PHP 8.4 + Composer** for building the bundled LSP PHAR (`make -C + tools/lsp build/phar`). + +## Build + +Everything below runs from this directory (`tools/phpstorm-plugin/`) or, from +the repo root, via `make -C tools/phpstorm-plugin `. + +```bash +# 1. Build the bundled LSP server (~ 2 MB PHAR): +make -C tools/lsp build/phar + +# 2. Build the plugin: +make build # compiles Kotlin, runs unit tests, packages plugin jar +make test # unit tests only (JUnit 5) +make verify # IntelliJ Plugin Verifier compatibility check +make run-ide # boots a sandbox PhpStorm with the plugin pre-loaded +make clean +``` + +The wrapper resolves to Gradle 9.0.0 (SHA-256 of the distribution pinned in +`gradle/wrapper/gradle-wrapper.properties`). First invocation downloads +Gradle and the PhpStorm 2026.1.2 distribution into your user-level Gradle +cache; subsequent runs reuse them. + +A plugin build without first running `make -C tools/lsp build/phar` succeeds +but warns and ships *without* a bundled LSP -- users would then have to point +Preferences -> Tools -> xPHP -> "xphp LSP binary" at an external binary. CI +runs both makes in order; for local development, doing the same is the +zero-config path. + +## Install + +Until the plugin lands on the JetBrains Marketplace: + +```bash +make -C tools/lsp build/phar +make -C tools/phpstorm-plugin build +# tools/phpstorm-plugin/build/distributions/xphp-phpstorm-plugin-0.1.0.zip +``` + +In PhpStorm: Preferences -> Plugins -> "Install Plugin from Disk..." -> pick +the generated zip. + +## Sandbox manual smoke test + +```bash +make run-ide +# In the spawned PhpStorm: +# 1. Open the repo's playground/ directory as a project. +# 2. Open playground/src/Containers/Box.xphp . +# 3. Confirm: hover over Box<...> shows the specialized FQN, +# F12 jumps to class Box, completion fires inside <...>, +# a deliberate Box against a Stringable bound underlines. +# 4. Preferences -> Tools -> xPHP: confirm the "xphp LSP binary" +# field is present and either points at the bundled PHAR or +# lets you override it with an absolute path. +``` + +## How it's wired + +``` +Open file.xphp in PhpStorm + | + v +XphpLspServerSupportProvider.fileOpened() + | (filters on XphpFileType) + v +XphpLspServerDescriptor.createCommandLine() + | (1) XphpSettings.lspPath if set, else + | (2) PharExtractor.extract() -> system-dir/xphp/xphp-lsp.phar + | (3) else error pointing at settings + v +IntelliJ Platform LSP API spawns `php ` over stdio + | + v +xphp Language Server (tools/lsp/) replies with diagnostics, hover, +definition, completion, semantic tokens -- the platform threads +them into the standard editor UI. +``` + +## Why this lives under `tools/` + +The xphp monorepo separates the **compiler product** (root) from the **tools +that consume it** (`tools//`). The PhpStorm plugin consumes the +compiler's published behaviour by spawning the LSP -- never as a compile-time +dependency. See [CONTRIBUTING.md "Monorepo layout"](../../CONTRIBUTING.md) for +the convention; this package is the worked example for "package whose CI +needs both ecosystems (PHP + JDK)". + +## Out of scope for now + +- **Marketplace publication.** Requires signing keys; the `signPlugin` task + is wired but never invoked here. Tracked as a deferred follow-up. +- **Native Kotlin lexer / parser.** Would unlock IntelliJ-grade refactoring + and structure view, at the cost of weeks of work and a second AST to keep + in sync. The LSP path delivers the same author experience without the + maintenance burden -- revisit if and when the LSP path proves + insufficient. +- **PhpStorm < 2026.1 support.** Locked out by `since-build=261`. LSP4IJ + fallback path is the obvious workaround; not worth it for an MVP. +- **Compile-on-save action.** Useful (`bin/xphp compile` triggered from a + run configuration / file watcher) but orthogonal; ship once LSP + intelligence is rock-solid. +- **Headless PhpStorm CI integration tests.** JetBrains' IDE distribution + isn't licensed for headless CI use in the open-source case. The plugin + verifier covers binary compatibility; behavioural integration tests stay + manual. diff --git a/tools/phpstorm-plugin/build.gradle.kts b/tools/phpstorm-plugin/build.gradle.kts new file mode 100644 index 00000000..8558f1c2 --- /dev/null +++ b/tools/phpstorm-plugin/build.gradle.kts @@ -0,0 +1,193 @@ +// Build script for the xphp PhpStorm plugin. +// +// Uses the IntelliJ Platform Gradle Plugin 2.x DSL -- the successor to the +// legacy `gradle-intellij-plugin`. The 2.x DSL is the supported path going +// forward and the one JetBrains' own plugin template uses. +// +// All version pins live in gradle.properties so a single edit there propagates +// to every coordinate (since-build, IDE target, kotlin runtime, jvm toolchain). +// Reach them with `providers.gradleProperty("...")` rather than +// `project.findProperty(...)` so Gradle's configuration cache stays happy. + +import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType + +plugins { + id("java") + id("org.jetbrains.kotlin.jvm") version "2.3.21" + id("org.jetbrains.intellij.platform") version "2.16.0" +} + +group = providers.gradleProperty("pluginGroup").get() +version = providers.gradleProperty("pluginVersion").get() + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(providers.gradleProperty("javaVersion").get().toInt())) + } +} + +kotlin { + jvmToolchain(providers.gradleProperty("javaVersion").get().toInt()) +} + +// Repositories live here (not in settings.gradle.kts) because the settings- +// level `org.jetbrains.intellij.platform.settings` plugin can't expose its +// `intellijPlatform { defaultRepositories() }` Kotlin accessor reliably -- +// see settings.gradle.kts for the longer rationale. Applying the main +// plugin at the project level via `plugins {}` above gives us the accessor +// here without any of that grief. +repositories { + mavenCentral() + + intellijPlatform { + defaultRepositories() + } +} + +dependencies { + intellijPlatform { + // PhpStorm matches the LSP-API target window we picked in + // gradle.properties. `create(IntelliJPlatformType.PhpStorm, version)` + // resolves the binary IDE distribution from JetBrains' installers repo + // (the `jetbrainsIdeInstallers` entry registered by + // `defaultRepositories()`), not from a Maven artifact -- there is no + // Maven publication for the full PhpStorm distribution. + create( + IntelliJPlatformType.PhpStorm, + providers.gradleProperty("platformVersion").get(), + ) + + // The bundled PHP plugin gives us the platform PHP language classes + // (file type registry, PSI, PhpStorm-specific configurables). Without + // it our File-Type registration sits alongside PHP's instead of being + // recognised as a sibling. + bundledPlugins(providers.gradleProperty("platformBundledPlugins").map { it.split(",") }) + + // v2 plugin model: extension points declared inside a plugin's + // `` sub-module aren't reachable through a + // plain `` on the parent plugin id. The TextMate plugin + // declares `com.intellij.textmate.bundleProvider` and the supporting + // classes under its `intellij.textmate` sub-module, so we need it + // on the compile classpath (mirroring the + // `` + // entry in plugin.xml). + bundledModule("intellij.textmate") + + // Toolchain components used by the build / verify pipeline. + pluginVerifier() + zipSigner() + } + + // Plain JUnit 5 for unit-level tests against pure-data classes + // (XphpLanguage, XphpFileType). We deliberately do NOT pull in + // `intellijPlatform { testFramework(TestFrameworkType.Platform) }` + // here -- that injects IntelliJ's `PathClassLoader` over the test + // runtime, which breaks plain JUnit 5 dispatch. Anything that + // genuinely needs `BasePlatformTestCase` should live in a separate + // source set with its own test task; the build verifier already + // covers structural plugin validation without booting the IDE. + testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.11.4") +} + +intellijPlatform { + pluginConfiguration { + version.set(providers.gradleProperty("pluginVersion")) + + ideaVersion { + sinceBuild.set(providers.gradleProperty("pluginSinceBuild")) + untilBuild.set(providers.gradleProperty("pluginUntilBuild")) + } + } + + pluginVerification { + ides { + // Verify against the same baseline we target. Future minors get + // added when they ship; the until-build window catches the rest. + recommended() + } + } +} + +// Bring the TextMate grammar from tools/lsp/vscode-extension/syntaxes/ into +// the plugin resources at build time -- single source of truth across the +// VS Code extension and this plugin. The grammar isn't wired to a TextMate +// bundle yet (chunk 3 registers the file type only; LSP semantic tokens from +// chunk 4 cover highlighting), but bundling it now means the resource exists +// when a later chunk plugs it in. +// +// Wired straight into `processResources` via `from(...)` rather than as a +// finalised separate Copy task -- Gradle 9's strict input/output validation +// flags any indirect path from copy output to the `jar` task's input +// directory, and adding the source here makes the dependency explicit by +// construction. +val tmLanguageSource = file("../lsp/vscode-extension/syntaxes/xphp.tmLanguage.json") + +// PHAR built by `make -C tools/lsp build/phar`. Bundled into the plugin jar +// at `bin/xphp-lsp.phar`; PharExtractor reads it from the classpath on +// first plugin load and copies it into PhpStorm's system directory. +// +// Same shape as the tmLanguage copy above: upstream artifact, build it +// out-of-band and we pick it up. CI runs both makes before assembling the +// plugin (chunk 6). A missing PHAR doesn't fail the build -- it ships an +// LSP-less plugin where the user must point Settings -> Tools -> xPHP at +// an external binary. That's the right behaviour for a dev iterating on +// the plugin without rebuilding the LSP on every change. +val xphpLspPhar = file("../lsp/var/xphp-lsp.phar") + +tasks { + processResources { + // Declare both upstream artifacts as optional inputs so Gradle's + // incremental-build / configuration-cache machinery re-evaluates + // the `if (...exists())` checks per build instead of pinning the + // answer at configuration time. Without this, a build that + // configures BEFORE `make -C tools/lsp build/phar` finishes + // would silently ship without the PHAR even if a parallel make + // dropped it mid-configure. + inputs.file(tmLanguageSource).withPropertyName("tmLanguageSource").optional(true) + inputs.file(xphpLspPhar).withPropertyName("xphpLspPhar").optional(true) + + if (tmLanguageSource.exists()) { + from(tmLanguageSource) { + into("textmate") + } + } else { + doFirst { + logger.warn( + "TextMate grammar missing at ${tmLanguageSource.path} -- " + + "shipping plugin without it. The VS Code extension's " + + "syntaxes/xphp.tmLanguage.json is the source of truth; " + + "regenerate / restore it there if you need this in the jar." + ) + } + } + + if (xphpLspPhar.exists()) { + from(xphpLspPhar) { + into("bin") + } + } else { + doFirst { + logger.warn( + "Bundled xphp-lsp.phar missing at ${xphpLspPhar.path} -- " + + "shipping plugin WITHOUT a bundled LSP binary. Users " + + "will need to set Preferences -> Tools -> xPHP -> 'xphp " + + "LSP binary' to an external path before .xphp editing " + + "intelligence is available. Run `make -C tools/lsp " + + "build/phar` before `./gradlew build` to produce a " + + "self-contained plugin." + ) + } + } + } + + test { + useJUnitPlatform() + } + + // Pre-empt the IntelliJ Platform Gradle Plugin's tendency to download a + // brand-new JBR every clean -- pin to the bundled toolchain when CI runs. + wrapper { + gradleVersion = "9.0.0" + } +} diff --git a/tools/phpstorm-plugin/gradle.properties b/tools/phpstorm-plugin/gradle.properties new file mode 100644 index 00000000..3cd208f5 --- /dev/null +++ b/tools/phpstorm-plugin/gradle.properties @@ -0,0 +1,56 @@ +# Plugin coordinates surfaced to JetBrains Marketplace when we eventually publish. +pluginGroup = com.xphp.lsp +pluginName = xphp +pluginVersion = 0.1.0 + +# Plugin compatibility window. +# +# 261.* = PhpStorm 2026.1. We target the 2026.1 LSP API specifically because: +# * The IntelliJ Platform LSP API went *free for all editions* in 2025.2. +# Earlier than that, LSP required Ultimate. +# * 2026.1 added the rest of the LSP feature surface we lean on -- Code Lens, +# range formatting, Optimize Imports. Older baselines would mean shimming +# half the protocol ourselves or falling back to LSP4IJ, both of which buy +# more code for an MVP than they save. +# 262.* = next major's first minor. Widens the until-build so a routine +# PhpStorm update doesn't auto-disable the plugin; we'll bump it as new majors +# land. +pluginSinceBuild = 261 +pluginUntilBuild = 262.* + +# IDE we build / verify against -- newest stable at scaffold time. +platformType = PS +platformVersion = 2026.1.2 + +# Bundled IDE plugin we depend on so PhpStorm's PHP language plumbing is on +# the classpath at compile time and at runtime in the sandbox. +# com.jetbrains.php: PSI hooks for the PHP language family (file type +# discrimination, future XphpLanguage-as-PhpLanguage-dialect work). +# org.jetbrains.plugins.textmate: provides the TextMate bundle / grammar +# APIs (TextMateBundleProvider + `com.intellij.textmate.bundleProvider` +# extension point) that XphpTextMateBundleProvider implements. Without +# it on the compile classpath, that class fails to resolve at build +# time and `.xphp` files open as plain text at runtime. +platformBundledPlugins = com.jetbrains.php,org.jetbrains.plugins.textmate + +# Toolchain. PhpStorm 2026.x runs on JBR 21; we follow. +javaVersion = 21 +kotlinVersion = 2.3.21 + +# Gradle daemon hygiene -- keeps configure-time short across `gradle build` +# and `gradle test` invocations in the same shell. +# +# `org.gradle.parallel = false`: a known race in Gradle 9.0.0 + JDK 21 + the +# IntelliJ Platform Gradle Plugin 2.16.x triggers `ClosedFileSystemException` +# when multiple worker threads read entries from the same cached PhpStorm +# distribution zip concurrently (the platform plugin walks ~hundreds of +# `intellij.*.xml` plugin-descriptor entries during compileClasspath +# resolution, and one thread's `close()` races another's `read()`). Local +# builds usually slip through; CI's clean-cache 4-core runner hits the race +# almost every time. Disabling parallel is the documented workaround until +# the platform plugin pins a fix (tracked in the JetBrains issue tracker as +# IJSDK-2024). Caching stays on -- it's per-task and not part of the race. +org.gradle.parallel = false +org.gradle.caching = true +org.gradle.jvmargs = -Xmx2g -Dfile.encoding=UTF-8 +kotlin.stdlib.default.dependency = false diff --git a/tools/phpstorm-plugin/gradle/wrapper/gradle-wrapper.jar b/tools/phpstorm-plugin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..8bdaf60c Binary files /dev/null and b/tools/phpstorm-plugin/gradle/wrapper/gradle-wrapper.jar differ diff --git a/tools/phpstorm-plugin/gradle/wrapper/gradle-wrapper.properties b/tools/phpstorm-plugin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..6ca25867 --- /dev/null +++ b/tools/phpstorm-plugin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +distributionSha256Sum=8fad3d78296ca518113f3d29016617c7f9367dc005f932bd9d93bf45ba46072b +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/tools/phpstorm-plugin/gradlew b/tools/phpstorm-plugin/gradlew new file mode 100755 index 00000000..0972d35f --- /dev/null +++ b/tools/phpstorm-plugin/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/tools/phpstorm-plugin/gradlew.bat b/tools/phpstorm-plugin/gradlew.bat new file mode 100644 index 00000000..8de1053a --- /dev/null +++ b/tools/phpstorm-plugin/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/tools/phpstorm-plugin/settings.gradle.kts b/tools/phpstorm-plugin/settings.gradle.kts new file mode 100644 index 00000000..c08de47f --- /dev/null +++ b/tools/phpstorm-plugin/settings.gradle.kts @@ -0,0 +1,32 @@ +// Gradle settings file. +// +// `pluginManagement` MUST be the first block. After that: +// * `rootProject.name` is the canonical project identifier. +// * Repositories deliberately live in `build.gradle.kts`, NOT in +// `dependencyResolutionManagement {}` here. +// +// Why: the IntelliJ Platform Gradle Plugin's settings-level companion +// (`org.jetbrains.intellij.platform.settings`) provides an +// `intellijPlatform { defaultRepositories() }` DSL on top of +// `RepositoryHandler`, but the Kotlin DSL accessor doesn't get generated +// reliably -- the settings script compiles before its own `plugins {}` +// block applies, so `intellijPlatform` resolves to "unresolved reference". +// We get the same outcome (the `jetbrainsIdeInstallers` repo, the IntelliJ +// dependencies cache redirector, etc.) by applying the main plugin at the +// project level in build.gradle.kts where accessor generation works +// normally. + +pluginManagement { + repositories { + gradlePluginPortal() + } +} + +rootProject.name = "xphp-phpstorm-plugin" + +dependencyResolutionManagement { + // PREFER_PROJECT: project-level `repositories {}` win. Default in + // Gradle 8 was relaxed; in Gradle 9 explicit mode declaration is the + // safer pattern when we rely on project-side repo registration. + repositoriesMode.set(RepositoriesMode.PREFER_PROJECT) +} diff --git a/tools/phpstorm-plugin/src/main/kotlin/com/xphp/lsp/PharExtractor.kt b/tools/phpstorm-plugin/src/main/kotlin/com/xphp/lsp/PharExtractor.kt new file mode 100644 index 00000000..62098e04 --- /dev/null +++ b/tools/phpstorm-plugin/src/main/kotlin/com/xphp/lsp/PharExtractor.kt @@ -0,0 +1,139 @@ +package com.xphp.lsp + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.PathManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import java.io.IOException +import java.io.InputStream +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.security.MessageDigest + +/** + * Extracts the bundled `xphp-lsp.phar` from the plugin jar into PhpStorm's + * system directory the first time it's needed, and refreshes the cache when + * the bundled bytes change between plugin versions. + * + * Why not run the PHAR straight out of the jar URL: PHARs read by `php` need + * a real filesystem path (the PHAR runtime parses its own offsets relative + * to the file location and can't open `jar:file:!/bin/...` URIs). We copy + * once, keep a sha256 sidecar, and skip the copy on every subsequent run. + * + * The path under `getSystemPath()/xphp/xphp-lsp.phar` survives PhpStorm + * restarts but is wiped by "Invalidate Caches" -- which is the right + * trade-off: a cache invalidation should re-extract a known-good binary, + * and the cost is one file write. + * + * Structurally the `@Service` is a thin facade over an inner [Extractor] + * that owns the IO state machine. The split mirrors + * [com.xphp.lsp.textmate.XphpTextMateBundleProvider.Extractor] and exists + * for testability: unit tests construct the [Extractor] directly with + * caller-controlled `streamLoader` and `targetPath`, so the real production + * state machine is exercised rather than re-implemented under test. + */ +@Service(Service.Level.APP) +class PharExtractor { + + private val extractor = Extractor() + + /** See [Extractor.extract]. */ + fun extract(): Path? = extractor.extract() + + /** See [Extractor.targetPath]; production callers use this for logging. */ + val targetPath: Path get() = extractor.targetPath + + /** + * IO state machine. `internal` so tests in the same module can + * construct it directly; production callers go through [extract] on + * the facade. + */ + internal class Extractor( + val targetPath: Path = PathManager.getSystemDir().resolve("xphp/xphp-lsp.phar"), + private val streamLoader: () -> InputStream? = { + PharExtractor::class.java.getResourceAsStream("/bin/xphp-lsp.phar") + }, + ) { + private val log = Logger.getInstance(PharExtractor::class.java) + private val checksumPath: Path = targetPath.resolveSibling("xphp-lsp.phar.sha256") + + /** + * Ensure the bundled PHAR is extracted and up to date. + * + * Returns the absolute path to the extracted PHAR, or `null` if no + * bundled bytes were available (e.g. a dev build where the LSP + * package hasn't been compiled yet -- the user-configurable LSP + * path is the escape hatch for that case). + */ + fun extract(): Path? { + val stream = streamLoader() ?: run { + log.info( + "No bundled xphp-lsp.phar inside the plugin jar. Falling back " + + "to the user-configured LSP path (Tools -> xPHP)." + ) + return null + } + + val bundledBytes = stream.use(InputStream::readAllBytes) + val bundledSha = sha256Hex(bundledBytes) + + val onDiskSha = readChecksumOrNull() + if (onDiskSha == bundledSha && Files.isRegularFile(targetPath)) { + log.debug("Bundled xphp-lsp.phar already extracted to $targetPath ($bundledSha)") + return targetPath + } + + Files.createDirectories(targetPath.parent) + + // Per-process unique temp file so two PhpStorm instances starting + // simultaneously can't race on the same sibling "xphp-lsp.phar.tmp" + // path and leak a partial PHAR if either crashes mid-write. The + // atomic move at the end means whichever instance finishes last + // wins with byte-identical content; uniqueness is purely about + // protecting the in-progress temp file. + val tmp = Files.createTempFile(targetPath.parent, "xphp-lsp", ".phar.tmp") + try { + Files.write(tmp, bundledBytes) + Files.move( + tmp, + targetPath, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + Files.writeString(checksumPath, bundledSha) + log.info("Extracted bundled xphp-lsp.phar to $targetPath ($bundledSha)") + return targetPath + } catch (e: IOException) { + log.warn("Failed to extract bundled xphp-lsp.phar to $targetPath", e) + Files.deleteIfExists(tmp) + return null + } + } + + private fun readChecksumOrNull(): String? = + try { + if (Files.isRegularFile(checksumPath)) Files.readString(checksumPath).trim() + else null + } catch (_: IOException) { + null + } + + private fun sha256Hex(bytes: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256").digest(bytes) + val sb = StringBuilder(digest.size * 2) + for (b in digest) { + val v = b.toInt() and 0xFF + sb.append(HEX[v ushr 4]).append(HEX[v and 0x0F]) + } + return sb.toString() + } + } + + companion object { + private val HEX = "0123456789abcdef".toCharArray() + + fun getInstance(): PharExtractor = + ApplicationManager.getApplication().getService(PharExtractor::class.java) + } +} diff --git a/tools/phpstorm-plugin/src/main/kotlin/com/xphp/lsp/XphpLspServerDescriptor.kt b/tools/phpstorm-plugin/src/main/kotlin/com/xphp/lsp/XphpLspServerDescriptor.kt new file mode 100644 index 00000000..51deb9ee --- /dev/null +++ b/tools/phpstorm-plugin/src/main/kotlin/com/xphp/lsp/XphpLspServerDescriptor.kt @@ -0,0 +1,189 @@ +package com.xphp.lsp + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.platform.lsp.api.ProjectWideLspServerDescriptor +import com.intellij.platform.lsp.api.customization.LspCustomization +import com.xphp.lsp.settings.XphpSettings +import com.xphp.lsp.settings.XphpSettingsConfigurable +import java.io.File + +/** + * LSP server descriptor for xphp. + * + * A `ProjectWideLspServerDescriptor` rather than a per-document one because + * the xphp LSP analyzes across files (cross-file go-to-definition, workspace + * symbol queries) -- a per-document server would re-parse the workspace on + * every open, defeating the in-memory `Registry` cache the server already + * maintains. + * + * Binary resolution order: + * 1. [XphpSettings.lspPath] if the user set it explicitly (overrides + * everything else -- useful when iterating on the LSP locally). + * 2. The bundled PHAR extracted by [PharExtractor] from the plugin jar + * into PhpStorm's system directory. This is the zero-config path + * a typical user gets on plugin install. + * 3. If neither is available, fire a balloon notification with an + * "Open Settings..." action that takes the user straight to the + * Tools -> xPHP pane, and abort the start. The notification is + * the user-facing channel; the thrown exception is just the LSP + * framework's signal to mark start as failed. + * + * Transport: stdio. Matches `tools/lsp/bin/xphp-lsp` (no `--lint` arg). + */ +class XphpLspServerDescriptor(project: Project) : + ProjectWideLspServerDescriptor(project, "xphp") { + + override fun isSupportedFile(file: VirtualFile): Boolean = + file.extension == "xphp" + + // Opt in to LSP-routed editor actions. Server-side capability advertisement + // (`definitionProvider: true`, `hoverProvider: true` in our `initialize` + // response) tells the platform the server CAN do each thing; the + // customization here tells the platform to actually ASK. Both sides are + // required -- with only server capabilities, PhpStorm never dispatches a + // `textDocument/definition` on Ctrl+click, even though the server is + // running and would happily answer. Confirmed via captured idea.log + // showing zero `textDocument/definition` traffic before this opt-in. + // + // The no-arg `LspCustomization()` constructor instantiates the + // `Lsp*Support` (enabled) version of every customizer -- go-to-def, + // hover, completion, semantic tokens, the lot. This is fine: each + // customizer also consults the server's advertised `ServerCapabilities` + // before dispatching, so handlers our LSP doesn't implement (rename, + // formatting, etc.) won't be routed regardless of the customizer state. + // The deprecated per-feature boolean overrides (`lspGoToDefinitionSupport`, + // `lspHoverSupport`, ...) feed an older opt-in path that wraps customizers + // in `*Disabled` defaults; overriding this single property bypasses that + // logic and gives the modern, lint-clean opt-in. + // + // `LspCustomization` itself is annotated `@ApiStatus.OverrideOnly` -- + // the IntelliJ Plugin Verifier flags `LspCustomization()` direct + // instantiation in client code (OVERRIDE_ONLY_API_USAGES). Using an + // empty anonymous subclass satisfies the contract: we're EXTENDING + // the class (the documented use case), not constructing it from + // outside, and we inherit every default the no-arg path provides. + override val lspCustomization: LspCustomization = object : LspCustomization() {} + + // IntelliJ's LSP framework dedupes "is this server already running?" + // by descriptor equality. Our `XphpLspServerSupportProvider.fileOpened` + // calls `ensureServerStarted(XphpLspServerDescriptor(project))` on every + // open -- a brand-new instance each time. Without these overrides, + // every new instance != the previous one, and the framework treats + // each call as "different server, restart needed." In practice that + // shut down the running server immediately after init -- visible in + // idea.log as `(Running;0) -> ShutdownNormally;0` followed by exit 137 + // when SIGTERM didn't complete in time. + // + // Same project + same descriptor class = same logical LSP server. + // `project` is inherited from `ProjectWideLspServerDescriptor`. + override fun equals(other: Any?): Boolean = + other is XphpLspServerDescriptor && other.project === project + + override fun hashCode(): Int = project.hashCode() + + override fun createCommandLine(): GeneralCommandLine { + val binary = resolveBinary() ?: run { + notifyMissingBinary() + // The LSP framework catches whatever createCommandLine throws and + // logs it. The detailed message lives in the balloon the user + // actually sees; the exception just needs to abort the start + // without shouting in idea.log. + throw RuntimeException("xphp LSP binary not available (see notification balloon)") + } + + val cmd = GeneralCommandLine() + cmd.workDirectory = project.basePath?.let(::File) + + // Distinguish "binary is a PHAR" from "binary is a shell script". The + // PHAR needs `php` as the launcher; the script (tools/lsp/bin/xphp-lsp) + // has its own shebang and runs directly. We pick by extension rather + // than file inspection -- the user explicitly typed this path in + // settings, no need to second-guess. For PHAR launches, honour + // `settings.phpPath` if set (parity with the VS Code extension's + // `xphp.phpPath`); otherwise fall back to bare `php` and let the OS + // resolve it against PATH. + if (binary.extension.equals("phar", ignoreCase = true)) { + cmd.exePath = XphpSettings.getInstance().phpPath ?: "php" + cmd.addParameter(binary.absolutePath) + } else { + cmd.exePath = binary.absolutePath + } + + return cmd + } + + /** + * Returns the LSP binary path or null if neither the explicit setting + * nor the bundled-PHAR fallback resolves to a real file. Null is the + * trigger for [notifyMissingBinary]. + * + * **Always** runs [PharExtractor.extract] first, regardless of + * whether `lspPath` is set. Reason: a user who set `lspPath` to the + * exact path PharExtractor writes to (`/xphp/xphp-lsp.phar`) + * was effectively pinning their LSP to whatever bytes happened to be + * on disk at the time they configured the setting. Plugin upgrades + * couldn't refresh the bundled PHAR -- the configured-path branch + * short-circuited before the extractor ran, so a stale on-disk PHAR + * stayed in use forever. PharExtractor's sha-check is cheap on the + * no-change path (read bundled bytes, sha compare, return), so always + * running it is the right default; the explicit `lspPath` is then a + * pure override that points wherever (could be the bundle target, + * could be an external binary). + */ + private fun resolveBinary(): File? { + val bundled = PharExtractor.getInstance().extract()?.toFile() + val configured = XphpSettings.getInstance().lspPath + if (configured != null) { + val asFile = File(configured) + if (asFile.isFile) return asFile + LOG.warn( + "Configured xphp LSP binary does not exist on disk: " + + "$configured. Using the bundled PHAR instead. Clear " + + "the path in Settings -> Tools -> xPHP, or update it to " + + "a real binary, to silence this warning." + ) + } + return bundled + } + + private fun notifyMissingBinary() { + val configured = XphpSettings.getInstance().lspPath + val (title, content) = if (configured != null) { + "xphp LSP binary not found" to ( + "The path configured under Settings -> Tools -> xPHP doesn't " + + "point at a real file: $configured. Update the " + + "setting or rebuild the plugin with a bundled PHAR." + ) + } else { + "xphp LSP is not configured" to ( + "Set the path to xphp-lsp.phar in Settings -> Tools -> " + + "xPHP, or rebuild the plugin (`make -C tools/lsp build/phar` " + + "before `make -C tools/phpstorm-plugin dist`) so a bundled " + + "server ships inside the plugin jar." + ) + } + NotificationGroupManager.getInstance() + .getNotificationGroup("xphp") + .createNotification(title, content, NotificationType.WARNING) + .addAction( + NotificationAction.createSimpleExpiring("Open Settings...") { + ShowSettingsUtil.getInstance().showSettingsDialog( + project, + XphpSettingsConfigurable::class.java, + ) + } + ) + .notify(project) + } + + private companion object { + private val LOG = Logger.getInstance(XphpLspServerDescriptor::class.java) + } +} diff --git a/tools/phpstorm-plugin/src/main/kotlin/com/xphp/lsp/XphpLspServerSupportProvider.kt b/tools/phpstorm-plugin/src/main/kotlin/com/xphp/lsp/XphpLspServerSupportProvider.kt new file mode 100644 index 00000000..a83df113 --- /dev/null +++ b/tools/phpstorm-plugin/src/main/kotlin/com/xphp/lsp/XphpLspServerSupportProvider.kt @@ -0,0 +1,38 @@ +package com.xphp.lsp + +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.platform.lsp.api.LspServerSupportProvider + +/** + * IntelliJ Platform LSP entry point. + * + * The platform calls [fileOpened] on every file open across every registered + * provider; the convention is "if this file is mine, ensure the server is + * running." `serverStarter.ensureServerStarted(...)` de-dupes across calls + * so opening 20 .xphp files spawns exactly one server. + * + * Filter is **by file extension**, not by `FileType`. An earlier iteration + * registered a `XphpFileType : LanguageFileType(XphpLanguage)` and filtered + * with `file.fileType is XphpFileType`, but `LanguageFileType` makes the + * platform assume the bound `Language` has a `ParserDefinition` registered + * (it needs one to construct PSI for the editor). We don't have one -- + * parsing is delegated to the LSP -- so the editor failed to construct and + * `.xphp` files refused to open. Dropping the file type fixed file opens; + * the extension filter here is the docs-recommended pattern + * (https://plugins.jetbrains.com/docs/intellij/language-server-protocol.html + * #basic-implementation) and works regardless of how PhpStorm decides to + * classify `.xphp` files internally (TextMate-handled when our bundle is + * loaded; plain text otherwise). + */ +class XphpLspServerSupportProvider : LspServerSupportProvider { + + override fun fileOpened( + project: Project, + file: VirtualFile, + serverStarter: LspServerSupportProvider.LspServerStarter, + ) { + if (file.extension != "xphp") return + serverStarter.ensureServerStarted(XphpLspServerDescriptor(project)) + } +} diff --git a/tools/phpstorm-plugin/src/main/kotlin/com/xphp/lsp/settings/XphpSettings.kt b/tools/phpstorm-plugin/src/main/kotlin/com/xphp/lsp/settings/XphpSettings.kt new file mode 100644 index 00000000..3c04c572 --- /dev/null +++ b/tools/phpstorm-plugin/src/main/kotlin/com/xphp/lsp/settings/XphpSettings.kt @@ -0,0 +1,97 @@ +package com.xphp.lsp.settings + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.util.xmlb.XmlSerializerUtil + +/** + * Persistent application-level settings for the xphp plugin. + * + * Two knobs today: + * + * * `lspPath`: absolute path to a custom `xphp-lsp.phar` (or + * `xphp-lsp` script). Empty means "use the bundled PHAR that + * [com.xphp.lsp.PharExtractor] extracts on first plugin load". + * + * * `phpPath`: absolute path to the PHP interpreter that should + * launch the PHAR. Empty means "use whatever `php` is on the + * IDE's PATH". Matches the `xphp.phpPath` setting the VS Code + * extension already exposes, so multi-PHP-version dev machines + * and non-PATH installs work the same way across editors. + * + * State persists to `/options/xphp.xml` in PhpStorm's config + * directory. Per-project overrides aren't supported -- a developer + * working across multiple xphp projects on a single PhpStorm install + * almost certainly wants the same LSP binary + PHP interpreter for + * all of them. + */ +@Service(Service.Level.APP) +@State( + name = "xphpSettings", + storages = [Storage("xphp.xml")], +) +class XphpSettings : PersistentStateComponent { + + /** + * State container. Must be `public` because `PersistentStateComponent` + * exposes it through the public `getState()` / `loadState()` methods -- + * Kotlin won't let a public function return / accept an `internal` + * type. + * + * The only sanctioned mutator is the Kotlin UI DSL binding in + * [XphpSettingsConfigurable], which writes raw textfield content + * directly to `state.lspPath` / `state.phpPath`. External callers + * MUST read through the trimmed accessors ([lspPath], [phpPath]) so + * a stray space in the user's input doesn't propagate to the + * descriptor's `cmd.exePath`. + */ + data class State( + /** + * Absolute path to a user-supplied `xphp-lsp` server. Empty + * means "no override"; [com.xphp.lsp.XphpLspServerDescriptor] + * falls through to the bundled PHAR. Stored raw (not trimmed) + * because the Kotlin UI DSL binding writes verbatim from the + * textfield -- normalization happens at read time below. + */ + var lspPath: String = "", + + /** + * Absolute path to the PHP interpreter used to launch a PHAR + * LSP. Empty means "use whatever `php` is on PATH". Same + * normalization rule as `lspPath`. + */ + var phpPath: String = "", + ) + + private var state = State() + + override fun getState(): State = state + + override fun loadState(loaded: State) { + XmlSerializerUtil.copyBean(loaded, state) + } + + /** + * Trimmed view of [State.lspPath]. Null when no override is + * configured -- callers use null as the signal to fall through to + * the bundled PHAR. + */ + val lspPath: String? + get() = state.lspPath.trim().takeIf { it.isNotEmpty() } + + /** + * Trimmed view of [State.phpPath]. Null when no override is + * configured -- callers default to "php" and let the OS resolve it + * against PATH. + */ + val phpPath: String? + get() = state.phpPath.trim().takeIf { it.isNotEmpty() } + + companion object { + fun getInstance(): XphpSettings = + ApplicationManager.getApplication().getService(XphpSettings::class.java) + } +} diff --git a/tools/phpstorm-plugin/src/main/kotlin/com/xphp/lsp/settings/XphpSettingsConfigurable.kt b/tools/phpstorm-plugin/src/main/kotlin/com/xphp/lsp/settings/XphpSettingsConfigurable.kt new file mode 100644 index 00000000..af88589a --- /dev/null +++ b/tools/phpstorm-plugin/src/main/kotlin/com/xphp/lsp/settings/XphpSettingsConfigurable.kt @@ -0,0 +1,82 @@ +package com.xphp.lsp.settings + +import com.intellij.openapi.fileChooser.FileChooserDescriptor +import com.intellij.openapi.options.BoundConfigurable +import com.intellij.openapi.ui.DialogPanel +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.panel + +/** + * Settings UI under Preferences -> Tools -> xPHP. + * + * Extends [BoundConfigurable] (not the bare [com.intellij.openapi.options.Configurable]): + * the base class captures the [DialogPanel] returned by [createPanel] and wires + * `apply()` / `reset()` / `isModified()` / `disposeUIResources()` to the panel's + * matching methods, so the Kotlin UI DSL bindings declared inside the panel + * actually run during the platform's save / cancel / dirty-check lifecycle. + * + * The earlier hand-rolled `Configurable` overrode those methods directly and + * never delegated to the panel, which silently broke persistence: the + * textfield's typed value never reached the bound property, so [apply] + * stored a stale empty string back into `xphp.xml` every time the user + * hit OK. + * + * Bind the textfield directly to `XphpSettings.state::lspPath` -- the mutable + * property reference on the State data class. No intermediate field, no + * manual sync. `PersistentStateComponent` flushes mutations to + * `/options/xphp.xml` on the platform's normal cadence. + */ +class XphpSettingsConfigurable : BoundConfigurable("xPHP") { + + private val settings = XphpSettings.getInstance() + + override fun createPanel(): DialogPanel = panel { + // The current non-deprecated `textFieldWithBrowseButton` overload + // takes a `FileChooserDescriptor` (with the title baked in via + // `.withTitle(...)`); the older `browseDialogTitle = ...` named + // argument is deprecated -- and on this build the Kotlin compiler + // promotes that deprecation to an error. + // + // `FileChooserDescriptorFactory.createSingleFileDescriptor()` was + // also deprecated in 2024.x -- the Plugin Verifier flags it. + // Instantiating `FileChooserDescriptor` directly with the explicit + // flag tuple (files=true, folders=false, jars=false, jarsAsFiles= + // false, jarContents=false, chooseMultiple=false) is the stable + // replacement that ships in every supported IDE build. + row("xphp LSP binary:") { + textFieldWithBrowseButton( + FileChooserDescriptor(true, false, false, false, false, false) + .withTitle("Select xphp LSP binary"), + ) + .bindText(settings.state::lspPath) + .align(AlignX.FILL) + .comment( + "Absolute path to xphp-lsp.phar built via " + + "make -C tools/lsp build/phar, or to the " + + "live tools/lsp/bin/xphp-lsp script. Leave " + + "empty to use the plugin's bundled server (auto-extracted " + + "to PhpStorm's system dir on first plugin load)." + ) + } + + // PHP interpreter override -- mirrors the VS Code extension's + // `xphp.phpPath` setting so multi-PHP-version dev machines and + // non-PATH installs work the same across editors. + row("PHP interpreter:") { + textFieldWithBrowseButton( + FileChooserDescriptor(true, false, false, false, false, false) + .withTitle("Select PHP interpreter"), + ) + .bindText(settings.state::phpPath) + .align(AlignX.FILL) + .comment( + "Absolute path to the php binary used to launch " + + "the xphp LSP PHAR. Leave empty to use whatever php " + + "is on PATH. Only affects PHAR launches; if the LSP " + + "binary above is a shell script it runs directly through " + + "its own shebang." + ) + } + } +} diff --git a/tools/phpstorm-plugin/src/main/kotlin/com/xphp/lsp/textmate/XphpBundleRegistrar.kt b/tools/phpstorm-plugin/src/main/kotlin/com/xphp/lsp/textmate/XphpBundleRegistrar.kt new file mode 100644 index 00000000..aedeff79 --- /dev/null +++ b/tools/phpstorm-plugin/src/main/kotlin/com/xphp/lsp/textmate/XphpBundleRegistrar.kt @@ -0,0 +1,229 @@ +package com.xphp.lsp.textmate + +import com.intellij.openapi.application.PathManager +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity +import org.jetbrains.plugins.textmate.TextMateService +import org.jetbrains.plugins.textmate.configuration.TextMateUserBundlesSettings +import java.io.IOException +import java.io.InputStream +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.security.MessageDigest + +/** + * Bootstraps the xphp TextMate grammar into PhpStorm's TextMate plugin + * on plugin startup so `.xphp` files get syntax highlighting. + * + * # Why this exists (and why not `TextMateBundleProvider`) + * + * PhpStorm 2026.1.2's TextMate plugin declares an extension point + * `com.intellij.textmate.bundleProvider` whose interface + * `org.jetbrains.plugins.textmate.api.TextMateBundleProvider` is the + * documented API for "plugin ships a TextMate grammar". But scanning + * every jar in the bundled IDE distribution shows **zero classes** + * actually consume that EP. `TextMateServiceImpl.registerBundles` + * gathers bundle paths from only two sources: + * + * * `TextMateBuiltinBundlesSettings` -- filesystem-discovered IDE + * built-ins (we can't write there from a plugin). + * * `TextMateUserBundlesSettings` -- entries the user adds through + * `Settings -> Editor -> TextMate Bundles`. + * + * The BundleProvider EP exists in the API surface but is presently + * unwired in 2026.1.2. An earlier iteration of this plugin registered + * against that EP and the bundle silently never loaded -- the platform + * never asked us for it. + * + * To actually make highlighting work, we go through the user-bundles + * registry. This [ProjectActivity] runs after project open, extracts + * our bundled grammar to a stable on-disk location, and calls + * `TextMateUserBundlesSettings.addBundle(path, "xphp")` if it isn't + * already registered. Subsequent runs are no-ops via + * `hasEnabledBundle`. + * + * # Visible side effect + * + * After first run, an "xphp" entry appears in + * `Settings -> Editor -> TextMate Bundles`, pointing at + * `/xphp/textmate-bundle/xphp/`. The user can disable + * or remove it from there. Uninstalling the plugin leaves the entry + * orphaned (points at a path that still exists); fixing that requires + * a `Disposable` hook, which is a fine follow-up but not on the + * critical path here. + */ +class XphpBundleRegistrar : ProjectActivity { + + private val log = Logger.getInstance(XphpBundleRegistrar::class.java) + private val extractor = Extractor() + + override suspend fun execute(project: Project) { + val bundleDir = extractor.extract() ?: return + val path = bundleDir.toAbsolutePath().toString() + val settings = TextMateUserBundlesSettings.getInstance() ?: run { + // The settings service is `Service.Level.APP`; getInstance() + // returns nullable per its Kotlin signature, presumably to + // accommodate edge cases like running headless or during a + // partial classloading sequence. In a real IDE session it + // should always resolve. Bail gracefully if it doesn't. + log.warn("TextMateUserBundlesSettings unavailable; skipping bundle registration") + return + } + + if (settings.hasEnabledBundle(path)) { + log.debug("xphp TextMate bundle already registered at $path") + return + } + + settings.addBundle(path, "xphp") + log.info("Registered xphp TextMate bundle at $path") + + // Reload bundles only when we actually changed the user-bundles + // list. An earlier iteration of this code called reload + // unconditionally to heal legacy installs whose on-disk bundles + // were missing info.plist -- but reloadEnabledBundles() fires + // `fileTypesChanged`, which cascades into PhpStorm's LSP framework + // bouncing every registered LSP server (idea.log: + // `Stopping LSP server normally` followed by exit 137 a moment + // after init succeeded). The bounce killed our LSP server on + // every IDE start, leaving the user with a "stopped" LSP + // indicator and no completion / GTD. + // + // First-install path (this branch): reload once so the platform + // picks up the newly-registered bundle. After that, the entry + // is persisted; subsequent IDE starts hit the early-return above + // and don't touch the file-types graph. + TextMateService.getInstance().reloadEnabledBundles() + } + + /** + * Bundle extractor. Public for tests; production callers go through + * [execute]. Pattern intentionally mirrors + * [com.xphp.lsp.PharExtractor.Extractor] (sha-keyed cache, atomic + * write, configurable target + stream loader) so tests construct it + * directly without touching IntelliJ's `Application`. + */ + internal class Extractor( + private val resource: String = "/textmate/xphp.tmLanguage.json", + private val grammarFileName: String = "xphp.tmLanguage.json", + private val bundleRoot: Path = PathManager.getSystemDir().resolve("xphp/textmate-bundle/xphp"), + private val streamLoader: () -> InputStream? = { + XphpBundleRegistrar::class.java.getResourceAsStream(resource) + }, + ) { + private val log = Logger.getInstance(XphpBundleRegistrar::class.java) + private val grammarPath: Path = bundleRoot.resolve("Syntaxes").resolve(grammarFileName) + private val checksumPath: Path = bundleRoot.resolve("xphp.sha256") + private val infoPlistPath: Path = bundleRoot.resolve("info.plist") + + /** + * Extract the grammar to disk if needed. Returns the bundle + * root (NOT the grammar file -- TextMate wants the directory + * that contains `Syntaxes/`). Returns null when the plugin + * jar carries no grammar resource. + */ + fun extract(): Path? { + val stream = streamLoader() ?: run { + log.info( + "No bundled xphp.tmLanguage.json inside the plugin jar; " + + "skipping TextMate bundle registration. .xphp files " + + "will fall back to PhpLanguage-inherited highlighting." + ) + return null + } + + Files.createDirectories(grammarPath.parent) + + // info.plist tells IntelliJ's bundle reader this is a + // classic-TextMate-format bundle. Without it the reader + // logs "bundle has an unknown format" and refuses to load + // grammars. Written unconditionally (and idempotently) so + // users who installed an earlier plugin version that + // shipped the bundle without info.plist get the fix on + // the very next IDE start. + ensureInfoPlist() + + val bundledBytes = stream.use(InputStream::readAllBytes) + val bundledSha = sha256Hex(bundledBytes) + + val onDiskSha = readChecksumOrNull() + if (onDiskSha == bundledSha && Files.isRegularFile(grammarPath)) { + log.debug("Bundled xphp grammar already extracted to $grammarPath ($bundledSha)") + return bundleRoot + } + + // Per-process unique temp -- two PhpStorm instances starting + // simultaneously won't race on a shared sibling temp file. + val tmp = Files.createTempFile(grammarPath.parent, grammarFileName, ".tmp") + try { + Files.write(tmp, bundledBytes) + Files.move( + tmp, + grammarPath, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + Files.writeString(checksumPath, bundledSha) + log.info("Extracted xphp.tmLanguage.json to $grammarPath ($bundledSha)") + return bundleRoot + } catch (e: IOException) { + log.warn("Failed to extract xphp.tmLanguage.json to $grammarPath", e) + Files.deleteIfExists(tmp) + return null + } + } + + private fun ensureInfoPlist() { + if (Files.isRegularFile(infoPlistPath)) return + try { + Files.writeString(infoPlistPath, INFO_PLIST) + } catch (e: IOException) { + log.warn("Failed to write $infoPlistPath", e) + } + } + + private fun readChecksumOrNull(): String? = + try { + if (Files.isRegularFile(checksumPath)) Files.readString(checksumPath).trim() + else null + } catch (_: IOException) { + null + } + + private fun sha256Hex(bytes: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256").digest(bytes) + val sb = StringBuilder(digest.size * 2) + for (b in digest) { + val v = b.toInt() and 0xFF + sb.append(HEX[v ushr 4]).append(HEX[v and 0x0F]) + } + return sb.toString() + } + } + + companion object { + private val HEX = "0123456789abcdef".toCharArray() + + /** + * Minimal TextMate bundle metadata. IntelliJ's bundle reader + * uses the presence of `info.plist` (or `package.json`, for VS + * Code-style bundles) at the bundle root to detect the bundle + * format. The only field it requires is `name`; we don't ship + * a UUID because TextMate-spec UUIDs are bundle-discovery keys + * that the platform doesn't dedupe against (our bundle path + * is the dedup key). + */ + private val INFO_PLIST: String = """ + + + + + name + xphp + + + """.trimIndent() + } +} diff --git a/tools/phpstorm-plugin/src/main/resources/META-INF/plugin.xml b/tools/phpstorm-plugin/src/main/resources/META-INF/plugin.xml new file mode 100644 index 00000000..7d84562b --- /dev/null +++ b/tools/phpstorm-plugin/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,184 @@ + + + com.xphp.lsp + xphp + xphp-lang + + xphp, a PHP + superset with monomorphized generics that compiles to vanilla PHP.

+ + Editing intelligence (diagnostics, hover, go-to-definition, completion) is delivered + by the bundled xphp Language Server over the IntelliJ Platform LSP API. The same + server powers the VS Code extension at tools/lsp/vscode-extension/; + single source of truth, no per-editor duplication.

+ + Requires PhpStorm 2026.1 or later. + ]]>
+ + 0.1.0 +
    +
  • Initial scaffold. No editor functionality yet.
  • +
+ ]]>
+ + + com.intellij.modules.platform + + + com.intellij.modules.lsp + + + com.jetbrains.php + + + org.jetbrains.plugins.textmate + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/tools/phpstorm-plugin/src/test/kotlin/com/xphp/lsp/PharExtractorTest.kt b/tools/phpstorm-plugin/src/test/kotlin/com/xphp/lsp/PharExtractorTest.kt new file mode 100644 index 00000000..81fc2e4c --- /dev/null +++ b/tools/phpstorm-plugin/src/test/kotlin/com/xphp/lsp/PharExtractorTest.kt @@ -0,0 +1,106 @@ +package com.xphp.lsp + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.InputStream +import java.nio.file.Files +import java.nio.file.Path +import java.security.MessageDigest + +/** + * Tests for [PharExtractor.Extractor]'s IO state machine. + * + * Mirrors [com.xphp.lsp.textmate.XphpTextMateBundleProviderTest]'s shape: + * we instantiate the production `Extractor` directly with caller-controlled + * `streamLoader` and a `@TempDir`-rooted `targetPath`. The same code paths + * run that fire when the IDE boots; nothing is re-implemented under test. + * A regression in `Extractor.extract()` (e.g. someone removes the sidecar + * write, or breaks the sha256 comparison) is caught here. + */ +class PharExtractorTest { + + private fun newExtractor(bytes: ByteArray?, baseDir: Path) = + PharExtractor.Extractor( + targetPath = baseDir.resolve("xphp-lsp.phar"), + streamLoader = { bytes?.inputStream() as InputStream? }, + ) + + @Test + fun `first run extracts bytes and writes checksum`(@TempDir tmp: Path) { + val bytes = "hello-xphp".toByteArray() + val extractor = newExtractor(bytes, tmp) + + val out = extractor.extract() + + assertNotNull(out) + assertEquals(tmp.resolve("xphp-lsp.phar"), out) + assertArrayEquals(bytes, Files.readAllBytes(out!!)) + + val sidecar = tmp.resolve("xphp-lsp.phar.sha256") + assertTrue(Files.isRegularFile(sidecar)) + assertEquals(sha256Hex(bytes), Files.readString(sidecar).trim()) + } + + @Test + fun `second run with unchanged bytes is a no-op (mtime preserved)`(@TempDir tmp: Path) { + val bytes = "hello-xphp".toByteArray() + + val first = newExtractor(bytes, tmp).extract()!! + val firstMtime = Files.getLastModifiedTime(first) + + // Ensure the filesystem clock has had a chance to tick before the + // second call so a re-write would actually change mtime on a + // coarse-grained FS. + Thread.sleep(50) + + val second = newExtractor(bytes, tmp).extract()!! + assertEquals(first, second) + assertEquals(firstMtime, Files.getLastModifiedTime(second)) + } + + @Test + fun `changed bundled bytes re-extracts and updates checksum`(@TempDir tmp: Path) { + val v1 = "hello-xphp-v1".toByteArray() + newExtractor(v1, tmp).extract() + + val v2 = "hello-xphp-v2-rebuilt".toByteArray() + val updated = newExtractor(v2, tmp).extract() + + assertNotNull(updated) + assertArrayEquals(v2, Files.readAllBytes(updated!!)) + assertEquals( + sha256Hex(v2), + Files.readString(tmp.resolve("xphp-lsp.phar.sha256")).trim(), + ) + } + + @Test + fun `no bundled bytes returns null and leaves the directory empty`(@TempDir tmp: Path) { + val extractor = newExtractor(bytes = null, baseDir = tmp) + + val out = extractor.extract() + + assertNull(out) + assertFalse(Files.exists(tmp.resolve("xphp-lsp.phar"))) + } + + private fun sha256Hex(bytes: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256").digest(bytes) + val sb = StringBuilder(digest.size * 2) + for (b in digest) { + val v = b.toInt() and 0xFF + sb.append(HEX[v ushr 4]).append(HEX[v and 0x0F]) + } + return sb.toString() + } + + companion object { + private val HEX = "0123456789abcdef".toCharArray() + } +} diff --git a/tools/phpstorm-plugin/src/test/kotlin/com/xphp/lsp/textmate/XphpBundleRegistrarTest.kt b/tools/phpstorm-plugin/src/test/kotlin/com/xphp/lsp/textmate/XphpBundleRegistrarTest.kt new file mode 100644 index 00000000..662aac0f --- /dev/null +++ b/tools/phpstorm-plugin/src/test/kotlin/com/xphp/lsp/textmate/XphpBundleRegistrarTest.kt @@ -0,0 +1,143 @@ +package com.xphp.lsp.textmate + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.InputStream +import java.nio.file.Files +import java.nio.file.Path + +/** + * Tests for [XphpBundleRegistrar.Extractor]'s file-IO contract. + * + * Mirrors [com.xphp.lsp.PharExtractorTest]'s shape: we instantiate the + * production [XphpBundleRegistrar.Extractor] with caller-controlled + * bundled bytes (via the `streamLoader` constructor parameter) and a + * `@TempDir` standing in for `PathManager.getSystemDir()`. Same code paths + * run, just without IntelliJ's `Application` in scope. + */ +class XphpBundleRegistrarTest { + + private fun newExtractor(bytes: ByteArray?, baseDir: Path) = + XphpBundleRegistrar.Extractor( + bundleRoot = baseDir.resolve("xphp"), + streamLoader = { bytes?.inputStream() as InputStream? }, + ) + + @Test + fun `first run extracts grammar to Syntaxes subdir and writes checksum`(@TempDir tmp: Path) { + val bytes = """{"scopeName":"source.xphp"}""".toByteArray() + + val bundleRoot = newExtractor(bytes, tmp).extract() + + assertNotNull(bundleRoot) + assertEquals(tmp.resolve("xphp"), bundleRoot) + + val grammar = bundleRoot!!.resolve("Syntaxes/xphp.tmLanguage.json") + assertTrue(Files.isRegularFile(grammar)) + assertArrayEquals(bytes, Files.readAllBytes(grammar)) + + val sidecar = bundleRoot.resolve("xphp.sha256") + assertTrue(Files.isRegularFile(sidecar)) + assertEquals(64, Files.readString(sidecar).trim().length) // sha256 hex + } + + @Test + fun `first run writes info_plist with bundle name`(@TempDir tmp: Path) { + val bytes = """{"scopeName":"source.xphp"}""".toByteArray() + + val bundleRoot = newExtractor(bytes, tmp).extract()!! + val infoPlist = bundleRoot.resolve("info.plist") + + assertTrue(Files.isRegularFile(infoPlist), "info.plist must exist for the platform's bundle reader to recognize the format") + val contents = Files.readString(infoPlist) + // Smoke checks; full plist correctness is the platform's concern. + assertTrue(contents.contains("name"), "info.plist has the `name` key") + assertTrue(contents.contains("xphp"), "info.plist names the bundle 'xphp'") + } + + @Test + fun `second extract restores info_plist when missing (heals legacy installs)`(@TempDir tmp: Path) { + val bytes = """{"scopeName":"source.xphp"}""".toByteArray() + val extractor = newExtractor(bytes, tmp) + extractor.extract()!! + + // Simulate the broken-legacy state: someone deletes info.plist + // out from under us (or an earlier plugin version never wrote one). + val infoPlist = tmp.resolve("xphp/info.plist") + Files.delete(infoPlist) + assertFalse(Files.exists(infoPlist)) + + // Re-running extract() must restore info.plist even though the + // grammar's sha256 hasn't changed -- otherwise users on the + // legacy plugin can't be healed on upgrade. + extractor.extract() + assertTrue(Files.isRegularFile(infoPlist)) + } + + @Test + fun `second run with unchanged bytes is a no-op (mtime preserved)`(@TempDir tmp: Path) { + val bytes = """{"scopeName":"source.xphp"}""".toByteArray() + + val first = newExtractor(bytes, tmp).extract()!! + val grammarFirst = first.resolve("Syntaxes/xphp.tmLanguage.json") + val firstMtime = Files.getLastModifiedTime(grammarFirst) + + // Ensure the filesystem clock has had a chance to tick before the + // second call so a re-write would actually change mtime on a + // coarse-grained FS. + Thread.sleep(50) + + val second = newExtractor(bytes, tmp).extract()!! + assertEquals(first, second) + assertEquals(firstMtime, Files.getLastModifiedTime(grammarFirst)) + } + + @Test + fun `changed bundled bytes re-extracts and updates checksum`(@TempDir tmp: Path) { + val v1 = """{"scopeName":"source.xphp","version":"v1"}""".toByteArray() + newExtractor(v1, tmp).extract() + + val v2 = """{"scopeName":"source.xphp","version":"v2"}""".toByteArray() + val updated = newExtractor(v2, tmp).extract() + + assertNotNull(updated) + val grammar = updated!!.resolve("Syntaxes/xphp.tmLanguage.json") + assertArrayEquals(v2, Files.readAllBytes(grammar)) + + // Sidecar reflects the new content. + val sha = Files.readString(updated.resolve("xphp.sha256")).trim() + assertEquals(sha256Hex(v2), sha) + } + + @Test + fun `no bundled grammar returns null and leaves the directory empty`(@TempDir tmp: Path) { + val extractor = newExtractor(bytes = null, baseDir = tmp) + + val bundleRoot = extractor.extract() + + assertNull(bundleRoot) + // Nothing should have been created when there's no grammar to ship. + assertFalse(Files.exists(tmp.resolve("xphp"))) + } + + private fun sha256Hex(bytes: ByteArray): String { + val digest = java.security.MessageDigest.getInstance("SHA-256").digest(bytes) + val sb = StringBuilder(digest.size * 2) + for (b in digest) { + val v = b.toInt() and 0xFF + sb.append(HEX[v ushr 4]).append(HEX[v and 0x0F]) + } + return sb.toString() + } + + companion object { + private val HEX = "0123456789abcdef".toCharArray() + } +}