Add internal intellij plugin (#1248)

This introduces an IntelliJ plugin that's meant to assist with development of the Pkl codebase itself.

The plugin adds a file editor that opens snippet tests in a split editor pane, showing the input on the left side and output on the right side.
This commit is contained in:
Daniel Chao
2025-10-21 03:42:21 -07:00
committed by GitHub
parent f6d3fb1228
commit cce49a40fa
9 changed files with 315 additions and 0 deletions

1
.gitignore vendored
View File

@@ -15,6 +15,7 @@ testgenerated/
!.idea/runConfigurations/ !.idea/runConfigurations/
!.idea/scopes/ !.idea/scopes/
!.idea/vcs.xml !.idea/vcs.xml
.intellijPlatform/
.vscode/ .vscode/

View File

@@ -90,6 +90,17 @@ This will listen on port 5005.
Example: `./gradlew test -Djvmdebug=true` Example: `./gradlew test -Djvmdebug=true`
== Snippet Test Plugin
There is an IntelliJ plugin meant for development on the Pkl project itself.
This plugin provides a split pane window when viewing snippet tests such as LanguageSnippetTests and FormatterSnippetTests.
To install:
1. Run `./gradlew pkl-internal-intellij-plugin:buildPlugin`.
2. Within IntelliJ, run the action "Install Plugin From Disk...".
3. Select the zip file within `pkl-internal-intellij-plugin/build/distributions`.
== Resources == Resources
For automated build setup examples see our https://github.com/apple/pkl/blob/main/.circleci/[CircleCI] jobs like our https://github.com/apple/pkl/blob/main/.circleci/jobs/BuildNativeJob.pkl[BuildNativeJob.pkl], where we build Pkl automatically. For automated build setup examples see our https://github.com/apple/pkl/blob/main/.circleci/[CircleCI] jobs like our https://github.com/apple/pkl/blob/main/.circleci/jobs/BuildNativeJob.pkl[BuildNativeJob.pkl], where we build Pkl automatically.

View File

@@ -17,6 +17,8 @@ graalVmSha256-linux-x64 = "1862f2ce97387a303cae4c512cb21baf36fafd2457c3cbbc10d87
graalVmSha256-linux-aarch64 = "6c3c8b7617006c5d174d9cf7d357ccfb4bae77a4df1294ee28084fcb6eea8921" graalVmSha256-linux-aarch64 = "6c3c8b7617006c5d174d9cf7d357ccfb4bae77a4df1294ee28084fcb6eea8921"
graalVmSha256-windows-x64 = "33ef1d186b5c1e95465fcc97e637bc26e72d5f2250a8615b9c5d667ed5c17fd0" graalVmSha256-windows-x64 = "33ef1d186b5c1e95465fcc97e637bc26e72d5f2250a8615b9c5d667ed5c17fd0"
ideaExtPlugin = "1.1.9" ideaExtPlugin = "1.1.9"
intellijPlugin = "2.10.1"
intellij = "2025.2.3"
javaPoet = "0.+" javaPoet = "0.+"
javaxInject = "1" javaxInject = "1"
jansi = "2.+" jansi = "2.+"
@@ -60,6 +62,7 @@ geantyref = { group = "io.leangen.geantyref", name = "geantyref", version.ref =
graalCompiler = { group = "org.graalvm.compiler", name = "compiler", version.ref = "graalVm" } graalCompiler = { group = "org.graalvm.compiler", name = "compiler", version.ref = "graalVm" }
graalSdk = { group = "org.graalvm.sdk", name = "graal-sdk", version.ref = "graalVm" } graalSdk = { group = "org.graalvm.sdk", name = "graal-sdk", version.ref = "graalVm" }
graalJs = { group = "org.graalvm.js", name = "js", version.ref = "graalVm" } graalJs = { group = "org.graalvm.js", name = "js", version.ref = "graalVm" }
intellij = { group = "com.jetbrains.intellij.idea", name = "ideaIC", version.ref = "intellij" }
javaPoet = { group = "com.palantir.javapoet", name = "javapoet", version.ref = "javaPoet" } javaPoet = { group = "com.palantir.javapoet", name = "javapoet", version.ref = "javaPoet" }
javaxInject = { group = "javax.inject", name = "javax.inject", version.ref = "javaxInject" } javaxInject = { group = "javax.inject", name = "javax.inject", version.ref = "javaxInject" }
jansi = { group = "org.fusesource.jansi", name = "jansi", version.ref = "jansi" } jansi = { group = "org.fusesource.jansi", name = "jansi", version.ref = "jansi" }
@@ -106,3 +109,4 @@ jmh = { id = "me.champeau.jmh", version.ref = "jmhPlugin" }
kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
nexusPublish = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "nexusPublishPlugin" } nexusPublish = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "nexusPublishPlugin" }
shadow = { id = "com.gradleup.shadow", version.ref = "shadowPlugin" } shadow = { id = "com.gradleup.shadow", version.ref = "shadowPlugin" }
intellij = { id = "org.jetbrains.intellij.platform", version.ref = "intellijPlugin" }

