From cacf8cd96b40289eb73f2e9a3423b3238a39d2e5 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 2 Jun 2026 21:36:22 +0200 Subject: [PATCH] MVP: basic functionality to support xphp v0.1.0 --- .docker/jdk.Dockerfile | 20 + .github/workflows/ci-phpstorm-plugin.yml | 100 +++++ .github/workflows/release-phpstorm-plugin.yml | 109 +++++ .gitignore | 14 + LICENSE | 10 +- Makefile | 57 +++ README.md | 107 +++++ build.gradle.kts | 218 ++++++++++ docker-compose.yml | 43 ++ docs/roadmap.md | 232 +++++++++++ gradle.properties | 67 ++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 8 + gradlew | 251 ++++++++++++ gradlew.bat | 94 +++++ settings.gradle.kts | 32 ++ src/main/kotlin/com/xphp/lsp/PharExtractor.kt | 139 +++++++ .../kotlin/com/xphp/lsp/XphpClassFileSync.kt | 181 +++++++++ .../com/xphp/lsp/XphpFileRenameListener.kt | 346 ++++++++++++++++ .../com/xphp/lsp/XphpLspServerDescriptor.kt | 219 +++++++++++ .../xphp/lsp/XphpLspServerSupportProvider.kt | 38 ++ .../lsp/XphpShowReferencesCommandsSupport.kt | 372 ++++++++++++++++++ .../com/xphp/lsp/settings/XphpSettings.kt | 97 +++++ .../lsp/settings/XphpSettingsConfigurable.kt | 82 ++++ .../xphp/lsp/textmate/XphpBundleRegistrar.kt | 229 +++++++++++ src/main/resources/META-INF/plugin.xml | 222 +++++++++++ .../kotlin/com/xphp/lsp/PharExtractorTest.kt | 106 +++++ .../lsp/textmate/XphpBundleRegistrarTest.kt | 143 +++++++ 28 files changed, 3531 insertions(+), 5 deletions(-) create mode 100644 .docker/jdk.Dockerfile create mode 100644 .github/workflows/ci-phpstorm-plugin.yml create mode 100644 .github/workflows/release-phpstorm-plugin.yml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 build.gradle.kts create mode 100644 docker-compose.yml create mode 100644 docs/roadmap.md create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts create mode 100644 src/main/kotlin/com/xphp/lsp/PharExtractor.kt create mode 100644 src/main/kotlin/com/xphp/lsp/XphpClassFileSync.kt create mode 100644 src/main/kotlin/com/xphp/lsp/XphpFileRenameListener.kt create mode 100644 src/main/kotlin/com/xphp/lsp/XphpLspServerDescriptor.kt create mode 100644 src/main/kotlin/com/xphp/lsp/XphpLspServerSupportProvider.kt create mode 100644 src/main/kotlin/com/xphp/lsp/XphpShowReferencesCommandsSupport.kt create mode 100644 src/main/kotlin/com/xphp/lsp/settings/XphpSettings.kt create mode 100644 src/main/kotlin/com/xphp/lsp/settings/XphpSettingsConfigurable.kt create mode 100644 src/main/kotlin/com/xphp/lsp/textmate/XphpBundleRegistrar.kt create mode 100644 src/main/resources/META-INF/plugin.xml create mode 100644 src/test/kotlin/com/xphp/lsp/PharExtractorTest.kt create mode 100644 src/test/kotlin/com/xphp/lsp/textmate/XphpBundleRegistrarTest.kt diff --git a/.docker/jdk.Dockerfile b/.docker/jdk.Dockerfile new file mode 100644 index 0000000..11c5be2 --- /dev/null +++ b/.docker/jdk.Dockerfile @@ -0,0 +1,20 @@ +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 \ + ibxrender1 \ + ibxi6 \ + ibxtst6 \ + ibxrandr2 \ + ibxcursor1 \ + libxinerama1 \ + ibfontconfig1 \ + ibfreetype6 \ + ibgtk-3-0 \ + fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* diff --git a/.github/workflows/ci-phpstorm-plugin.yml b/.github/workflows/ci-phpstorm-plugin.yml new file mode 100644 index 0000000..14f6b78 --- /dev/null +++ b/.github/workflows/ci-phpstorm-plugin.yml @@ -0,0 +1,100 @@ +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 + - uses: gradle/actions/wrapper-validation@v6 + - 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. + # + # The build's downloadLspPhar task fetches the xphp LSP binary from + # `xphpLspPharUrl` in gradle.properties and bundles it at + # bin/xphp-lsp.phar. The build FAILS if that property is unset/empty + # -- by design, so a jar never ships without an embedded LSP. + run: make build + + - name: Upload plugin jar + uses: actions/upload-artifact@v4 + with: + name: xphp-phpstorm-plugin-jar + path: 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: | + build/reports/tests/ + 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 + + - 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. + # + # The verifier itself doesn't read the PHAR, but Gradle's build graph + # treats `processResources` (which downloads + bundles the PHAR from + # `xphpLspPharUrl` in gradle.properties) as a prerequisite of + # `verifyPlugin`, so the property must be set or the build aborts + # before the verifier runs. + run: make verify + + - name: Upload Plugin Verifier reports on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: phpstorm-plugin-verifier-reports + path: build/reports/pluginVerifier/ + 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 0000000..b5b761b --- /dev/null +++ b/.github/workflows/release-phpstorm-plugin.yml @@ -0,0 +1,109 @@ +name: PhpStorm Plugin Release + +on: + push: + tags: + - 'v*' + +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 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 + # + # downloadLspPhar fetches the xphp LSP binary from `xphpLspPharUrl` in + # gradle.properties and bundles it at bin/xphp-lsp.phar. buildPlugin + # FAILS if that property is unset/empty -- a release zip never ships + # without an embedded LSP. + run: | + ./gradlew + 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 build/distributions/xphp-phpstorm-plugin-${{ steps.tag.outputs.version }}.zip) + if [[ ! -f "$path" ]]; then + echo "::error::zip not found at expected path" + ls -la 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/.gitignore b/.gitignore new file mode 100644 index 0000000..926d8d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +docker-compose.override.yml + +# 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 diff --git a/LICENSE b/LICENSE index 81500d1..d2398f3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,13 +1,13 @@ MIT License -Copyright (c) 2026 xphp-lang +Copyright (c) 2026-present Matheus Martins Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. @@ -17,5 +17,5 @@ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b8ff504 --- /dev/null +++ b/Makefile @@ -0,0 +1,57 @@ +.PHONY: build +build: + ./gradlew build + +.PHONY: test +test: + ./gradlew 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 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 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 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 clean diff --git a/README.md b/README.md new file mode 100644 index 0000000..bace631 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# xphp PhpStorm plugin + +Editing intelligence for `.xphp` files inside PhpStorm -- every +capability of the [xphp Language Server](../lsp/) plus a few +PhpStorm-native niceties layered on top. One LSP server, one TextMate +grammar, two editor integrations (this plugin and the [VS Code +extension](../vscode-extension/)). + +For what's planned next, see [roadmap](./docs/roadmap.md). + +## Install + +The plugin isn't on the JetBrains Marketplace yet. Install fromdisk + +```bash +make build # -> build/distributions/xphp-phpstorm-plugin-0.1.0.zip +``` + +In PhpStorm: **Preferences -> Plugins -> ⚙ -> Install Plugin from +Disk…** and pick the generated zip. Restart the IDE when prompted. + +The LSP PHAR is bundled inside the plugin and extracted to PhpStorm's +system directory on first plugin load -- no separate server install +needed. + +To override (e.g. point at a working tree's `bin/xphp-lsp` for plugin dev), +use **Preferences -> Tools -> xPHP -> "xphp LSP binary"**. + +### Features + +In addition to all features supported by the LSP, this plugin provides the +following: + +- **Code lens click target** is dispatched client-side + (`editor.action.showReferences`), so clicking the lens lands in + PhpStorm's native usage popup, not a generic LSP location list. +- **File rename sync** (the inverse of the LSP `willRenameFiles` + direction) is implemented in plugin Kotlin: a Shift+F6 class + rename triggers the matching file rename via PhpStorm's own + refactoring pipeline. +- **Zero-config server install** -- the PHAR is bundled in the + plugin jar; first plugin load extracts it to PhpStorm's system + directory. + +## Requirements + +- **PhpStorm 2026.1 or later** (`since-build = 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. + +## Build + +```bash +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 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. + +## How it's wired + +```mermaid +flowchart TD + A["Open .xphp file in PhpStorm"] + B["XphpLspServerSupportProvider.fileOpened()
filters on XphpFileType"] + C{"XphpLspServerDescriptor.createCommandLine()
resolve LSP binary"} + D["XphpSettings.lspPath
user override"] + E["PharExtractor.extract()
system-dir/xphp/xphp-lsp.phar"] + F["Error pointing at settings"] + G["IntelliJ Platform LSP API
spawns php <path-to-phar> over stdio"] + H["xphp Language Server (tools/lsp/)
diagnostics · hover · definition · completion · code lens · ..."] + I["Platform threads responses
into the standard editor UI"] + + A --> B + B --> C + C -- "(1) override set" --> D + C -- "(2) else bundled" --> E + C -- "(3) neither" --> F + D --> G + E --> G + G --> H + H --> I +``` + +Plugin-only Kotlin classes that wrap or extend the standard LSP path: + +- `XphpFileRenameListener` -- listens for VFS file moves / renames and + dispatches LSP 3.17 `willRenameFiles` requests (the IntelliJ LSP API doesn't + fire them natively). +- `XphpClassRenameListener` (via `BulkFileListener`) -- listens for class + renames inside `.xphp` files and triggers the matching file rename to keep + `PSR-4` in sync. +- `XphpShowReferencesCommandsSupport` -- intercepts + `editor.action.showReferences` from the server and opens + PhpStorm's native usage popup at the lens position. +- `PharExtractor` -- copies the bundled PHAR from the plugin jar + to PhpStorm's system directory on first load. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..33bdf20 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,218 @@ +// 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 +import java.net.URI + +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 { + // PhpStorm-only. `plugin.xml` declares + // `com.intellij.modules.php`, so the plugin + // can't load in IDEA / WebStorm / RustRover / RubyMine / etc. -- + // verifying against `recommended()` (the JetBrains-curated set + // of every IntelliJ Platform IDE) downloads ~6-8 IDE bundles + // (~10-15 GB) the plugin would never run on, and on GitHub + // ubuntu-latest runners that's enough to exhaust the ~14 GB + // free disk and crash the runner worker with + // "No space left on device". + // + // Pinning to the same PhpStorm version the sandbox + the + // platform dependency use checks the API surface the plugin + // actually targets. Reads from `platformVersion` in + // gradle.properties (same value the `dependencies.intellijPlatform.create(...)` + // call above resolves) so a single bump there moves both + // the runtime dependency and the verifier target -- no drift. + create( + IntelliJPlatformType.PhpStorm, + providers.gradleProperty("platformVersion").get(), + ) + } + } +} + +// PHAR downloaded at build time from `xphpLspPharUrl` in gradle.properties. +// 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. +// +// The download is a hard requirement: a build with `xphpLspPharUrl` unset or +// empty FAILS rather than silently shipping an LSP-less plugin. This +// guarantees a release zip always carries an embedded LSP binary. +// +// gradle.properties is the single source of truth -- the same file every +// other build pin lives in -- so CI and releases read the URL straight from +// the committed value with no env var involved. `providers.gradleProperty` +// also honours the standard Gradle overrides for free +// (`-PxphpLspPharUrl=...`, `ORG_GRADLE_PROJECT_xphpLspPharUrl=...`) for the +// occasional one-off local build against a different binary. +// +// Read through the `providers` API rather than `findProperty(...)` so +// Gradle's configuration cache tracks the value and re-runs the download +// when it changes. +val xphpLspPharUrl = providers.gradleProperty("xphpLspPharUrl") +val downloadedPharDir = layout.buildDirectory.dir("lsp") + +val downloadLspPhar by tasks.registering { + description = "Downloads xphp-lsp.phar (xphpLspPharUrl in gradle.properties) for bundling into the plugin jar." + group = "build" + + // The URL is the only meaningful input -- change it and the PHAR + // re-downloads; leave it unchanged and the task stays up-to-date so + // repeated builds don't re-fetch. Declared optional so configuration + // (and tasks that never touch the PHAR, e.g. `help`) doesn't blow up + // just because the var is unset; the hard requirement is enforced at + // execution time in the action below. + inputs.property("xphpLspPharUrl", xphpLspPharUrl).optional(true) + outputs.dir(downloadedPharDir) + + // Capture providers into locals so the action body holds no reference to + // `Project` -- keeps the task configuration-cache compatible. + val urlProvider = xphpLspPharUrl + val outDir = downloadedPharDir + + doLast { + // takeIf{isNotBlank} so an empty value (e.g. `xphpLspPharUrl =` with + // nothing after it, which a `providers` lookup reports as + // present-but-"") is treated as unset and yields the actionable + // message below instead of a cryptic "no protocol" URL error. + val url = urlProvider.orNull?.takeIf { it.isNotBlank() } + ?: throw GradleException( + "No xphp LSP PHAR URL configured. Set `xphpLspPharUrl` in " + + "gradle.properties to the phar download URL (or pass " + + "-PxphpLspPharUrl=) and re-run the build. The build " + + "downloads the xphp LSP binary and bundles it at " + + "bin/xphp-lsp.phar." + ) + + val dir = outDir.get().asFile + val phar = dir.resolve("xphp-lsp.phar") + dir.mkdirs() + phar.delete() + + logger.lifecycle("Downloading xphp-lsp.phar from $url") + URI(url).toURL().openStream().use { input -> + phar.outputStream().use { output -> input.copyTo(output) } + } + logger.lifecycle("Downloaded xphp-lsp.phar (${phar.length()} bytes) to $phar") + } +} + +tasks { + processResources { + // Bundle the downloaded PHAR. Depending on the task provider wires + // the build dependency automatically and copies its output dir + // contents (xphp-lsp.phar) into bin/. If the env var is unset the + // download task fails first, so processResources never runs without + // a PHAR in hand. + from(downloadLspPhar) { + into("bin") + } + } + + 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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..53030ef --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +services: + # 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" + +volumes: + # 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 later on. + gradle_cache: diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..bce6b73 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,232 @@ +# roadmap + +Forward-looking inventory for the PhpStorm-side of xphp tooling. + +The plugin is a thin client over the [xphp Language Server](../../lsp/); +most editor capabilities come from the server. This roadmap covers +PhpStorm-specific work — distribution, UX polish that hits the +IntelliJ Platform's own surfaces, and integrations that wouldn't make +sense as LSP methods. + +Three lanes: + +- **Shipped** — already in production. Full descriptions in + [`README.md`](README.md#features). +- **Planned** — design is understood, no open questions. Effort + sized as **S** (~1 day), **M** (1-3 days), **L** (~1 week). +- **Exploratory** — value is real but the shape isn't. Each item + carries a checklist of open questions, prior art, and a proposed + first-step spike. + +For LSP-level work see [`../../lsp/docs/roadmap.md`](../../lsp/docs/roadmap.md). + +--- + +## Shipped + +| Surface | Notes | +|-----------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **`.xphp` file type recognition** | Bundled TextMate grammar wired through PhpStorm's `FileType` infrastructure. | +| **Zero-config server install** | LSP PHAR bundled inside the plugin jar; `PharExtractor` copies it to PhpStorm's system directory on first plugin load. | +| **All LSP-driven editor features** | Diagnostics, hover, GTD, find usages, completion, rename, code actions, code lens, call / type hierarchy, semantic tokens, signature help, inlay hints, folding ranges, document highlight, document / workspace symbols — see [`README.md#features`](README.md#features). | +| **PSR-4 class ↔ filename rename sync (both directions)** | `XphpFileRenameListener` dispatches LSP 3.17 `willRenameFiles` on VFS moves; `XphpClassRenameListener` triggers the matching file rename when a class is renamed in source. Cross-directory file moves also update the namespace and every consuming `use` import. | +| **Code lens click → native usage popup at lens position** | `XphpShowReferencesCommandsSupport` intercepts `editor.action.showReferences` from the server and anchors PhpStorm's usage chooser to the lens line, not the caret. | +| **LSP binary override setting** | Preferences → Tools → xPHP → "xphp LSP binary" — for plugin developers iterating against a working-tree `bin/xphp-lsp`. | + +--- + +## Planned + +### Marketplace publication (S, blocked on infra) + +`signPlugin` task is wired in `build.gradle.kts` but never invoked +locally. Listed as Planned because the steps are mechanical: +generate signing keys, store them as CI secrets, add a release +workflow that runs `signPlugin` + `publishPlugin` on tag push, +write the listing copy. The blocker is the JetBrains-account +signing-key setup, not engineering. + +### `prepareRename` integration polish (S) + +Once the LSP ships `prepareRename` (Planned at +[`../../lsp/docs/roadmap.md`](../../lsp/docs/roadmap.md#preparerename--pre-fill-the-rename-dialog-s)), +verify PhpStorm's rename dialog picks it up automatically. No +plugin code expected — IntelliJ's LSP API should consume it +natively. Tagged Planned because a smoke test is the entire +deliverable. + +### Native "Generated PHP" peek action (M, depends on LSP lowering preview) + +When the LSP exploratory item "Lowering preview" lands, add a +PhpStorm-native action ("View → Generated PHP") that pulls the +generated source from the LSP and opens it in a read-only editor +tab side-by-side with the source. PhpStorm pattern: extend +`AnAction` and use `FileEditorManager.openFile` with a virtual +`LightVirtualFile`. Sized after the LSP-side design lands. + +### Plugin-side LSP capability gating (S) + +Today the plugin advertises every IntelliJ-supported LSP capability +unconditionally. When we add new server-side capabilities (or +deprecate any), the plugin's `LspServerSupportProvider` +implementation should reflect that gated by version +negotiation. Mostly hygiene; user-visible only if a future server +version drops a capability that this plugin still advertises. + +--- + +## Exploratory + +### Headless CI integration tests for the plugin + +**What it'd do.** Run a sandbox PhpStorm in headless mode against +the plugin zip in CI so regressions in rename / code lens / native +popup wiring are caught before manual prod testing. + +**Open questions.** + +- JetBrains' IDE distribution isn't licensed for headless CI use + in the open-source case. Is there a `runIde --headless` mode + that's licence-compatible, or do we need a Community-Edition + distribution as the test runner? +- Plugin Verifier covers binary API compatibility but not runtime + wiring. What's the right intermediate — JUnit-driven instances + of `LightPlatformCodeInsightFixtureTestCase` that exercise the + plugin's listeners against an in-memory `.xphp` project? +- Cost: CI minutes for the IDE boot per PR. + +**Prior art to study.** JetBrains' own `intellij-platform-plugin-template` +test suite; how Scala plugin / Kotlin plugin teams run CI; the +`IntelliJPlatformTestRunner` setup used by JB internal teams. + +**First-step spike.** Stand up a single +`LightPlatformCodeInsightFixtureTestCase` +that opens a virtual `.xphp` file and asserts the file-type +provider classifies it correctly. Measure CI minutes before +expanding. + +### Compile-on-save integration + +**What it'd do.** A run configuration that triggers +`bin/xphp compile` automatically when any `.xphp` file in the +project is saved, with the generated PHP written to the project's +`var/dist/` (or configurable). + +**Open questions.** + +- Where does "compile" live in the PhpStorm UI? A file watcher + (PhpStorm's built-in FileWatcher API), a run configuration, a + toolbar action, or a project setting? +- Per-file or whole-project? Per-file is fast but may produce + inconsistent specialization sets; whole-project is correct but + slow on every save. +- How do we surface compile errors? As LSP diagnostics on the + source file (likely), or as a separate tool window? +- Does this conflict with existing PhpStorm File Watcher users + who've configured their own `bin/xphp compile` watcher? + +**Prior art to study.** PhpStorm's built-in Sass / TypeScript / +Less compile-on-save File Watchers; the Rust plugin's "Auto-import +crates" behaviour as a precedent for background invocation of a +toolchain command. + +**First-step spike.** Wire a single FileWatcher template (XML +descriptor) for users to import manually. Validate the UX before +committing to a native action. + +### Native Kotlin lexer / parser for `.xphp` + +**What it'd do.** Replace the TextMate grammar with a full +IntelliJ PSI-aware lexer + parser written in Kotlin, unlocking: +IntelliJ-grade refactoring (extract method / inline / change +signature), structure view that reflects xphp generics natively, +custom intentions and inspections that don't go through LSP. + +**Open questions.** + +- The maintenance cost is significant — every parser-grammar change + in the upstream `xphp` parser needs to be mirrored. Is there an + intermediate where we generate the Kotlin grammar from the PHP + parser's AST shape? +- LSP already delivers most of the editor experience. What's the + marginal value of native PSI, and is it big enough to justify + the second AST? +- IntelliJ's "Grammar-Kit" plugin generates lexer + parser from a + BNF; would that close the gap with reasonable upkeep, or is a + hand-written parser the only realistic path for PHP-with-generics? + +**Prior art to study.** PhpStorm's own PHP plugin source; the +Kotlin plugin's path from JFlex / Grammar-Kit to its current +state; Rust plugin's similar tradeoff and how they chose. + +**First-step spike.** Spike a lexer-only via Grammar-Kit that +tokenises `<…>` clauses without parsing them. Measure cost + +upkeep before committing to a full parser. + +### Stack trace demangling + +**What it'd do.** When the user pastes a stack trace into +PhpStorm's "Analyze Stacktrace" dialog (or the trace appears in a +run console), recognize mangled FQNs like +`\XPHP\Generated\App\Containers\Box\T_d59a1...` and turn them +into clickable links that jump to the source template (`class Box`). + +**Open questions.** + +- Depends on the LSP exploratory item "Reverse-map mangled FQN". + This plugin-side work is just the integration once the LSP + method exists. +- PhpStorm's "Analyze Stacktrace" pipeline is extensible via + `ExceptionFilter` / `Filter` — is the right hook there, or in a + console output filter? +- Visual: do we replace the mangled segment with the human- + readable form, or add a clickable hyperlink while keeping the + original text? + +**Prior art to study.** Kotlin's stack-trace inline-class +demangling in PhpStorm's IntelliJ counterpart; Rust plugin's +`rustc-demangle` integration. + +**First-step spike.** Wait for the LSP method. Then start with the +console filter (lower-impact than rewriting the Analyze +Stacktrace dialog). + +### Specialization explorer tool window + +**What it'd do.** Bring up a docked tool window listing every +concrete instantiation of a generic template (`Box`, +`Box`, `Box` …) grouped by call site, with click-to- +navigate. + +**Open questions.** + +- Same as the LSP-side exploratory item — depends on the server + exposing the data. The plugin-side question is the IntelliJ + ToolWindow shape. +- Is there a one-tool-window-per-template view, or one global + "Specializations" window with a filter? +- Where does the entry point live — gutter icon next to + `class Box`, intention action, dedicated menu? + +**Prior art to study.** PhpStorm's existing "Type Hierarchy" +toolwindow; the IntelliJ ToolWindow API documentation. + +**First-step spike.** Wait for the LSP server endpoint. Prototype +a global "xphp Specializations" tool window populated from a +hard-coded list before wiring real data. + +--- + +## Out of scope (not on the roadmap) + +- **PhpStorm < 2026.1 support.** Locked out by `since-build = 261`. + Backporting via LSP4IJ adds significant code for an MVP. Revisit + if a meaningfully large userbase reports they're stuck on an + older PhpStorm. +- **Rider / IntelliJ IDEA / WebStorm targets.** The plugin + registers under PhpStorm-specific extension points. Multi-IDE + packaging is non-trivial and only worth it if there's demand. +- **A separate IDE plugin without the LSP path.** The LSP is the + canonical delivery channel; the plugin's value is the + IntelliJ-native niceties layered on top, not a parallel + implementation. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..819aaeb --- /dev/null +++ b/gradle.properties @@ -0,0 +1,67 @@ +# 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 + +# URL the build downloads the xphp LSP binary from at build time. The +# `downloadLspPhar` task fetches it and bundles it into the plugin jar at +# bin/xphp-lsp.phar (PharExtractor copies it into PhpStorm's system dir on +# first load). This is the single source of truth -- CI and releases read it +# straight from here; no env var or GitHub variable involved. +# +# REQUIRED: the build FAILS if this is unset or empty, by design -- a plugin +# jar must never ship without an embedded LSP. Uncomment and set it to the +# phar download URL (e.g. a GitHub release asset): +xphpLspPharUrl = https://github.com/xphp-lang/language-server/releases/download/v0.1.0/xphp-lsp.phar + +# 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/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..6ca2586 --- /dev/null +++ b/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/gradlew b/gradlew new file mode 100755 index 0000000..0972d35 --- /dev/null +++ b/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/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..8de1053 --- /dev/null +++ b/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/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..c08de47 --- /dev/null +++ b/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/src/main/kotlin/com/xphp/lsp/PharExtractor.kt b/src/main/kotlin/com/xphp/lsp/PharExtractor.kt new file mode 100644 index 0000000..62098e0 --- /dev/null +++ b/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/src/main/kotlin/com/xphp/lsp/XphpClassFileSync.kt b/src/main/kotlin/com/xphp/lsp/XphpClassFileSync.kt new file mode 100644 index 0000000..3dde06e --- /dev/null +++ b/src/main/kotlin/com/xphp/lsp/XphpClassFileSync.kt @@ -0,0 +1,181 @@ +package com.xphp.lsp + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.ProjectLocator +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.newvfs.BulkFileListener +import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent +import com.intellij.openapi.vfs.newvfs.events.VFileEvent + +/** + * Cycle L Half A (file-rename half): class rename → file follows. + * + * The textDocument/rename flow (Shift+F6) applies text edits to the + * source declaration AND every reference, but PhpStorm's LSP4IJ + * silently drops the `RenameFile` resource op our server used to + * emit alongside the text edits (ba3f52e reverted that emission + * after `failureHandling: "abort"` caused PhpStorm to abort the + * WHOLE WorkspaceEdit when it saw the unsupported op). Net effect: + * the class gets renamed everywhere, but `Foo.xphp` stays named + * `Foo.xphp` while declaring `class Bar`, breaking PSR-4 autoload. + * + * This listener closes the loop **client-side**: on every + * `VFileContentChangeEvent` for an `.xphp` / `.php` file (which + * fires after LSP4IJ applies the rename's text edits to disk), + * inspect the new content -- if the file declares exactly one + * top-level ClassLike whose short name doesn't match the basename + * stem, rename the file to match. Single-declaration PSR-4 files + * only; multi-class or non-PSR-4 layouts are left alone. + * + * Trigger sources (all caught by the same hook): + * + * - Shift+F6 → LSP textDocument/rename → text edits applied → + * content change fires here → file renamed to match new class. + * - User types a new class name in source (or pastes from + * another file) → save → content change fires → file rename. + * "Block + prompt" UX from the cycle plan: not implemented here + * because the underlying invariant (PSR-4 single-declaration + * match) is the same one PhpStorm's native PHP plugin enforces + * silently, and the user explicitly opted into that pattern by + * touching xphp source. + * + * Cycle interactions: + * + * - Half B file rename → text edits applied to the renamed file + * (class now matches new basename) → this listener fires → + * class short name == basename stem → no-op. Symmetric. + * - Two-step rename in this listener's path (rename file then + * re-edit the class): not possible -- VFS file rename fires + * `VFilePropertyChangeEvent`, not `VFileContentChangeEvent`, + * so this listener doesn't re-trigger on its own renames. + * + * Listener implements `BulkFileListener` (subscribed via + * `VirtualFileManager.VFS_CHANGES` topic) rather than + * `AsyncFileListener` because we explicitly want the AFTER hook + * (file content already written to disk), not the prepare/before + * phase Half B uses. + * + * @see XphpFileRenameListener Half B (file rename → class rename) + */ +class XphpClassFileSync : BulkFileListener { + + override fun after(events: List) { + for (event in events) { + if (event !is VFileContentChangeEvent) continue + val vfile = event.file + if (!vfile.isPsr4Candidate()) continue + // Defer the check + rename to a fresh EDT tick. Same + // rationale as Half B's `invokeLater`: VFS-change + // processing holds a read lock during `after`, and a + // WriteCommandAction inside that context would deadlock. + ApplicationManager.getApplication().invokeLater { + syncFileWithClass(vfile) + } + } + } + + private fun syncFileWithClass(vfile: VirtualFile) { + if (!vfile.isValid) return + val document = FileDocumentManager.getInstance().getDocument(vfile) ?: return + val source = document.text + val classNames = findTopLevelClassLikeNames(source) + if (classNames.size != 1) { + // 0 = no declaration; >1 = multi-class file. Both are + // outside the PSR-4 single-declaration contract. + return + } + val classShortName = classNames[0] + val basenameStem = vfile.nameWithoutExtension + if (classShortName == basenameStem) { + // Already in sync. + return + } + val targetName = "$classShortName.${vfile.extension ?: "xphp"}" + val parent = vfile.parent ?: return + if (parent.findChild(targetName) != null) { + // Target already exists -- don't overwrite a sibling. + LOG.info( + "xphp class-file sync: target $targetName already exists in ${parent.url}; " + + "leaving ${vfile.name} (class $classShortName) untouched", + ) + return + } + val project = ProjectLocator.getInstance().guessProjectForFile(vfile) + WriteCommandAction.runWriteCommandAction(project, "Sync xphp Class Filename", null, { + try { + vfile.rename(this, targetName) + } catch (e: Exception) { + LOG.warn("xphp class-file sync: rename of ${vfile.name} -> $targetName failed", e) + } + }) + } + + /** + * Scan the source for top-level ClassLike declarations (class / + * interface / trait / enum). Returns the short names of each + * top-level declaration encountered. + * + * Implementation note: this runs on every content-change tick + * for `.xphp` / `.php` files, so it has to be cheap. A real + * AST parse would be more correct but requires either spinning + * up a parser (we don't have a PSI parser for xphp on the + * client side) or round-tripping to the LSP server (slow and + * noisy in the log). Instead we use a tolerant regex tuned + * for the PSR-4 happy path: + * + * - Anchored to start of line (`^` with the `(?m)` flag) so + * declarations nested inside method bodies don't match. + * - Optional `abstract` / `final` / `readonly` modifiers. + * - Matches `class|interface|trait|enum` followed by an + * identifier (`[A-Za-z_][A-Za-z0-9_]*`). + * - Comments are not stripped -- but a `// class Foo` line is + * prefixed by `//`, not at start of line, so it won't + * match. Block-comment `class Foo` references would + * false-match; the count-1 guard makes this surface as + * "multi-declaration" and skip the rename, which is the + * safe fallback. + * + * Limitations (acceptable for V1 PSR-4 sync): + * + * - Bracketed namespaces `namespace A { class X {} }` indent + * the class declaration; with the `^` anchor we'd miss it. + * PSR-4 conventions universally use line-namespace form + * (`namespace A;`) so the class lives at column 0. + * - Heredoc / nowdoc content containing literal `class Foo` + * at column 0 would false-match. Vanishingly rare in + * practice and would just produce a multi-declaration + * skip. + */ + private fun findTopLevelClassLikeNames(source: String): List { + return DECLARATION_PATTERN.findAll(source) + .map { it.groupValues[1] } + .toList() + } + + private fun VirtualFile.isPsr4Candidate(): Boolean { + if (!isValid) return false + val ext = extension?.lowercase() ?: return false + if (ext != "xphp" && ext != "php") return false + // Skip files inside library / vendor / build / cache trees. + // ProjectLocator.guessProjectForFile returns null for files + // outside any open project; we use the same probe to filter. + return ProjectLocator.getInstance().guessProjectForFile(this) != null + } + + private companion object { + private val LOG = Logger.getInstance(XphpClassFileSync::class.java) + + /** + * `(?m)^(modifier\s+)*(class|interface|trait|enum)\s+(Name)` + * -- anchored to start of line, optional modifier prefix, + * captures the identifier. See {@link findTopLevelClassLikeNames} + * for the rationale on each piece. + */ + private val DECLARATION_PATTERN = Regex( + """(?m)^(?:(?:abstract|final|readonly)\s+)*(?:class|interface|trait|enum)\s+([A-Za-z_][A-Za-z0-9_]*)""", + ) + } +} diff --git a/src/main/kotlin/com/xphp/lsp/XphpFileRenameListener.kt b/src/main/kotlin/com/xphp/lsp/XphpFileRenameListener.kt new file mode 100644 index 0000000..9390f55 --- /dev/null +++ b/src/main/kotlin/com/xphp/lsp/XphpFileRenameListener.kt @@ -0,0 +1,346 @@ +package com.xphp.lsp + +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.editor.Document +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.vfs.AsyncFileListener +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import com.intellij.openapi.vfs.newvfs.events.VFileMoveEvent +import com.intellij.openapi.vfs.newvfs.events.VFilePropertyChangeEvent +import com.intellij.platform.lsp.api.LspServer +import com.intellij.platform.lsp.api.LspServerManager +import org.eclipse.lsp4j.DidOpenTextDocumentParams +import org.eclipse.lsp4j.FileRename +import org.eclipse.lsp4j.Position +import org.eclipse.lsp4j.RenameFilesParams +import org.eclipse.lsp4j.ResourceOperation +import org.eclipse.lsp4j.TextDocumentEdit +import org.eclipse.lsp4j.TextDocumentItem +import org.eclipse.lsp4j.TextEdit +import org.eclipse.lsp4j.WorkspaceEdit +import org.eclipse.lsp4j.jsonrpc.messages.Either +import java.util.concurrent.CompletableFuture + +/** + * Cycle L Half B: file rename -> class rename sync. + * + * IntelliJ fires VFS rename events through the + * `AsyncFileListener` extension point. For each rename of an + * `.xphp` / `.php` file, this listener sends a + * `workspace/willRenameFiles` request to the xphp LSP server, + * which returns a `WorkspaceEdit` renaming the in-source class + * declaration to match the new basename (plus every workspace + * reference to it). The plugin applies those text edits via a + * write action. + * + * Safety: the server's `XphpWillRenameFilesHandler` returns a + * null WorkspaceEdit (no edits) when the file isn't a single- + * declaration PSR-4 candidate (multi-class file, basename + * mismatch, etc.). This listener silently no-ops in those cases + * -- the file stays renamed, source stays untouched. An + * informational notification surfaces so the user knows the + * class rename didn't fire. + * + * Why `AsyncFileListener` (and not `BulkFileListener`): + * - Runs off the EDT in the prepare phase, then commits via the + * `ChangeApplier.afterVfsChange()` hook on the EDT -- the + * right contract for LSP request + write-action coordination. + * - Sees synthetic `VFilePropertyChangeEvent`s with + * `propertyName = PROP_NAME`, the canonical signal for a file + * rename (covers project-tree rename, F2, refactor-rename). + * + * Half A (class rename -> file rename) is NOT covered here. The + * xphp server emits `RenameFile` ops in `textDocument/rename` + * responses when `initializationOptions.xphpAcceptsRenameFile` + * is set (see XphpLspServerDescriptor); whether LSP4IJ's + * internal `LspWorkspaceEditApplier` actually applies them is + * a prod-test question. If not, that's the follow-up cycle. + */ +class XphpFileRenameListener : AsyncFileListener { + + override fun prepareChange(events: List): AsyncFileListener.ChangeApplier? { + // Filter to .xphp/.php rename + move events; collect (oldUri, + // newUri) pairs. Off-EDT phase: safe to read VFS attributes, + // not safe to apply writes (write actions must run on the EDT + // in the committer below). + // + // Two event shapes feed into the same FileRename pair: + // - VFilePropertyChangeEvent (PROP_NAME) -- in-place rename + // (same parent dir, different basename). Drives Half B's + // class-name update. + // - VFileMoveEvent -- cross-directory move (different parent + // dir, same OR different basename). Drives Cycle L.1's + // namespace update. Reuses the same willRenameFiles + // dispatch; the server routes pure moves through + // NamespaceMoveProvider and combined cases through the + // existing rename pipeline. + val renames = mutableListOf() + // Source bytes pre-captured per (oldUri, newUri). We read in + // prepareChange where the file is GUARANTEED to be at its old + // location with content available via VFS; the alternative is + // a race against afterVfsChange's window where neither the + // workspace nor the OS file system has settled to the new + // path (prod log xphp-20260530-183636 id=13: 1 ms null + // response because both sides of sourceFor came back empty). + // We feed these bytes to the server as a synthetic didOpen + // for the NEW URI before sending willRenameFiles, so the + // server's workspace lookup hits deterministically. + val sourcesByNewUri = mutableMapOf() + for (ev in events) { + if (ev is VFilePropertyChangeEvent + && ev.propertyName == VirtualFile.PROP_NAME + && ev.file.isXphpLike() + ) { + val oldName = ev.oldValue as? String ?: continue + val newName = ev.newValue as? String ?: continue + if (oldName == newName) continue + val parentUrl = ev.file.parent?.url ?: continue + val newUri = "$parentUrl/$newName" + renames.add(FileRename("$parentUrl/$oldName", newUri)) + ev.file.readContents()?.let { sourcesByNewUri[newUri] = it } + continue + } + if (ev is VFileMoveEvent && ev.file.isXphpLike()) { + // VFileMoveEvent's `file.url` reflects the file's + // CURRENT location, which during prepareChange() is + // still the OLD path (the VFS change hasn't applied + // yet). Construct BOTH URIs from the parents + + // file.name explicitly so we capture the actual + // (oldUri, newUri) pair rather than (oldUri, oldUri). + val basename = ev.file.name + val oldParentUrl = ev.oldParent.url + val newParentUrl = ev.newParent.url + val newUri = "$newParentUrl/$basename" + renames.add(FileRename("$oldParentUrl/$basename", newUri)) + ev.file.readContents()?.let { sourcesByNewUri[newUri] = it } + } + } + + if (renames.isEmpty()) return null + + return object : AsyncFileListener.ChangeApplier { + override fun afterVfsChange() { + // VFS rename is now applied; ask each active xphp LSP + // for the corresponding class-rename WorkspaceEdit and + // commit it under a single undoable WriteCommandAction. + applyRenames(renames, sourcesByNewUri) + } + } + } + + /** + * Read the file's current bytes via VFS, returning null on any + * read failure. Only called from `prepareChange`, where the VFS + * read lock is held and the file is still at its pre-change + * location. + */ + private fun VirtualFile.readContents(): String? { + return try { + String(contentsToByteArray(), charset) + } catch (e: Exception) { + LOG.warn("xphp file-rename: failed to pre-read source from ${this.url}", e) + null + } + } + + private fun applyRenames(renames: List, sourcesByNewUri: Map) { + // Find the xphp LSP server for each project that has one + // running. ProjectManager.openProjects scans every open + // project window -- typically one, occasionally more. + for (project in ProjectManager.getInstance().openProjects) { + val server = findXphpServer(project) ?: continue + // Seed the server's workspace with each renamed file's + // pre-captured source under its NEW URI before sending + // willRenameFiles. This bridges the + // didClose(old)→didOpen(new) gap deterministically: + // when the server's `sourceFor(newUri)` runs, workspace + // has the entry already so the lookup hits without + // needing a filesystem read against an in-flight rename. + // Without this seed, sourceFor would race against the + // OS-level rename completion and PhpStorm's own delayed + // didOpen (which arrives ~22 ms later) -- prod log + // xphp-20260530-183636 id=13 showed the failure mode. + seedWorkspaceWithPreReadSources(server, renames, sourcesByNewUri) + val edit = requestWillRenameFiles(server, renames) ?: continue + applyWorkspaceEdit(project, edit, renames) + } + } + + /** + * Send a synthetic `textDocument/didOpen` to the LSP server for + * each rename's new URI, with the source bytes captured in + * `prepareChange`. Version sentinel `0` -- when PhpStorm's + * natural `didOpen` lands (typically ~20 ms later) it ships + * version 1 and the server's workspace replaces our entry + * cleanly (PhpactorWorkspace.open is a hash assignment, no + * version-conflict path to mishandle). + * + * Fire-and-forget (notification, not request) -- `sendNotification` + * returns void; no need to await an ack before the willRenameFiles + * request follows on the same connection. Notifications are + * processed in order on the server side, so by the time + * willRenameFiles handling reads `workspace.has(newUri)`, our + * seeded entry is in place. + */ + private fun seedWorkspaceWithPreReadSources( + server: LspServer, + renames: List, + sourcesByNewUri: Map, + ) { + for (rename in renames) { + val source = sourcesByNewUri[rename.newUri] ?: continue + server.sendNotification { ls -> + ls.textDocumentService.didOpen( + DidOpenTextDocumentParams( + TextDocumentItem(rename.newUri, "xphp", 0, source), + ), + ) + } + } + } + + private fun findXphpServer(project: Project): LspServer? { + return LspServerManager.getInstance(project) + .getServersForProvider(XphpLspServerSupportProvider::class.java) + .firstOrNull() + } + + private fun requestWillRenameFiles(server: LspServer, renames: List): WorkspaceEdit? { + val params = RenameFilesParams(renames) + return try { + server.sendRequestSync(LspServer.DEFAULT_REQUEST_TIMEOUT_MS) { ls -> + @Suppress("UNCHECKED_CAST") + ls.workspaceService.willRenameFiles(params) + as CompletableFuture + } + } catch (e: Exception) { + LOG.warn("workspace/willRenameFiles request failed", e) + null + } + } + + /** + * Apply text edits from the server's WorkspaceEdit response under + * one undoable WriteCommandAction. Each TextDocumentEdit targets + * a URI we resolve back to a VirtualFile + Document; edits within + * a document are applied bottom-up (end offset descending) so + * earlier ranges don't shift while later ones are still pending. + * + * Skips RenameFile / CreateFile / DeleteFile resource operations + * -- the server doesn't emit those for `workspace/willRenameFiles` + * (the client is performing the file move) but defensively + * ignoring them keeps the code robust to future protocol drift. + * + * Threading: `AsyncFileListener.afterVfsChange` runs off-EDT with + * a read lock held. Calling `WriteCommandAction.runWriteCommandAction` + * directly from there deadlocks (read lock blocks write lock + * acquisition) and PhpStorm logs "Cannot execute background + * write action in 10 seconds" after the timeout, dropping the + * edits silently. Schedule the apply onto the EDT via + * `invokeLater` so the write action runs after VFS-change + * processing has released its read lock. + */ + private fun applyWorkspaceEdit(project: Project, edit: WorkspaceEdit, renames: List) { + val docChanges = edit.documentChanges ?: run { + notifyClassUnchanged(project, renames) + return + } + val textEdits = docChanges.mapNotNull { it.takeIf(Either::isLeft)?.left } + if (textEdits.isEmpty()) { + notifyClassUnchanged(project, renames) + return + } + + ApplicationManager.getApplication().invokeLater { + WriteCommandAction.runWriteCommandAction(project, "Rename xphp Class to Match File", null, { + for (docEdit in textEdits) { + applyTextDocumentEdit(docEdit) + } + }) + } + } + + private fun applyTextDocumentEdit(docEdit: TextDocumentEdit) { + val uri = docEdit.textDocument.uri + val vfile = resolveVirtualFile(uri) ?: run { + LOG.warn("workspace/willRenameFiles: edit targeted unresolvable URI $uri") + return + } + val document = FileDocumentManager.getInstance().getDocument(vfile) ?: run { + LOG.warn("workspace/willRenameFiles: no Document for $uri") + return + } + // Apply edits bottom-up so character offsets stay stable as + // we mutate the document. + val sorted = docEdit.edits.sortedByDescending { lspRangeToOffset(document, it.range.end) } + for (edit in sorted) { + applyTextEdit(document, edit) + } + } + + private fun applyTextEdit(document: Document, edit: TextEdit) { + val start = lspRangeToOffset(document, edit.range.start) + val end = lspRangeToOffset(document, edit.range.end) + if (start < 0 || end > document.textLength || end < start) { + LOG.warn("workspace/willRenameFiles: invalid range [$start, $end) for document length ${document.textLength}") + return + } + document.replaceString(start, end, edit.newText) + } + + /** + * LSP `Position{line, character}` (0-based, UTF-16 columns) -> + * document byte offset. IntelliJ's `Document.getLineStartOffset` + * is 0-based; `character` is taken as-is (which is wrong in the + * presence of surrogate pairs, but matches what the LSP server + * emits since it uses the same UTF-16 column convention). + */ + private fun lspRangeToOffset(document: Document, position: Position): Int { + val line = position.line.coerceAtLeast(0) + if (line >= document.lineCount) return document.textLength + return document.getLineStartOffset(line) + position.character + } + + private fun resolveVirtualFile(uri: String): VirtualFile? { + return runReadAction { + com.intellij.openapi.vfs.VirtualFileManager.getInstance().findFileByUrl(uri) + } + } + + private fun notifyClassUnchanged(project: Project, renames: List) { + // Show one info notification summarising the skipped rename. + // Reasons (multi-class file, basename mismatch, missing + // declaration) are diagnosable from the file content; we + // don't break them out individually to keep the surface + // minimal. + val sample = renames.firstOrNull() ?: return + val basename = sample.newUri.substringAfterLast('/') + ApplicationManager.getApplication().invokeLater { + NotificationGroupManager.getInstance() + .getNotificationGroup("xphp") + .createNotification( + "xphp: file renamed, class not updated", + "Couldn't automatically rename the class in $basename -- file isn't a single PSR-4 declaration. Rename the class manually with Shift+F6 if needed.", + NotificationType.INFORMATION, + ) + .notify(project) + } + } + + private fun VirtualFile.isXphpLike(): Boolean { + val ext = extension?.lowercase() ?: return false + return ext == "xphp" || ext == "php" + } + + private companion object { + private val LOG = Logger.getInstance(XphpFileRenameListener::class.java) + } +} diff --git a/src/main/kotlin/com/xphp/lsp/XphpLspServerDescriptor.kt b/src/main/kotlin/com/xphp/lsp/XphpLspServerDescriptor.kt new file mode 100644 index 0000000..c97202b --- /dev/null +++ b/src/main/kotlin/com/xphp/lsp/XphpLspServerDescriptor.kt @@ -0,0 +1,219 @@ +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 { + if (file.extension == "xphp") return true + // PHP stubs extracted by the LSP server are .php files outside + // the workspace -- e.g. `/tmp/xphp-lsp-extracted-stubs// + // Reflection/ReflectionNamedType.php`. When the LSP returns a + // Location pointing at one of them (native-class GTD, + // typeDefinition, etc.), PhpStorm asks every registered LSP + // descriptor "is this file yours?" Without this branch our + // descriptor says no, the platform finds no claimant, and + // reports "Cannot find declaration to go to" -- even though + // the LSP returned the correct stub path. + // + // We claim only the well-known extraction cache root, not + // every .php file -- those still belong to PhpStorm's native + // PHP support. The cache root is hard-coded to match + // PHP's sys_get_temp_dir() default + the prefix used by + // ReflectorFactory::extractStubsCache(). + return file.path.contains("/xphp-lsp-extracted-stubs/") + } + + // 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() { + // Client-side handler for the `editor.action.showReferences` + // command that XphpCodeLensHandler emits with pre-baked + // Location[]. Without this override PhpStorm's default + // LspCommandsSupport round-trips every command to the server + // via `workspace/executeCommand`; our server-side no-op + // returns null, the click silently fails. The override + // intercepts the specific command client-side and navigates + // directly to the first location. See + // XphpShowReferencesCommandsSupport for the rationale and + // multi-location follow-up note. + override val commandsCustomizer = XphpShowReferencesCommandsSupport() + } + + + // 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/src/main/kotlin/com/xphp/lsp/XphpLspServerSupportProvider.kt b/src/main/kotlin/com/xphp/lsp/XphpLspServerSupportProvider.kt new file mode 100644 index 0000000..a83df11 --- /dev/null +++ b/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/src/main/kotlin/com/xphp/lsp/XphpShowReferencesCommandsSupport.kt b/src/main/kotlin/com/xphp/lsp/XphpShowReferencesCommandsSupport.kt new file mode 100644 index 0000000..5d6410c --- /dev/null +++ b/src/main/kotlin/com/xphp/lsp/XphpShowReferencesCommandsSupport.kt @@ -0,0 +1,372 @@ +package com.xphp.lsp + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.LogicalPosition +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.platform.lsp.api.LspServer +import com.intellij.platform.lsp.api.customization.LspCommandsSupport +import com.intellij.ui.ColoredListCellRenderer +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.awt.RelativePoint +import org.eclipse.lsp4j.Command +import org.eclipse.lsp4j.Location +import org.eclipse.lsp4j.Position +import org.eclipse.lsp4j.ReferenceContext +import org.eclipse.lsp4j.ReferenceParams +import org.eclipse.lsp4j.TextDocumentIdentifier +import javax.swing.JList + +/** + * Client-side handler for `editor.action.showReferences` -- the + * de-facto LSP convention for "open the references panel with + * pre-baked locations" emitted by code lenses (and code actions). + * + * PhpStorm's LSP4IJ-rooted LSP adapter doesn't recognize this + * command name out of the box and falls back to a server-side + * `workspace/executeCommand` round-trip. The server we ship + * registers a no-op for the command, so before this customizer + * the click would silently do nothing -- the user's + * `2026-05-30 11:0*` prod log proved exactly that. + * + * Override here intercepts the command on the client side before + * the round-trip. Dispatch: + * + * - one location -> navigate the editor straight to it + * (no popup -- matches IntelliJ's built-in "Go to + * Implementation" UX for single-target results). + * - two or more -> pop a JBPopupFactory chooser anchored at + * the editor caret with one row per usage, rendered as + * `[file-icon] filename:line `. + * Type-to-filter is enabled via setNamerForFiltering. + * + * Arguments shape (the de-facto VS Code convention every mainline + * LSP client also recognizes): + * `[uri: string, position: Position, locations: Location[]]` + * + * For the full Find Usages tool window the user still has Alt+F7, + * which goes through the standard `textDocument/references` flow. + * This popup is a faster shortcut, not a replacement. + */ +class XphpShowReferencesCommandsSupport : LspCommandsSupport() { + + override fun executeCommand(server: LspServer, contextFile: VirtualFile, command: Command) { + if (command.command == COMMAND_NAME) { + handleShowReferences(server, command) + return + } + super.executeCommand(server, contextFile, command) + } + + private fun handleShowReferences(server: LspServer, command: Command) { + val args = command.arguments + if (args == null || args.isEmpty()) { + LOG.debug("editor.action.showReferences: missing arguments") + return + } + // Pull the lens-side position out of the command arguments so + // the multi-location popup can anchor THERE, not at the + // editor caret (which may be off in a method body while the + // user clicks the class-declaration lens above). arguments[0] + // is the URI; arguments[1] is the position the lens + // dispatches. Both shapes (3-arg VS Code, 2-arg LSP4IJ) + // carry this same prefix. + val anchorUri = if (args.size >= 1) parseString(args[0]) else null + val anchorPosition = if (args.size >= 2) parsePosition(args[1]) else null + // Two emission shapes we accept: + // - VS Code path (spec-compliant viewport-aware resolve): + // `[uri, position, locations]` -- locations baked in by + // the server's codeLens/resolve handler before render. + // - PhpStorm/LSP4IJ path (no resolve, fires the raw command): + // `[uri, position]` -- locations slot absent. We fetch + // them on demand via `textDocument/references` against the + // same server connection that just dispatched us. + val locations: List = when { + args.size >= 3 -> parseLocations(args[2]) ?: fetchLocations(server, args) + else -> fetchLocations(server, args) + } + if (locations.isEmpty()) { + LOG.debug("editor.action.showReferences: zero locations to navigate to") + return + } + val items = locations.toUsageItems() + if (items.isEmpty()) { + LOG.warn("editor.action.showReferences: every location had an unresolvable URI") + return + } + if (items.size == 1) { + items[0].navigate(server.project) + return + } + showChooserPopup(server.project, items, anchorUri, anchorPosition) + } + + /** + * Lazy fetch path: send `textDocument/references` over the live + * LSP connection. Triggered when the codeLens click carries + * `[uri, position]` only (PhpStorm/LSP4IJ -- no + * codeLens/resolve). Runs synchronously on the EDT because + * `LspCommandsSupport.executeCommand` is `@RequiresEdt`; + * `LspServer.sendRequestSync` uses the LSP server's + * default-timeout cap so a hung server can't block the UI + * indefinitely. Typical latency is the same as Alt+F7 since + * the server-side path is identical. + */ + private fun fetchLocations(server: LspServer, args: List): List { + if (args.size < 2) return emptyList() + val uri = parseString(args[0]) ?: run { + LOG.warn( + "editor.action.showReferences: arguments[0] is not a String uri " + + "(was ${args[0]?.javaClass?.simpleName})" + ) + return emptyList() + } + val position = parsePosition(args[1]) ?: run { + LOG.warn("editor.action.showReferences: arguments[1] is not a Position") + return emptyList() + } + val params = ReferenceParams( + TextDocumentIdentifier(uri), + position, + ReferenceContext(false), // includeDeclaration: false -- match codeLens count semantics + ) + return try { + val raw = server.sendRequestSync>(LspServer.DEFAULT_REQUEST_TIMEOUT_MS) { ls -> + @Suppress("UNCHECKED_CAST") + ls.textDocumentService.references(params) as java.util.concurrent.CompletableFuture> + } + raw ?: emptyList() + } catch (e: Exception) { + LOG.warn("editor.action.showReferences: textDocument/references fetch failed", e) + emptyList() + } + } + + private fun parsePosition(raw: Any?): Position? { + if (raw == null) return null + return try { + Gson().fromJson(Gson().toJsonTree(raw), Position::class.java) + } catch (e: Exception) { + null + } + } + + /** + * Extract a String from a `Command.arguments[i]` slot. lsp4j + * deserialises argument entries as `JsonElement` (more precisely + * `JsonPrimitive` for strings/numbers/bools), not raw Kotlin types + * -- a direct `as? String` cast returns null and the call fails + * silently. Round-trip via Gson so any input shape that + * represents a JSON string normalises to a Kotlin `String`. + */ + private fun parseString(raw: Any?): String? { + if (raw == null) return null + if (raw is String) return raw + return try { + val element = Gson().toJsonTree(raw) + if (element.isJsonPrimitive && element.asJsonPrimitive.isString) { + element.asString + } else { + null + } + } catch (e: Exception) { + null + } + } + + /** + * Show the multi-location chooser anchored at the lens position + * when we can resolve it -- not at the editor caret. + * + * Prior behaviour used `popup.showInBestPositionFor(editor)`, + * which anchors at the CURRENT caret. In practice the caret is + * almost never on the same line as the clicked lens (lenses + * stack on top of class / method declarations; the caret is + * usually in a method body) so the popup appeared far from + * where the user clicked. + * + * Resolution order: + * 1. If we have BOTH the lens URI (`anchorUri`) and an open + * editor for that URI, AND a Position to anchor on, convert + * `(line, character)` -> editor pixel coordinates and show + * the popup at that point, offset by one line height so it + * lands just below the lens line rather than overlapping + * the identifier itself. + * 2. Fall back to `showInBestPositionFor` against the selected + * editor (legacy behaviour) if anything in (1) is missing. + * 3. Last resort: centred in the project window. + */ + private fun showChooserPopup( + project: Project, + items: List, + anchorUri: String?, + anchorPosition: Position?, + ) { + val popup = JBPopupFactory.getInstance() + .createPopupChooserBuilder(items) + .setTitle("Usages") + .setItemChosenCallback { it.navigate(project) } + .setRenderer(UsageItemRenderer()) + // Type-to-filter: matches IntelliJ's standard chooser-popup UX. + .setNamerForFiltering { "${it.vfile.name}:${it.line + 1} ${it.preview}" } + .setRequestFocus(true) + .createPopup() + val anchorEditor = anchorUri?.let { editorForUri(project, it) } + val anchorPoint = if (anchorEditor != null && anchorPosition != null) { + computeAnchorPoint(anchorEditor, anchorPosition) + } else { + null + } + when { + anchorPoint != null -> popup.show(anchorPoint) + else -> { + val fallback = anchorEditor ?: FileEditorManager.getInstance(project).selectedTextEditor + if (fallback != null) { + popup.showInBestPositionFor(fallback) + } else { + popup.showCenteredInCurrentWindow(project) + } + } + } + } + + /** + * Look up the open editor showing `uri`, or null if the file + * isn't open. Lens clicks always come from a file the user has + * open (you can't see a lens in a closed file), so this + * resolves except in corner cases like the editor being closed + * mid-resolve. + */ + private fun editorForUri(project: Project, uri: String): Editor? { + val vfile = VirtualFileManager.getInstance().findFileByUrl(uri) ?: return null + val editors = FileEditorManager.getInstance(project).getEditors(vfile) + for (e in editors) { + val text = (e as? com.intellij.openapi.fileEditor.TextEditor)?.editor + if (text != null) return text + } + return null + } + + /** + * LSP `Position` (0-based line + 0-based UTF-16 char column) -> + * editor pixel-space `RelativePoint`. Translate down by one + * line height so the popup lands BELOW the line containing the + * identifier rather than overlapping it (which would obscure + * the source the user just clicked next to). Returns null on + * any conversion failure (e.g. position past EOF after a fast + * edit between lens render and click). + */ + private fun computeAnchorPoint(editor: Editor, position: Position): RelativePoint? { + return try { + val logical = LogicalPosition(position.line, position.character) + val xy = editor.logicalPositionToXY(logical) + xy.translate(0, editor.lineHeight) + RelativePoint(editor.contentComponent, xy) + } catch (e: Exception) { + LOG.debug("editor.action.showReferences: anchor-point conversion failed", e) + null + } + } + + /** + * Convert each `Location` to a `UsageItem`, dropping any URI we + * can't resolve to a `VirtualFile` (e.g. stale lens after the + * file was deleted). Preview text is computed eagerly via one + * VFS read per item -- negligible at codeLens scale. + */ + private fun List.toUsageItems(): List = mapNotNull { loc -> + val vfile = VirtualFileManager.getInstance().findFileByUrl(loc.uri) ?: return@mapNotNull null + UsageItem( + vfile = vfile, + line = loc.range.start.line, + character = loc.range.start.character, + preview = readPreview(vfile, loc.range.start.line), + ) + } + + /** + * `Command.arguments` is `List` after lsp4j's untyped + * Gson deserialisation -- entries are usually `JsonElement` or + * `LinkedTreeMap`. Round-trip via Gson with a typed `TypeToken` + * to coerce to `List` without depending on the precise + * runtime shape. + */ + private fun parseLocations(raw: Any?): List? { + if (raw == null) return null + val gson = Gson() + val json = gson.toJsonTree(raw) + return try { + val type = object : TypeToken>() {}.type + gson.fromJson>(json, type) + } catch (e: Exception) { + LOG.warn("editor.action.showReferences: failed to parse locations", e) + null + } + } + + /** + * One row in the chooser popup. Carries everything the renderer + * needs plus a `navigate` helper so the item-chosen callback + * stays a one-liner. + */ + private data class UsageItem( + val vfile: VirtualFile, + val line: Int, + val character: Int, + val preview: String, + ) { + fun navigate(project: Project) { + OpenFileDescriptor(project, vfile, line, character).navigate(true) + } + } + + private class UsageItemRenderer : ColoredListCellRenderer() { + override fun customizeCellRenderer( + list: JList, + value: UsageItem, + index: Int, + selected: Boolean, + hasFocus: Boolean, + ) { + icon = value.vfile.fileType.icon + // 1-based line for display -- LSP carries 0-based but IDE + // conventions surface 1-based everywhere users see it. + append("${value.vfile.name}:${value.line + 1}", SimpleTextAttributes.REGULAR_ATTRIBUTES) + if (value.preview.isNotEmpty()) { + append(" " + value.preview, SimpleTextAttributes.GRAYED_ATTRIBUTES) + } + } + } + + private companion object { + const val COMMAND_NAME = "editor.action.showReferences" + private val LOG = Logger.getInstance(XphpShowReferencesCommandsSupport::class.java) + + /** + * Read the trimmed source line at the given 0-based line index + * from a VirtualFile. Returns "" if the file is unreadable or + * the line index is past EOF. Reads the whole file once + * because VirtualFile has no random-line API; the files we + * read here are LSP-tracked source files (kB-range), so the + * full read is cheap. + */ + private fun readPreview(vfile: VirtualFile, line: Int): String { + if (line < 0) return "" + return try { + val text = String(vfile.contentsToByteArray(), vfile.charset) + val lines = text.split('\n') + if (line >= lines.size) "" else lines[line].trim() + } catch (e: Exception) { + LOG.debug("editor.action.showReferences: could not read preview for ${vfile.url}:$line", e) + "" + } + } + } +} diff --git a/src/main/kotlin/com/xphp/lsp/settings/XphpSettings.kt b/src/main/kotlin/com/xphp/lsp/settings/XphpSettings.kt new file mode 100644 index 0000000..3c04c57 --- /dev/null +++ b/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/src/main/kotlin/com/xphp/lsp/settings/XphpSettingsConfigurable.kt b/src/main/kotlin/com/xphp/lsp/settings/XphpSettingsConfigurable.kt new file mode 100644 index 0000000..af88589 --- /dev/null +++ b/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/src/main/kotlin/com/xphp/lsp/textmate/XphpBundleRegistrar.kt b/src/main/kotlin/com/xphp/lsp/textmate/XphpBundleRegistrar.kt new file mode 100644 index 0000000..aedeff7 --- /dev/null +++ b/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/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..46b97a1 --- /dev/null +++ b/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,222 @@ + + + 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/src/test/kotlin/com/xphp/lsp/PharExtractorTest.kt b/src/test/kotlin/com/xphp/lsp/PharExtractorTest.kt new file mode 100644 index 0000000..81fc2e4 --- /dev/null +++ b/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/src/test/kotlin/com/xphp/lsp/textmate/XphpBundleRegistrarTest.kt b/src/test/kotlin/com/xphp/lsp/textmate/XphpBundleRegistrarTest.kt new file mode 100644 index 0000000..662aac0 --- /dev/null +++ b/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() + } +}