From 70be53ae86f2df3fbbfa0234ab7cc08ba1c1e009 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 11 Jun 2026 22:28:27 +0000 Subject: [PATCH 01/10] fix(lsp): match the server's namespaced xphp.showReferences code-lens command The server renamed the "Show references" CodeLens command from the VS Code-internal `editor.action.showReferences` to a neutral `xphp.showReferences`. Update the client-side LspCommandsSupport interceptor (and docs) to match so lens clicks keep opening the native usage popup. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 11 ++--- docs/roadmap.md | 2 +- .../com/xphp/lsp/XphpLspServerDescriptor.kt | 19 +++++---- .../lsp/XphpShowReferencesCommandsSupport.kt | 40 ++++++++++--------- 4 files changed, 37 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index bace631..bb2a17b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/docs/roadmap.md b/docs/roadmap.md index bce6b73..7bd56f7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -30,7 +30,7 @@ For LSP-level work see [`../../lsp/docs/roadmap.md`](../../lsp/docs/roadmap.md). | **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`. | --- diff --git a/src/main/kotlin/com/xphp/lsp/XphpLspServerDescriptor.kt b/src/main/kotlin/com/xphp/lsp/XphpLspServerDescriptor.kt index c97202b..01ca09f 100644 --- a/src/main/kotlin/com/xphp/lsp/XphpLspServerDescriptor.kt +++ b/src/main/kotlin/com/xphp/lsp/XphpLspServerDescriptor.kt @@ -87,16 +87,15 @@ 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() } diff --git a/src/main/kotlin/com/xphp/lsp/XphpShowReferencesCommandsSupport.kt b/src/main/kotlin/com/xphp/lsp/XphpShowReferencesCommandsSupport.kt index 5d6410c..8402d5c 100644 --- a/src/main/kotlin/com/xphp/lsp/XphpShowReferencesCommandsSupport.kt +++ b/src/main/kotlin/com/xphp/lsp/XphpShowReferencesCommandsSupport.kt @@ -25,16 +25,18 @@ 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). + * Client-side handler for `xphp.showReferences` -- the namespaced + * command the language server emits on its "Show references" code + * lenses to "open the references panel with pre-baked locations". * - * 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. + * The JetBrains LSP adapter's default `LspCommandsSupport` round-trips + * every code-lens command to the server via `workspace/executeCommand`; + * the server intentionally does NOT register (or advertise) this + * command, so without this customizer the click would error / do + * nothing. (The command is deliberately namespaced rather than the + * VS Code-internal `editor.action.showReferences`: advertising that + * id server-side makes vscode-languageclient shadow VS Code's built-in + * peek, so each client handles a neutral id client-side instead.) * * Override here intercepts the command on the client side before * the round-trip. Dispatch: @@ -68,7 +70,7 @@ class XphpShowReferencesCommandsSupport : LspCommandsSupport() { private fun handleShowReferences(server: LspServer, command: Command) { val args = command.arguments if (args == null || args.isEmpty()) { - LOG.debug("editor.action.showReferences: missing arguments") + LOG.debug("xphp.showReferences: missing arguments") return } // Pull the lens-side position out of the command arguments so @@ -93,12 +95,12 @@ class XphpShowReferencesCommandsSupport : LspCommandsSupport() { else -> fetchLocations(server, args) } if (locations.isEmpty()) { - LOG.debug("editor.action.showReferences: zero locations to navigate to") + LOG.debug("xphp.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") + LOG.warn("xphp.showReferences: every location had an unresolvable URI") return } if (items.size == 1) { @@ -123,13 +125,13 @@ class XphpShowReferencesCommandsSupport : LspCommandsSupport() { if (args.size < 2) return emptyList() val uri = parseString(args[0]) ?: run { LOG.warn( - "editor.action.showReferences: arguments[0] is not a String uri " + + "xphp.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") + LOG.warn("xphp.showReferences: arguments[1] is not a Position") return emptyList() } val params = ReferenceParams( @@ -144,7 +146,7 @@ class XphpShowReferencesCommandsSupport : LspCommandsSupport() { } raw ?: emptyList() } catch (e: Exception) { - LOG.warn("editor.action.showReferences: textDocument/references fetch failed", e) + LOG.warn("xphp.showReferences: textDocument/references fetch failed", e) emptyList() } } @@ -270,7 +272,7 @@ class XphpShowReferencesCommandsSupport : LspCommandsSupport() { xy.translate(0, editor.lineHeight) RelativePoint(editor.contentComponent, xy) } catch (e: Exception) { - LOG.debug("editor.action.showReferences: anchor-point conversion failed", e) + LOG.debug("xphp.showReferences: anchor-point conversion failed", e) null } } @@ -306,7 +308,7 @@ class XphpShowReferencesCommandsSupport : LspCommandsSupport() { val type = object : TypeToken>() {}.type gson.fromJson>(json, type) } catch (e: Exception) { - LOG.warn("editor.action.showReferences: failed to parse locations", e) + LOG.warn("xphp.showReferences: failed to parse locations", e) null } } @@ -346,7 +348,7 @@ class XphpShowReferencesCommandsSupport : LspCommandsSupport() { } private companion object { - const val COMMAND_NAME = "editor.action.showReferences" + const val COMMAND_NAME = "xphp.showReferences" private val LOG = Logger.getInstance(XphpShowReferencesCommandsSupport::class.java) /** @@ -364,7 +366,7 @@ class XphpShowReferencesCommandsSupport : LspCommandsSupport() { 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) + LOG.debug("xphp.showReferences: could not read preview for ${vfile.url}:$line", e) "" } } From 00b1c873f28e6c2f174a7b01acccdcdc9d596c70 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 12 Jun 2026 11:14:06 +0000 Subject: [PATCH 02/10] chore: bump bundled LSP phar to v0.2.0 Co-Authored-By: Claude Opus 4.8 (1M context) --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 819aaeb..08abbab 100644 --- a/gradle.properties +++ b/gradle.properties @@ -31,7 +31,7 @@ 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.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. From b4f7e7fcad02576959ee129ae19091d0ac1cba38 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 12 Jun 2026 11:39:15 +0000 Subject: [PATCH 03/10] chore: bump pluginVersion to 0.2.0 Co-Authored-By: Claude Opus 4.8 (1M context) --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 08abbab..d9eef12 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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.0 # Plugin compatibility window. # From dcb6e8434a7da74a9135cba1b3e40aa920e2300f Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 12 Jun 2026 11:39:46 +0000 Subject: [PATCH 04/10] docs: add 0.2.0 change-notes Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main/resources/META-INF/plugin.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 66a6952..1f0c9a9 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -31,6 +31,11 @@ ]]> 0.2.0 +
    +
  • Bundle xphp language server v0.2.0.
  • +
  • Match the server's namespaced xphp.showReferences code-lens command.
  • +

0.1.0

  • MVP: basic functionality to support xphp v0.1.0
  • From 6652d6e2db1c96208fe94c57d73ef124b9f62add Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 13 Jun 2026 08:47:18 +0000 Subject: [PATCH 05/10] chore: bump bundled LSP phar to v0.2.1 Co-Authored-By: Claude Opus 4.8 (1M context) --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index d9eef12..9b96190 100644 --- a/gradle.properties +++ b/gradle.properties @@ -31,7 +31,7 @@ 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.2.0/xphp-lsp.phar +xphpLspPharUrl = https://github.com/xphp-lang/language-server/releases/download/v0.2.1/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. From 50e79f8c07389237165d8d296abbb4c5ee1de1a8 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 13 Jun 2026 09:02:53 +0000 Subject: [PATCH 06/10] refactor: remove TextMate grammar; rely on LSP semantic tokens The LSP provides semantic-token highlighting, so the bundled TextMate grammar bootstrap was redundant (and the grammar resource was never actually packaged). Drop it entirely: - Delete XphpBundleRegistrar + its test and the postStartupActivity wiring - Drop the org.jetbrains.plugins.textmate and the intellij.textmate module dependency (plugin.xml, build.gradle.kts, platformBundledPlugins) - .xphp files now open as plain text and are routed to the LSP by extension; highlighting comes from the server's semantic tokens - Refresh comments/docs that described the TextMate path Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 +- build.gradle.kts | 10 - docs/roadmap.md | 4 +- gradle.properties | 7 +- src/main/kotlin/com/xphp/lsp/PharExtractor.kt | 3 +- .../xphp/lsp/XphpLspServerSupportProvider.kt | 4 +- .../xphp/lsp/textmate/XphpBundleRegistrar.kt | 229 ------------------ src/main/resources/META-INF/plugin.xml | 59 +---- .../kotlin/com/xphp/lsp/PharExtractorTest.kt | 3 +- .../lsp/textmate/XphpBundleRegistrarTest.kt | 143 ----------- 10 files changed, 17 insertions(+), 449 deletions(-) delete mode 100644 src/main/kotlin/com/xphp/lsp/textmate/XphpBundleRegistrar.kt delete mode 100644 src/test/kotlin/com/xphp/lsp/textmate/XphpBundleRegistrarTest.kt diff --git a/README.md b/README.md index bb2a17b..7c75359 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/build.gradle.kts b/build.gradle.kts index 33bdf20..537d8e7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -64,16 +64,6 @@ dependencies { // 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() diff --git a/docs/roadmap.md b/docs/roadmap.md index 7bd56f7..5a37ed6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -26,7 +26,7 @@ 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. | @@ -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, diff --git a/gradle.properties b/gradle.properties index 9b96190..89d3dd6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -37,12 +37,7 @@ xphpLspPharUrl = https://github.com/xphp-lang/language-server/releases/download/ # 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 diff --git a/src/main/kotlin/com/xphp/lsp/PharExtractor.kt b/src/main/kotlin/com/xphp/lsp/PharExtractor.kt index 62098e0..7eb04c5 100644 --- a/src/main/kotlin/com/xphp/lsp/PharExtractor.kt +++ b/src/main/kotlin/com/xphp/lsp/PharExtractor.kt @@ -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. diff --git a/src/main/kotlin/com/xphp/lsp/XphpLspServerSupportProvider.kt b/src/main/kotlin/com/xphp/lsp/XphpLspServerSupportProvider.kt index a83df11..3d55541 100644 --- a/src/main/kotlin/com/xphp/lsp/XphpLspServerSupportProvider.kt +++ b/src/main/kotlin/com/xphp/lsp/XphpLspServerSupportProvider.kt @@ -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 { diff --git a/src/main/kotlin/com/xphp/lsp/textmate/XphpBundleRegistrar.kt b/src/main/kotlin/com/xphp/lsp/textmate/XphpBundleRegistrar.kt deleted file mode 100644 index aedeff7..0000000 --- a/src/main/kotlin/com/xphp/lsp/textmate/XphpBundleRegistrar.kt +++ /dev/null @@ -1,229 +0,0 @@ -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 index 1f0c9a9..787eeb9 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -72,29 +72,6 @@ --> 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 index 81fc2e4..efbfb52 100644 --- a/src/test/kotlin/com/xphp/lsp/PharExtractorTest.kt +++ b/src/test/kotlin/com/xphp/lsp/PharExtractorTest.kt @@ -16,8 +16,7 @@ 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 + * 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 diff --git a/src/test/kotlin/com/xphp/lsp/textmate/XphpBundleRegistrarTest.kt b/src/test/kotlin/com/xphp/lsp/textmate/XphpBundleRegistrarTest.kt deleted file mode 100644 index 662aac0..0000000 --- a/src/test/kotlin/com/xphp/lsp/textmate/XphpBundleRegistrarTest.kt +++ /dev/null @@ -1,143 +0,0 @@ -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() - } -} From 65016ff0c4e799ed72e44c5887e00a172bbe302d Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 13 Jun 2026 21:36:31 +0000 Subject: [PATCH 07/10] build: pin LSP phar sha256 and bundle server v0.2.2 Add an optional xphpLspPharSha256 input to the downloadLspPhar task: it verifies the downloaded phar against a pinned checksum (failing the build on mismatch) and tracks the sha as a second build input. That closes the gap the URL alone can't -- a release re-published at the SAME version URL with new bytes now forces a re-download instead of staying UP-TO-DATE. Point xphpLspPharUrl at the v0.2.2 release and pin its sha256. v0.2.2 also fixes the embedded serverInfo version the status bar reports. Co-Authored-By: Claude Opus 4.8 (1M context) --- build.gradle.kts | 53 +++++++++++++++++++++++++++++++++++++++++------ gradle.properties | 10 ++++++++- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 537d8e7..c3bd4b1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,6 +11,7 @@ import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType import java.net.URI +import java.security.MessageDigest plugins { id("java") @@ -137,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 { @@ -181,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)") + } } } diff --git a/gradle.properties b/gradle.properties index 89d3dd6..56e0c7e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -31,7 +31,15 @@ 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.2.1/xphp-lsp.phar +xphpLspPharUrl = https://github.com/xphp-lang/language-server/releases/download/v0.2.2/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 = f368d1a4d55110cc8ce65ea9d4161dd24dab8c303f1f7fd9ba929b9f5a931fd1 # 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. From ba63af10957bd9d2f033d1b747dc4ac7b60e3e5c Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 13 Jun 2026 21:36:41 +0000 Subject: [PATCH 08/10] feat: color xphp tokens with PhpStorm's PHP highlighting keys Map each LSP semantic-token type directly onto PhpHighlightingData keys so .xphp code is colored exactly like PHP in whatever color scheme is active (stock, New UI, "Islands", or user-customized) and follows any edit the user makes to PHP's colors. Token modifiers pick the right PHP key: static vs instance field/method-call, and declaration vs call for functions. This replaces the earlier approach of custom XPHP_* attribute keys whose colors were seeded via per-scheme additionalTextAttributes for "Default" and "Darcula" only -- that regressed to black on any other scheme (the seed attaches by exact scheme name) and never matched the user's PHP colors. The now-obsolete XphpColors, XphpColorSettingsPage, colorSchemes/*.xml, and their plugin.xml registrations are removed (those files were untracked). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/xphp/lsp/XphpLspServerDescriptor.kt | 16 ++++ .../com/xphp/lsp/XphpSemanticTokensSupport.kt | 85 +++++++++++++++++++ src/main/resources/META-INF/plugin.xml | 19 ++++- 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/com/xphp/lsp/XphpSemanticTokensSupport.kt diff --git a/src/main/kotlin/com/xphp/lsp/XphpLspServerDescriptor.kt b/src/main/kotlin/com/xphp/lsp/XphpLspServerDescriptor.kt index 01ca09f..c9cc8ad 100644 --- a/src/main/kotlin/com/xphp/lsp/XphpLspServerDescriptor.kt +++ b/src/main/kotlin/com/xphp/lsp/XphpLspServerDescriptor.kt @@ -97,6 +97,22 @@ class XphpLspServerDescriptor(project: Project) : // 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::(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() } diff --git a/src/main/kotlin/com/xphp/lsp/XphpSemanticTokensSupport.kt b/src/main/kotlin/com/xphp/lsp/XphpSemanticTokensSupport.kt new file mode 100644 index 0000000..d51e665 --- /dev/null +++ b/src/main/kotlin/com/xphp/lsp/XphpSemanticTokensSupport.kt @@ -0,0 +1,85 @@ +package com.xphp.lsp + +import com.intellij.openapi.editor.colors.TextAttributesKey +import com.intellij.platform.lsp.api.customization.LspSemanticTokensSupport +import com.jetbrains.php.lang.highlighter.PhpHighlightingData as Php + +/** + * Maps the xphp LSP server's semantic-token legend onto PhpStorm's OWN PHP + * highlighting keys ([com.jetbrains.php.lang.highlighter.PhpHighlightingData]). + * + * `.xphp` highlighting arrives entirely as LSP semantic tokens (there is no + * `XphpLanguage`/parser; parsing is delegated to the LSP), painted by the + * platform's LSP integration via the [TextAttributesKey] this returns per + * token. We point each token at the matching PHP key so xphp code is colored + * EXACTLY like PHP in whatever editor color scheme is active -- the stock + * Default/Darcula, the New UI Light/Dark, the 2026.1 "Islands" themes, or any + * scheme the user has customized. Tweak PHP's colors under Settings -> Editor + * -> Color Scheme -> PHP and xphp follows automatically, for free. + * + * This replaced an earlier approach that defined custom `XPHP_*` keys and + * seeded their colors via `` for the schemes named + * "Default" and "Darcula". That regressed to black on any other scheme + * (the seed attaches by exact scheme name), and -- by design -- never matched + * the user's PHP colors. Deferring to the PHP keys is both more robust and + * what the user actually wants. + * + * Why PHP keys carry color where the platform defaults don't: PhpStorm's PHP + * support ships explicit colors for `VAR`, `PARAMETER`, `INSTANCE_FIELD`, + * the method/function-call family, etc. in every bundled scheme -- whereas + * the generic `DefaultLanguageHighlighterColors.LOCAL_VARIABLE` / `PARAMETER` + * are colorless (default foreground). That difference is exactly why xphp + * variables rendered black before. + * + * The server's legend (from our `initialize` response), for reference: + * types: namespace type class interface enum typeParameter parameter + * variable property function method keyword modifier comment + * string number operator + * modifiers: declaration definition readonly static deprecated abstract + * + * Two modifiers refine the mapping where PHP splits a category by them: + * - `static` -> static vs instance field / method-call color + * - `declaration` -> a function NAME (`FUNCTION`) vs a call (`FUNCTION_CALL`) + * + * NOTE: this only recolors tokens the server actually EMITS. Class/method + * *reference* sites the server doesn't tokenize stay default-colored. + */ +class XphpSemanticTokensSupport : LspSemanticTokensSupport() { + + // VERIFY-AGAINST-API: the override below must match + // `LspSemanticTokensSupport` in the targeted platform (PhpStorm 2026.1 / + // build 261): `getTextAttributesKey(tokenType: String, modifiers: + // List): TextAttributesKey?`, returning null to fall back to the + // platform default. The compiler flags any mismatch. + override fun getTextAttributesKey( + tokenType: String, + modifiers: List, + ): TextAttributesKey? { + val isStatic = "static" in modifiers + val isDeclaration = "declaration" in modifiers || "definition" in modifiers + return when (tokenType) { + // Types: PHP has no generics, so `typeParameter` (and the + // namespace segment) borrow the class-reference color -- the + // closest PHP analogue and consistent with how PHP paints types. + "namespace" -> Php.CLASS + "class", "enum", "type" -> Php.CLASS + "interface" -> Php.INTERFACE + "typeParameter" -> Php.CLASS + + "parameter" -> Php.PARAMETER + "variable" -> Php.VAR + "property" -> if (isStatic) Php.STATIC_FIELD else Php.INSTANCE_FIELD + "function" -> if (isDeclaration) Php.FUNCTION else Php.FUNCTION_CALL + "method" -> if (isStatic) Php.STATIC_METHOD_CALL else Php.INSTANCE_METHOD_CALL + + "keyword", "modifier" -> Php.KEYWORD + "comment" -> Php.COMMENT + "string" -> Php.STRING + "number" -> Php.NUMBER + "operator" -> Php.OPERATION_SIGN + + // Anything outside the legend: defer to the platform default. + else -> super.getTextAttributesKey(tokenType, modifiers) + } + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 787eeb9..9d6a2b9 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -33,7 +33,7 @@ 0.2.0
      -
    • Bundle xphp language server v0.2.0.
    • +
    • Bundle xphp language server v0.2.2.
    • Match the server's namespaced xphp.showReferences code-lens command.

    0.1.0

    @@ -164,6 +164,23 @@ the LSP's semantic tokens, which the IntelliJ LSP API applies on top of the plain-text editor. --> + +