View File

@@ -0,0 +1,4 @@
Internal IntelliJ plugin for developing the Pkl codebase.
This plugin is intentionally not published anywhere.
For usage, see the docs in link:../DEVELOPMENT.adoc[DEVELOPMENT.adoc].

View File

@@ -0,0 +1,35 @@
/*
* Copyright © 2025 Apple Inc. and the Pkl project authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
pklAllProjects
kotlin("jvm")
alias(libs.plugins.intellij)
}
repositories {
mavenCentral()
intellijPlatform { defaultRepositories() }
}
dependencies { intellijPlatform { create("IC", libs.versions.intellij.get()) } }
spotless {
kotlin {
ktfmt(libs.versions.ktfmt.get()).googleStyle()
target("src/*/kotlin/**/*.kt")
licenseHeaderFile(rootProject.file("buildSrc/src/main/resources/license-header.star-block.txt"))
}
}

View File

@@ -0,0 +1,76 @@
/*
* Copyright © 2025 Apple Inc. and the Pkl project authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.pkl.internal.intellij
import com.intellij.openapi.fileEditor.*
import com.intellij.openapi.fileEditor.impl.text.TextEditorProvider
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.vfs.VirtualFileManager
class SnippetTestEditorProvider : FileEditorProvider, DumbAware {
private val hiddenExtensionRegex = Regex(".*[.]([^.]*)[.]pkl")
override fun accept(project: Project, file: VirtualFile): Boolean {
return isSnippetTestInputFile(file) && findOutputFile(file) != null
}
override fun createEditor(project: Project, file: VirtualFile): FileEditor {
val textEditorProvider = TextEditorProvider.getInstance()
val outputFile = findOutputFile(file) ?: return textEditorProvider.createEditor(project, file)
val inputEditor = textEditorProvider.createEditor(project, file) as TextEditor
val outputEditor = textEditorProvider.createEditor(project, outputFile) as TextEditor
return SnippetTestSplitEditor(inputEditor, outputEditor)
}
override fun getEditorTypeId(): String = "snippet-test-split-editor"
override fun getPolicy(): FileEditorPolicy = FileEditorPolicy.HIDE_DEFAULT_EDITOR
private fun isSnippetTestInputFile(file: VirtualFile): Boolean {
val path = file.path
return path.contains("/src/test/files/") && path.contains("/input/") && file.extension == "pkl"
}
private fun possibleOutputPaths(testType: String, relativePath: String): String? {
return when (testType) {
"LanguageSnippetTests" ->
if (relativePath.matches(hiddenExtensionRegex)) relativePath.dropLast(4)
else relativePath.dropLast(3) + "pcf"
"FormatterSnippetTests" -> relativePath
"SnippetTests" -> relativePath.replaceAfterLast('.', "yaml")
else -> null
}
}
private fun findOutputFile(inputFile: VirtualFile): VirtualFile? {
val path = inputFile.path
val inputPattern = Regex(".*/src/test/files/(\\w+)/input/(.+)$")
val match = inputPattern.find(path) ?: return null
val testType = match.groupValues[1]
val relativePath = match.groupValues[2]
val relativeOutputPath = possibleOutputPaths(testType, relativePath) ?: return null
val outputPath = path.replace("/input/$relativePath", "/output/$relativeOutputPath")
val fileManager = VirtualFileManager.getInstance()
return fileManager.findFileByUrl("file://$outputPath")
?: fileManager.findFileByUrl("file://${outputPath.replaceAfterLast('.', "err")}")
}
}

View File

