Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

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
PhpStorm-native niceties layered on top. One LSP server, two editor
integrations (this plugin and the [VS Code
extension](../vscode-extension/)).

For what's planned next, see [roadmap](./docs/roadmap.md).
Expand Down Expand Up @@ -32,8 +32,9 @@ 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.
(the server's namespaced `xphp.showReferences` command), 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
Expand Down Expand Up @@ -100,8 +101,8 @@ Plugin-only Kotlin classes that wrap or extend the standard LSP path:
- `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.
- `XphpShowReferencesCommandsSupport` -- intercepts the server's
`xphp.showReferences` code-lens command 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.
63 changes: 47 additions & 16 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType
import java.net.URI
import java.security.MessageDigest

plugins {
id("java")
Expand Down Expand Up @@ -64,16 +65,6 @@ dependencies {
// recognised as a sibling.
bundledPlugins(providers.gradleProperty("platformBundledPlugins").map { it.split(",") })

// v2 plugin model: extension points declared inside a plugin's
// `<content><module ...>` sub-module aren't reachable through a
// plain `<depends>` 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
// `<dependencies><module name="intellij.textmate"/></dependencies>`
// entry in plugin.xml).
bundledModule("intellij.textmate")

// Toolchain components used by the build / verify pipeline.
pluginVerifier()
zipSigner()
Expand Down Expand Up @@ -147,24 +138,42 @@ intellijPlatform {
// Gradle's configuration cache tracks the value and re-runs the download
// when it changes.
val xphpLspPharUrl = providers.gradleProperty("xphpLspPharUrl")
val xphpLspPharSha256 = providers.gradleProperty("xphpLspPharSha256")
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.
// Two inputs gate the download:
//
// xphpLspPharUrl -- change it and the PHAR re-downloads. Sufficient
// for the normal release flow (the URL is
// version-pinned, e.g. .../v0.2.1/xphp-lsp.phar, so
// a version bump changes the string).
// xphpLspPharSha256 -- the expected sha256 of the bytes at that URL.
// Tracking it as a SECOND input closes the gap the
// URL alone can't: when a release is RE-PUBLISHED at
// the same URL (e.g. the server team fixes the
// embedded serverInfo version and re-uploads the
// SAME v0.2.1 asset), the URL is unchanged so a
// URL-only task stays UP-TO-DATE and keeps bundling
// the stale bytes. Bumping the pinned sha forces a
// re-fetch, and the post-download check below fails
// loudly if the bytes don't match (catching a
// corrupt download or a silently-changed artifact).
//
// Both optional: sha unset => integrity check skipped, URL-only behaviour
// (unchanged from before). URL unset is still a hard, actionable failure
// at execution time in the action below.
inputs.property("xphpLspPharUrl", xphpLspPharUrl).optional(true)
inputs.property("xphpLspPharSha256", xphpLspPharSha256).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 shaProvider = xphpLspPharSha256
val outDir = downloadedPharDir

doLast {
Expand All @@ -191,6 +200,28 @@ val downloadLspPhar by tasks.registering {
phar.outputStream().use { output -> input.copyTo(output) }
}
logger.lifecycle("Downloaded xphp-lsp.phar (${phar.length()} bytes) to $phar")

// Integrity / freshness check. Only runs when a sha is pinned;
// case-insensitive compare so the gradle.properties value can be
// upper- or lower-case hex.
val expectedSha = shaProvider.orNull?.takeIf { it.isNotBlank() }
if (expectedSha != null) {
val actualSha = MessageDigest.getInstance("SHA-256")
.digest(phar.readBytes())
.joinToString("") { "%02x".format(it) }
if (!actualSha.equals(expectedSha, ignoreCase = true)) {
throw GradleException(
"xphp-lsp.phar sha256 mismatch.\n" +
" expected (xphpLspPharSha256): $expectedSha\n" +
" actual (downloaded from URL): $actualSha\n" +
"The bytes at $url do not match the pinned checksum. " +
"Either the release was re-published with different " +
"contents (update xphpLspPharSha256 in gradle.properties " +
"to the new value) or the download is corrupt/wrong."
)
}
logger.lifecycle("Verified xphp-lsp.phar sha256 ($actualSha)")
}
}
}

Expand Down
6 changes: 3 additions & 3 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ For LSP-level work see [`../../lsp/docs/roadmap.md`](../../lsp/docs/roadmap.md).

| Surface | Notes |
|-----------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`.xphp` file type recognition** | Bundled TextMate grammar wired through PhpStorm's `FileType` infrastructure. |
| **`.xphp` file type recognition** | `.xphp` files open as plain text and are routed to the LSP by extension; the LSP's semantic tokens supply highlighting. |
| **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. |
| **Code lens click → native usage popup at lens position** | `XphpShowReferencesCommandsSupport` intercepts the server's `xphp.showReferences` code-lens command 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`. |

---
Expand Down Expand Up @@ -136,7 +136,7 @@ committing to a native action.

### Native Kotlin lexer / parser for `.xphp`

**What it'd do.** Replace the TextMate grammar with a full
**What it'd do.** Replace the LSP-only plain-text model 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,
Expand Down
19 changes: 11 additions & 8 deletions gradle.properties
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Plugin coordinates surfaced to JetBrains Marketplace when we eventually publish.
pluginGroup = com.xphp.lsp
pluginName = xphp
pluginVersion = 0.1.0
pluginVersion = 0.2.1

# Plugin compatibility window.
#
Expand Down Expand Up @@ -31,18 +31,21 @@ platformVersion = 2026.1.2
# 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
xphpLspPharUrl = https://github.com/xphp-lang/language-server/releases/download/v0.2.5/xphp-lsp.phar

# OPTIONAL: expected sha256 of the bytes at xphpLspPharUrl. When set, the
# downloadLspPhar task verifies the download against it (failing the build on
# mismatch) AND tracks it as a build input -- so if a release is RE-PUBLISHED
# at the same URL (same version, new bytes), bumping this value forces a
# re-download that the URL alone would not trigger. Update it whenever
# xphpLspPharUrl changes: `sha256sum build/lsp/xphp-lsp.phar` after a fetch.
xphpLspPharSha256 = 6deca5541f25ffcceb5e82dff01cf6c33cbfcf5610dbaacf1394ae43e19ec9fc

# 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
platformBundledPlugins = com.jetbrains.php

# Toolchain. PhpStorm 2026.x runs on JBR 21; we follow.
javaVersion = 21
Expand Down
3 changes: 1 addition & 2 deletions src/main/kotlin/com/xphp/lsp/PharExtractor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ import java.security.MessageDigest
* 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
* that owns the IO state machine. The split 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.
Expand Down
35 changes: 25 additions & 10 deletions src/main/kotlin/com/xphp/lsp/XphpLspServerDescriptor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -87,17 +87,32 @@ class XphpLspServerDescriptor(project: Project) :
// 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.
// Client-side handler for the `xphp.showReferences` command
// that XphpCodeLensHandler emits with pre-baked Location[].
// Without this override PhpStorm's default LspCommandsSupport
// round-trips every code-lens command to the server via
// `workspace/executeCommand`; the server does not register the
// command, so the click would fail. The override intercepts
// the 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()

// Color .xphp semantic tokens by mapping each LSP token type onto
// PhpStorm's own PHP highlighting keys (see XphpSemanticTokensSupport),
// so xphp reads exactly like PHP in whatever color scheme is active.
// Without this customizer the platform's default token->color table
// leaves variables, type-parameters, parameters, and the class/method
// family in the editor's default foreground -- so e.g. `$asInt` and
// `int` in `$asInt = Util::identity::<int>(42)` rendered black while
// only the number `42` picked up a color.
//
// VERIFY-AGAINST-API: property name. `commandsCustomizer` above is
// the proven pattern; the semantic-tokens slot on `LspCustomization`
// is expected to be `semanticTokensCustomizer`. If the 2026.1 API
// names it differently, this is a one-word rename (the compiler
// will point at it).
override val semanticTokensCustomizer = XphpSemanticTokensSupport()
}


Expand Down
4 changes: 2 additions & 2 deletions src/main/kotlin/com/xphp/lsp/XphpLspServerSupportProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ import com.intellij.platform.lsp.api.LspServerSupportProvider
* 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).
* classify `.xphp` files internally (plain text, with the LSP's semantic
* tokens supplying highlighting).
*/
class XphpLspServerSupportProvider : LspServerSupportProvider {

Expand Down
Loading
Loading