diff --git a/README.md b/README.md index bace631..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). @@ -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/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 bce6b73..5a37ed6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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`. | --- @@ -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 819aaeb..89d3dd6 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. # @@ -31,18 +31,13 @@ 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.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. # 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/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/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/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) "" } } 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 46b97a1..787eeb9 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -18,20 +18,27 @@ xphp, a PHP - superset with monomorphized generics that compiles to vanilla 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.

+ by the bundled xphp Language Server over the IntelliJ Platform LSP API. +

- Requires PhpStorm 2026.1 or later. + Requires:

+ - PHP 8.4+
+ - PhpStorm 2026.1 or later. ]]>
0.2.0 +
    +
  • Bundle xphp language server v0.2.0.
  • +
  • Match the server's namespaced xphp.showReferences code-lens command.
  • +

0.1.0

    -
  • Initial scaffold. No editor functionality yet.
  • +
  • MVP: basic functionality to support xphp v0.1.0
]]>
@@ -65,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() - } -}