@@ -0,0 +1,165 @@
/*
* Copyright © 2025 Apple Inc. and the Pkl project authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.pkl.internal.intellij
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.*
import com.intellij.openapi.fileEditor.FileEditor
import com.intellij.openapi.fileEditor.FileEditorLocation
import com.intellij.openapi.fileEditor.FileEditorState
import com.intellij.openapi.fileEditor.FileEditorStateLevel
import com.intellij.openapi.fileEditor.TextEditor
import com.intellij.openapi.util.UserDataHolderBase
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.ui.components.JBPanel
import java.awt.BorderLayout
import java.beans.PropertyChangeListener
import javax.swing.JComponent
import javax.swing.JSplitPane
class SnippetTestSplitEditor(
private val inputEditor: TextEditor,
private val outputEditor: TextEditor,
) : UserDataHolderBase(), FileEditor {
private var currentViewMode = ViewMode.SPLIT
private val splitPane: JSplitPane =
JSplitPane(JSplitPane.HORIZONTAL_SPLIT, inputEditor.component, outputEditor.component).apply {
resizeWeight = 0.5
}
private val mainPanel =
JBPanel<JBPanel<*>>(BorderLayout()).apply {
add(createToolbar(), BorderLayout.NORTH)
add(splitPane, BorderLayout.CENTER)
}
private fun createToolbar(): JComponent {
val actionGroup =
DefaultActionGroup().apply {
add(ShowInputOnlyAction())
add(ShowSplitAction())
}
val toolbar =
ActionManager.getInstance().createActionToolbar("SnippetTestEditor", actionGroup, true)
toolbar.targetComponent = mainPanel
return toolbar.component
}
private fun setViewMode(mode: ViewMode) {
if (currentViewMode == mode) return
currentViewMode = mode
// Remove the current center component
val layout = mainPanel.layout as BorderLayout
layout.getLayoutComponent(BorderLayout.CENTER)?.let { mainPanel.remove(it) }
when (mode) {
ViewMode.INPUT_ONLY -> {
mainPanel.add(inputEditor.component, BorderLayout.CENTER)
}
ViewMode.SPLIT -> {
// Re-add components to splitPane in case they were removed
splitPane.leftComponent = inputEditor.component
splitPane.rightComponent = outputEditor.component
mainPanel.add(splitPane, BorderLayout.CENTER)
}
}
mainPanel.revalidate()
mainPanel.repaint()
}
override fun getComponent(): JComponent = mainPanel
override fun getPreferredFocusedComponent(): JComponent? = inputEditor.preferredFocusedComponent
override fun getName(): String = "Snippet Test"
override fun getFile(): VirtualFile? = inputEditor.file
override fun setState(state: FileEditorState) {
if (state is SnippetTestSplitEditorState) {
inputEditor.setState(state.inputState)
outputEditor.setState(state.outputState)
}
}
override fun getState(level: FileEditorStateLevel): FileEditorState {
return SnippetTestSplitEditorState(inputEditor.getState(level), outputEditor.getState(level))
}
override fun isModified(): Boolean = inputEditor.isModified || outputEditor.isModified
override fun isValid(): Boolean = inputEditor.isValid && outputEditor.isValid
override fun addPropertyChangeListener(listener: PropertyChangeListener) {
inputEditor.addPropertyChangeListener(listener)
outputEditor.addPropertyChangeListener(listener)
}
override fun removePropertyChangeListener(listener: PropertyChangeListener) {
inputEditor.removePropertyChangeListener(listener)
outputEditor.removePropertyChangeListener(listener)
}
override fun getCurrentLocation(): FileEditorLocation? = inputEditor.currentLocation
override fun dispose() {
inputEditor.dispose()
outputEditor.dispose()
}
private enum class ViewMode {
INPUT_ONLY,
SPLIT,
}
private inner class ShowInputOnlyAction :
ToggleAction("Show Input Only", "Show only the input file", AllIcons.General.LayoutEditorOnly) {
override fun isSelected(e: AnActionEvent): Boolean = currentViewMode == ViewMode.INPUT_ONLY
override fun setSelected(e: AnActionEvent, state: Boolean) {
if (state) setViewMode(ViewMode.INPUT_ONLY)
}
}
private inner class ShowSplitAction :
ToggleAction(
"Show Split",
"Show input and output side by side",
AllIcons.General.LayoutEditorPreview,
) {
override fun isSelected(e: AnActionEvent): Boolean = currentViewMode == ViewMode.SPLIT
override fun setSelected(e: AnActionEvent, state: Boolean) {
if (state) setViewMode(ViewMode.SPLIT)
}
}
}
data class SnippetTestSplitEditorState(
val inputState: FileEditorState,
val outputState: FileEditorState,
) : FileEditorState {
override fun canBeMergedWith(otherState: FileEditorState, level: FileEditorStateLevel): Boolean {
return otherState is SnippetTestSplitEditorState &&
inputState.canBeMergedWith(otherState.inputState, level) &&
outputState.canBeMergedWith(otherState.outputState, level)
}
}

View File

@@ -0,0 +1,17 @@
<idea-plugin>
<id>org.pkl.intellij.snippet-test-helper</id>
<name>Pkl Snippet Test Helper</name>
<vendor>Apple Inc. and the Pkl project authors</vendor>
<description><![CDATA[
Automatically opens snippet test output files when input files are opened.
This is an internal plugin that is meant for development on the Pkl project itself.
]]></description>
<depends>com.intellij.modules.platform</depends>
<extensions defaultExtensionNs="com.intellij">
<fileEditorProvider implementation="org.pkl.intellij.SnippetTestEditorProvider"/>
</extensions>
</idea-plugin>

View File

@@ -45,6 +45,8 @@ include("pkl-formatter")
include("pkl-gradle") include("pkl-gradle")
include("pkl-internal-intellij-plugin")
include("pkl-parser") include("pkl-parser")
include("pkl-server") include("pkl-server")