mirror of
https://github.com/apple/pkl.git
synced 2026-01-11 06:10:40 +01:00
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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,6 +15,7 @@ testgenerated/
|
||||
!.idea/runConfigurations/
|
||||
!.idea/scopes/
|
||||
!.idea/vcs.xml
|
||||
.intellijPlatform/
|
||||
|
||||
.vscode/
|
||||
|
||||
|
||||
@@ -90,6 +90,17 @@ This will listen on port 5005.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ graalVmSha256-linux-x64 = "1862f2ce97387a303cae4c512cb21baf36fafd2457c3cbbc10d87
|
||||
graalVmSha256-linux-aarch64 = "6c3c8b7617006c5d174d9cf7d357ccfb4bae77a4df1294ee28084fcb6eea8921"
|
||||
graalVmSha256-windows-x64 = "33ef1d186b5c1e95465fcc97e637bc26e72d5f2250a8615b9c5d667ed5c17fd0"
|
||||
ideaExtPlugin = "1.1.9"
|
||||
intellijPlugin = "2.10.1"
|
||||
intellij = "2025.2.3"
|
||||
javaPoet = "0.+"
|
||||
javaxInject = "1"
|
||||
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" }
|
||||
graalSdk = { group = "org.graalvm.sdk", name = "graal-sdk", 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" }
|
||||
javaxInject = { group = "javax.inject", name = "javax.inject", version.ref = "javaxInject" }
|
||||
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" }
|
||||
nexusPublish = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "nexusPublishPlugin" }
|
||||
shadow = { id = "com.gradleup.shadow", version.ref = "shadowPlugin" }
|
||||
intellij = { id = "org.jetbrains.intellij.platform", version.ref = "intellijPlugin" }
|
||||
|
||||
4
pkl-internal-intellij-plugin/README.adoc
Normal file
4
pkl-internal-intellij-plugin/README.adoc
Normal 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].
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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")}")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -45,6 +45,8 @@ include("pkl-formatter")
|
||||
|
||||
include("pkl-gradle")
|
||||
|
||||
include("pkl-internal-intellij-plugin")
|
||||
|
||||
include("pkl-parser")
|
||||
|
||||
include("pkl-server")
|
||||
|
||||
Reference in New Issue
Block a user