diff --git a/.gitignore b/.gitignore index 89cd7441..3afeab10 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ testgenerated/ !.idea/runConfigurations/ !.idea/scopes/ !.idea/vcs.xml +.intellijPlatform/ .vscode/ diff --git a/DEVELOPMENT.adoc b/DEVELOPMENT.adoc index 9b68be0c..ad6813c6 100644 --- a/DEVELOPMENT.adoc +++ b/DEVELOPMENT.adoc @@ -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. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1b128a6b..e33d0f67 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/pkl-internal-intellij-plugin/README.adoc b/pkl-internal-intellij-plugin/README.adoc new file mode 100644 index 00000000..0796c310 --- /dev/null +++ b/pkl-internal-intellij-plugin/README.adoc @@ -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]. diff --git a/pkl-internal-intellij-plugin/pkl-internal-intellij-plugin.gradle.kts b/pkl-internal-intellij-plugin/pkl-internal-intellij-plugin.gradle.kts new file mode 100644 index 00000000..81066f7f --- /dev/null +++ b/pkl-internal-intellij-plugin/pkl-internal-intellij-plugin.gradle.kts @@ -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")) + } +} diff --git a/pkl-internal-intellij-plugin/src/main/kotlin/org/pkl/internal/intellij/SnippetTestEditorProvider.kt b/pkl-internal-intellij-plugin/src/main/kotlin/org/pkl/internal/intellij/SnippetTestEditorProvider.kt new file mode 100644 index 00000000..622f23df --- /dev/null +++ b/pkl-internal-intellij-plugin/src/main/kotlin/org/pkl/internal/intellij/SnippetTestEditorProvider.kt @@ -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")}") + } +} diff --git a/pkl-internal-intellij-plugin/src/main/kotlin/org/pkl/internal/intellij/SnippetTestSplitEditor.kt b/pkl-internal-intellij-plugin/src/main/kotlin/org/pkl/internal/intellij/SnippetTestSplitEditor.kt new file mode 100644 index 00000000..3344330a --- /dev/null +++ b/pkl-internal-intellij-plugin/src/main/kotlin/org/pkl/internal/intellij/SnippetTestSplitEditor.kt @@ -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>(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) + } +} diff --git a/pkl-internal-intellij-plugin/src/main/resources/META-INF/plugin.xml b/pkl-internal-intellij-plugin/src/main/resources/META-INF/plugin.xml new file mode 100644 index 00000000..bcdf2076 --- /dev/null +++ b/pkl-internal-intellij-plugin/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,17 @@ + + org.pkl.intellij.snippet-test-helper + Pkl Snippet Test Helper + Apple Inc. and the Pkl project authors + + + + com.intellij.modules.platform + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index de11c831..a60753bc 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,6 +45,8 @@ include("pkl-formatter") include("pkl-gradle") +include("pkl-internal-intellij-plugin") + include("pkl-parser") include("pkl-server")