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

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>