├── .editorconfig ├── .gitignore ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── settings.gradle.kts ├── src ├── main │ ├── kotlin │ │ └── com │ │ │ └── dprint │ │ │ ├── services │ │ │ ├── editorservice │ │ │ │ ├── exceptions │ │ │ │ │ ├── ProcessUnavailableException.kt │ │ │ │ │ ├── HandlerNotImplementedException.kt │ │ │ │ │ └── UnsupportedMessagePartException.kt │ │ │ │ ├── FormatResult.kt │ │ │ │ ├── v5 │ │ │ │ │ ├── MessageType.kt │ │ │ │ │ ├── IncomingMessage.kt │ │ │ │ │ ├── OutgoingMessage.kt │ │ │ │ │ ├── MessageChannel.kt │ │ │ │ │ ├── StdoutListener.kt │ │ │ │ │ └── EditorServiceV5.kt │ │ │ │ ├── process │ │ │ │ │ ├── StdErrListener.kt │ │ │ │ │ └── EditorProcess.kt │ │ │ │ ├── IEditorService.kt │ │ │ │ ├── EditorServiceCache.kt │ │ │ │ ├── v4 │ │ │ │ │ └── EditorServiceV4.kt │ │ │ │ └── EditorServiceInitializer.kt │ │ │ ├── FormatterService.kt │ │ │ └── DprintTaskExecutor.kt │ │ │ ├── formatter │ │ │ ├── DprintDocumentMerger.kt │ │ │ ├── DprintFormattingTask.kt │ │ │ ├── DprintRangeFormattingTask.kt │ │ │ └── DprintExternalFormatter.kt │ │ │ ├── i18n │ │ │ └── DprintBundle.kt │ │ │ ├── messages │ │ │ ├── DprintMessage.kt │ │ │ └── DprintAction.kt │ │ │ ├── listeners │ │ │ ├── ProjectStartupListener.kt │ │ │ ├── ConfigChangedAction.kt │ │ │ ├── FileOpenedListener.kt │ │ │ └── OnSaveAction.kt │ │ │ ├── config │ │ │ ├── BaseConfiguration.kt │ │ │ ├── UserConfiguration.kt │ │ │ └── ProjectConfiguration.kt │ │ │ ├── actions │ │ │ ├── RestartAction.kt │ │ │ ├── ClearCacheAction.kt │ │ │ └── ReformatAction.kt │ │ │ ├── toolwindow │ │ │ ├── ConsoleToolWindowFactory.kt │ │ │ └── Console.kt │ │ │ ├── utils │ │ │ ├── LogUtils.kt │ │ │ └── FileUtils.kt │ │ │ └── lifecycle │ │ │ └── DprintPluginLifecycleManager.kt │ └── resources │ │ ├── META-INF │ │ ├── toolWindowIcon.svg │ │ ├── toolWindowIcon_dark.svg │ │ ├── toolWindowIcon@20x20.svg │ │ ├── toolWindowIcon@20x20_dark.svg │ │ ├── toolWindowIcon_selected.svg │ │ ├── toolWindowIcon_selected_dark.svg │ │ ├── toolWindowIcon@20x20_selected.svg │ │ ├── toolWindowIcon@20x20_selected_dark.svg │ │ └── plugin.xml │ │ └── messages │ │ └── Bundle.properties └── test │ └── kotlin │ └── com │ └── dprint │ ├── services │ ├── editorservice │ │ ├── v5 │ │ │ ├── IncomingMessageTest.kt │ │ │ └── OutgoingMessageTest.kt │ │ ├── EditorServiceInitializerTest.kt │ │ ├── process │ │ │ └── EditorProcessTest.kt │ │ └── EditorServiceCacheTest.kt │ ├── FormatterServiceTest.kt │ └── DprintServiceUnitTest.kt │ └── formatter │ └── DprintFormattingTaskTest.kt ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── build.yml ├── .run ├── Run Plugin Tests.run.xml ├── Run IDE with Plugin.run.xml └── Run Plugin Verification.run.xml ├── LICENSE ├── gradle.properties ├── gradlew.bat ├── README.md ├── CHANGELOG.md ├── CLAUDE.md └── gradlew /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{kt,kts}] 2 | max_line_length = 120 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .intellijPlatform 2 | .gradle 3 | .kotlin 4 | .idea 5 | build 6 | **/.DS_Store 7 | .claude/ -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dprint/dprint-intellij/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" 3 | } 4 | 5 | rootProject.name = "dprint" 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/services/editorservice/exceptions/ProcessUnavailableException.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services.editorservice.exceptions 2 | 3 | class ProcessUnavailableException( 4 | message: String, 5 | ) : Exception(message) 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/services/editorservice/exceptions/HandlerNotImplementedException.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services.editorservice.exceptions 2 | 3 | class HandlerNotImplementedException( 4 | message: String, 5 | ) : Exception(message) 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/services/editorservice/exceptions/UnsupportedMessagePartException.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services.editorservice.exceptions 2 | 3 | class UnsupportedMessagePartException( 4 | message: String, 5 | ) : Exception(message) 6 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/toolWindowIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | D 3 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/toolWindowIcon_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | D 3 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/toolWindowIcon@20x20.svg: -------------------------------------------------------------------------------- 1 | 2 | D 3 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/toolWindowIcon@20x20_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | D 3 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/toolWindowIcon_selected.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | D 4 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/toolWindowIcon_selected_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | D 4 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/toolWindowIcon@20x20_selected.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | D 4 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/toolWindowIcon@20x20_selected_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | D 4 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/services/editorservice/FormatResult.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services.editorservice 2 | 3 | /** 4 | * The resulting state of running the Dprint formatter. 5 | * 6 | * If both parameters are null, it represents a no-op from the format operation. 7 | */ 8 | data class FormatResult( 9 | val formattedContent: String? = null, 10 | val error: String? = null, 11 | ) 12 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/formatter/DprintDocumentMerger.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.formatter 2 | 3 | import com.intellij.formatting.service.DocumentMerger 4 | import com.intellij.openapi.editor.Document 5 | 6 | class DprintDocumentMerger : DocumentMerger { 7 | override fun updateDocument( 8 | document: Document, 9 | newText: String, 10 | ): Boolean { 11 | if (document.isWritable) { 12 | document.setText(newText) 13 | return true 14 | } 15 | 16 | return false 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/i18n/DprintBundle.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.i18n 2 | 3 | import com.intellij.AbstractBundle 4 | import org.jetbrains.annotations.NonNls 5 | import org.jetbrains.annotations.PropertyKey 6 | 7 | @NonNls 8 | private const val DPRINT_BUNDLE = "messages.Bundle" 9 | 10 | object DprintBundle : AbstractBundle(DPRINT_BUNDLE) { 11 | @Suppress("SpreadOperator") 12 | @JvmStatic 13 | fun message( 14 | @PropertyKey(resourceBundle = DPRINT_BUNDLE) key: String, 15 | vararg params: Any, 16 | ) = getMessage(key, *params) 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/services/editorservice/v5/MessageType.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services.editorservice.v5 2 | 3 | enum class MessageType( 4 | val intValue: Int, 5 | ) { 6 | /** 7 | * Used when pending messages are drained due to a shutdow so that the handlers can be completed with no action. 8 | */ 9 | Dropped(-1), 10 | SuccessResponse(0), 11 | ErrorResponse(1), 12 | ShutDownProcess(2), 13 | Active(3), 14 | CanFormat(4), 15 | CanFormatResponse(5), 16 | FormatFile(6), 17 | FormatFileResponse(7), 18 | CancelFormat(8), 19 | } 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Dependabot configuration: 2 | # https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | # Maintain dependencies for Gradle dependencies 7 | - package-ecosystem: "gradle" 8 | directory: "/" 9 | target-branch: "next" 10 | schedule: 11 | interval: "daily" 12 | # Maintain dependencies for GitHub Actions 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | target-branch: "next" 16 | schedule: 17 | interval: "daily" 18 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/messages/DprintMessage.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.messages 2 | 3 | import com.intellij.util.messages.Topic 4 | 5 | /** 6 | * A message for the internal IntelliJ message bus that allows us to push logging information to a tool window 7 | */ 8 | interface DprintMessage { 9 | companion object { 10 | val DPRINT_MESSAGE_TOPIC = Topic("DPRINT_EVENT_MESSAGE", Listener::class.java) 11 | } 12 | 13 | interface Listener { 14 | fun info(message: String) 15 | 16 | fun warn(message: String) 17 | 18 | fun error(message: String) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/services/editorservice/v5/IncomingMessage.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services.editorservice.v5 2 | 3 | import java.nio.ByteBuffer 4 | 5 | private const val U32_BYTE_SIZE = 4 6 | 7 | class IncomingMessage( 8 | private val buffer: ByteArray, 9 | ) { 10 | private var index = 0 11 | 12 | fun readInt(): Int { 13 | val int = ByteBuffer.wrap(buffer, index, U32_BYTE_SIZE).int 14 | index += U32_BYTE_SIZE 15 | return int 16 | } 17 | 18 | fun readSizedString(): String { 19 | val length = readInt() 20 | val content = buffer.copyOfRange(index, index + length).decodeToString() 21 | index += length 22 | return content 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/listeners/ProjectStartupListener.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.listeners 2 | 3 | import com.dprint.config.ProjectConfiguration 4 | import com.dprint.services.DprintService 5 | import com.dprint.toolwindow.Console 6 | import com.intellij.openapi.components.service 7 | import com.intellij.openapi.project.Project 8 | import com.intellij.openapi.startup.ProjectActivity 9 | 10 | class ProjectStartupListener : ProjectActivity { 11 | override suspend fun execute(project: Project) { 12 | val projectConfig = project.service() 13 | if (!projectConfig.state.enabled) { 14 | return 15 | } 16 | 17 | val dprintService = project.service() 18 | project.service() 19 | dprintService.initializeEditorService() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/config/BaseConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.config 2 | 3 | import com.intellij.openapi.components.PersistentStateComponent 4 | 5 | /** 6 | * Base class for configuration that eliminates boilerplate while maintaining type safety. 7 | * Subclasses only need to define their State class and specify storage details via annotations. 8 | */ 9 | abstract class BaseConfiguration : PersistentStateComponent { 10 | protected abstract fun createDefaultState(): T 11 | 12 | private var internalState: T? = null 13 | 14 | override fun getState(): T { 15 | if (internalState == null) { 16 | internalState = createDefaultState() 17 | } 18 | return internalState!! 19 | } 20 | 21 | override fun loadState(state: T) { 22 | internalState = state 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/config/UserConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.config 2 | 3 | import com.intellij.openapi.components.Service 4 | import com.intellij.openapi.components.State 5 | import com.intellij.openapi.components.Storage 6 | 7 | /** 8 | * User-level configuration for personal preferences. 9 | * These settings are stored in .idea/dprintUserConfig.xml and should NOT be checked into version control. 10 | */ 11 | @Service(Service.Level.PROJECT) 12 | @State(name = "DprintUserConfiguration", storages = [Storage("dprintUserConfig.xml")]) 13 | class UserConfiguration : BaseConfiguration() { 14 | data class State( 15 | var runOnSave: Boolean = false, 16 | var overrideIntelliJFormatter: Boolean = true, 17 | var enableEditorServiceVerboseLogging: Boolean = true, 18 | ) 19 | 20 | override fun createDefaultState(): State = State() 21 | } 22 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/config/ProjectConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.config 2 | 3 | import com.intellij.openapi.components.Service 4 | import com.intellij.openapi.components.State 5 | import com.intellij.openapi.components.Storage 6 | 7 | /** 8 | * Project-level configuration that should be consistent across the team. 9 | * These settings are stored in .idea/dprintProjectConfig.xml and can be checked into version control. 10 | */ 11 | @Service(Service.Level.PROJECT) 12 | @State(name = "DprintProjectConfiguration", storages = [Storage("dprintProjectConfig.xml")]) 13 | class ProjectConfiguration : BaseConfiguration() { 14 | data class State( 15 | var enabled: Boolean = false, 16 | var configLocation: String = "", 17 | var executableLocation: String = "", 18 | var initialisationTimeout: Long = 10_000, 19 | var commandTimeout: Long = 5_000, 20 | ) 21 | 22 | override fun createDefaultState(): State = State() 23 | } 24 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/actions/RestartAction.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.actions 2 | 3 | import com.dprint.config.ProjectConfiguration 4 | import com.dprint.i18n.DprintBundle 5 | import com.dprint.services.DprintService 6 | import com.dprint.utils.infoLogWithConsole 7 | import com.intellij.openapi.actionSystem.AnAction 8 | import com.intellij.openapi.actionSystem.AnActionEvent 9 | import com.intellij.openapi.components.service 10 | import com.intellij.openapi.diagnostic.logger 11 | 12 | private val LOGGER = logger() 13 | 14 | /** 15 | * This action will restart the editor service when invoked 16 | */ 17 | class RestartAction : AnAction() { 18 | override fun actionPerformed(event: AnActionEvent) { 19 | event.project?.let { 20 | val enabled = it.service().state.enabled 21 | if (!enabled) return@let 22 | infoLogWithConsole(DprintBundle.message("restart.action.run"), it, LOGGER) 23 | it.service().restartEditorService() 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/actions/ClearCacheAction.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.actions 2 | 3 | import com.dprint.config.ProjectConfiguration 4 | import com.dprint.i18n.DprintBundle 5 | import com.dprint.services.DprintService 6 | import com.dprint.utils.infoLogWithConsole 7 | import com.intellij.openapi.actionSystem.AnAction 8 | import com.intellij.openapi.actionSystem.AnActionEvent 9 | import com.intellij.openapi.components.service 10 | import com.intellij.openapi.diagnostic.logger 11 | 12 | private val LOGGER = logger() 13 | 14 | /** 15 | * This action clears the cache of canFormat results. Useful if config changes have been made. 16 | */ 17 | class ClearCacheAction : AnAction() { 18 | override fun actionPerformed(event: AnActionEvent) { 19 | event.project?.let { 20 | val projectConfig = it.service().state 21 | if (!projectConfig.enabled) return@let 22 | infoLogWithConsole(DprintBundle.message("clear.cache.action.run"), it, LOGGER) 23 | it.service().clearCanFormatCache() 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.run/Run Plugin Tests.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 17 | 19 | true 20 | true 21 | false 22 | 23 | 24 | -------------------------------------------------------------------------------- /.run/Run IDE with Plugin.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 17 | 19 | true 20 | true 21 | false 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/toolwindow/ConsoleToolWindowFactory.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.toolwindow 2 | 3 | import com.intellij.openapi.components.service 4 | import com.intellij.openapi.project.DumbAware 5 | import com.intellij.openapi.project.Project 6 | import com.intellij.openapi.ui.SimpleToolWindowPanel 7 | import com.intellij.openapi.wm.ToolWindow 8 | import com.intellij.openapi.wm.ToolWindowFactory 9 | import com.intellij.ui.content.ContentFactory 10 | 11 | class ConsoleToolWindowFactory : 12 | ToolWindowFactory, 13 | DumbAware { 14 | override fun createToolWindowContent( 15 | project: Project, 16 | toolWindow: ToolWindow, 17 | ) { 18 | val console = project.service() 19 | val contentFactory = ContentFactory.getInstance() 20 | val panel = SimpleToolWindowPanel(true, false) 21 | panel.setContent(console.consoleView.component) 22 | val content = contentFactory.createContent(panel, "", false) 23 | toolWindow.contentManager.addContent(content) 24 | } 25 | 26 | override suspend fun isApplicableAsync(project: Project): Boolean = true 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Canva 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # IntelliJ Platform Artifacts Repositories 2 | # -> https://plugins.jetbrains.com/docs/intellij/intellij-artifacts.html 3 | pluginGroup=com.dprint.intellij.plugin 4 | pluginName=dprint 5 | pluginVersion=0.9.0 6 | # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html 7 | # for insight into build numbers and IntelliJ Platform versions. 8 | pluginSinceBuild=251 9 | platformType=IU 10 | platformVersion=2025.1 11 | platformDownloadSources=true 12 | # Opt-out flag for bundling Kotlin standard library. 13 | # See https://plugins.jetbrains.com/docs/intellij/kotlin.html#kotlin-standard-library for details. 14 | kotlin.stdlib.default.dependency=false 15 | org.gradle.jvmargs=-XX\:MaxHeapSize\=4096m -Xmx4096m 16 | # Gradle Releases -> https://github.com/gradle/gradle/releases 17 | gradleVersion=9.2.1 18 | # https://github.com/gradle/gradle/issues/20416 19 | systemProp.org.gradle.kotlin.dsl.precompiled.accessors.strict=tru 20 | # Parallel execution 21 | org.gradle.parallel=true 22 | org.gradle.workers.max=4 23 | # Build cache 24 | org.gradle.caching=true 25 | # Configuration cache (if compatible) 26 | org.gradle.configuration-cache=true -------------------------------------------------------------------------------- /src/test/kotlin/com/dprint/services/editorservice/v5/IncomingMessageTest.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services.editorservice.v5 2 | 3 | import io.kotest.core.spec.style.FunSpec 4 | import io.kotest.matchers.shouldBe 5 | import java.nio.ByteBuffer 6 | 7 | class IncomingMessageTest : 8 | FunSpec({ 9 | test("It decodes an int") { 10 | val int = 7 11 | val buffer = ByteBuffer.allocate(4) 12 | buffer.putInt(7) 13 | val incomingMessage = IncomingMessage(buffer.array()) 14 | incomingMessage.readInt() shouldBe int 15 | } 16 | 17 | test("It decodes a string") { 18 | val text = "blah!" 19 | val textAsByteArray = text.encodeToByteArray() 20 | val sizeBuffer = ByteBuffer.allocate(4) 21 | sizeBuffer.putInt(textAsByteArray.size) 22 | val buffer = ByteBuffer.allocate(4 + textAsByteArray.size) 23 | // Need to call array here so the 0's get copied into the new buffer 24 | buffer.put(sizeBuffer.array()) 25 | buffer.put(textAsByteArray) 26 | val incomingMessage = IncomingMessage(buffer.array()) 27 | incomingMessage.readSizedString() shouldBe text 28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /.run/Run Plugin Verification.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 17 | 19 | true 20 | true 21 | false 22 | 23 | 25 | 26 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/listeners/ConfigChangedAction.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.listeners 2 | 3 | import com.dprint.config.ProjectConfiguration 4 | import com.dprint.i18n.DprintBundle 5 | import com.dprint.services.DprintService 6 | import com.dprint.utils.infoLogWithConsole 7 | import com.intellij.ide.actionsOnSave.impl.ActionsOnSaveFileDocumentManagerListener 8 | import com.intellij.openapi.components.service 9 | import com.intellij.openapi.diagnostic.logger 10 | import com.intellij.openapi.editor.Document 11 | import com.intellij.openapi.fileEditor.FileDocumentManager 12 | import com.intellij.openapi.project.Project 13 | 14 | private val LOGGER = logger() 15 | 16 | /** 17 | * This listener restarts the editor service if the config file is updated. 18 | */ 19 | class ConfigChangedAction : ActionsOnSaveFileDocumentManagerListener.ActionOnSave() { 20 | override fun isEnabledForProject(project: Project): Boolean = project.service().state.enabled 21 | 22 | override fun processDocuments( 23 | project: Project, 24 | documents: Array, 25 | ) { 26 | val dprintService = project.service() 27 | val manager = FileDocumentManager.getInstance() 28 | for (document in documents) { 29 | manager.getFile(document)?.let { vfile -> 30 | if (vfile.path == dprintService.getConfigPath()) { 31 | infoLogWithConsole(DprintBundle.message("config.changed.run"), project, LOGGER) 32 | dprintService.restartEditorService() 33 | } 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/services/FormatterService.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services 2 | 3 | import com.dprint.i18n.DprintBundle 4 | import com.dprint.services.editorservice.FormatResult 5 | import com.dprint.utils.isFormattableFile 6 | import com.intellij.openapi.command.WriteCommandAction 7 | import com.intellij.openapi.components.Service 8 | import com.intellij.openapi.components.service 9 | import com.intellij.openapi.editor.Document 10 | import com.intellij.openapi.project.Project 11 | import com.intellij.openapi.vfs.VirtualFile 12 | 13 | /** 14 | * A project service that handles reading virtual files, formatting their contents and writing the formatted result. 15 | */ 16 | @Service(Service.Level.PROJECT) 17 | class FormatterService( 18 | private val project: Project, 19 | ) { 20 | fun format( 21 | virtualFile: VirtualFile, 22 | document: Document, 23 | ) { 24 | val content = document.text 25 | val filePath = virtualFile.path 26 | if (content.isBlank() || !isFormattableFile(project, virtualFile)) return 27 | 28 | val dprintService = project.service() 29 | if (dprintService.canFormatCached(filePath) == true) { 30 | val formatHandler: (FormatResult) -> Unit = { 31 | it.formattedContent?.let { 32 | WriteCommandAction.runWriteCommandAction(project) { 33 | document.setText(it) 34 | } 35 | } 36 | } 37 | 38 | dprintService.format(filePath, content, formatHandler) 39 | } else { 40 | DprintBundle.message("formatting.cannot.format", filePath) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | changelog = "2.5.0" 3 | commonsCollection4 = "4.5.0" 4 | gradleIntelliJPlugin = "2.10.5" 5 | kotestAssertions = "6.0.7" 6 | kotestRunner = "6.0.7" 7 | kotlin = "2.3.0" 8 | ktlint = "14.0.1" 9 | mockk = "1.14.7" 10 | versionCatalogUpdate = "1.0.1" 11 | versions = "0.53.0" 12 | 13 | [libraries] 14 | commonsCollection4 = { module = "org.apache.commons:commons-collections4", version.ref = "commonsCollection4" } 15 | kotestAssertions = { module = "io.kotest:kotest-assertions-core", version.ref = "kotestAssertions" } 16 | kotestRunner = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotestRunner" } 17 | mockk = { module = "io.mockk:mockk", version.ref = "mockk" } 18 | 19 | [plugins] 20 | # Changelog update gradle plugin - https://github.com/JetBrains/gradle-changelog-plugin 21 | changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } 22 | # IntelliJ gradle plugin - https://plugins.gradle.org/plugin/org.jetbrains.intellij 23 | gradleIntelliJPlugin = { id = "org.jetbrains.intellij.platform", version.ref = "gradleIntelliJPlugin" } 24 | # Kotlin gradle plugin - https://plugins.gradle.org/plugin/org.jetbrains.kotlin.jvm 25 | kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 26 | # ktlint formatter and linter - https://github.com/JLLeitschuh/ktlint-gradle 27 | ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } 28 | # Updates gradle plugin versions - https://github.com/littlerobots/version-catalog-update-plugin 29 | versionCatalogUpdate = { id = "nl.littlerobots.version-catalog-update", version.ref = "versionCatalogUpdate" } 30 | versions = { id = "com.github.ben-manes.versions", version.ref = "versions" } 31 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/toolwindow/Console.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.toolwindow 2 | 3 | import com.dprint.messages.DprintMessage 4 | import com.intellij.execution.impl.ConsoleViewImpl 5 | import com.intellij.execution.ui.ConsoleViewContentType 6 | import com.intellij.openapi.components.Service 7 | import com.intellij.openapi.project.Project 8 | import com.intellij.psi.search.GlobalSearchScope 9 | import java.time.LocalDateTime 10 | import java.time.format.DateTimeFormatter 11 | 12 | @Service(Service.Level.PROJECT) 13 | class Console( 14 | val project: Project, 15 | ) { 16 | val consoleView = ConsoleViewImpl(project, GlobalSearchScope.allScope(project), false, false) 17 | 18 | init { 19 | with(project.messageBus.connect()) { 20 | subscribe( 21 | DprintMessage.DPRINT_MESSAGE_TOPIC, 22 | object : DprintMessage.Listener { 23 | override fun info(message: String) { 24 | consoleView.print(decorateText(message), ConsoleViewContentType.LOG_INFO_OUTPUT) 25 | } 26 | 27 | override fun warn(message: String) { 28 | consoleView.print(decorateText(message), ConsoleViewContentType.LOG_WARNING_OUTPUT) 29 | } 30 | 31 | override fun error(message: String) { 32 | consoleView.print(decorateText(message), ConsoleViewContentType.LOG_ERROR_OUTPUT) 33 | } 34 | }, 35 | ) 36 | } 37 | } 38 | 39 | private fun decorateText(text: String): String = 40 | "${DateTimeFormatter.ofPattern("yyyy MM dd HH:mm:ss").format(LocalDateTime.now())}: ${text}\n" 41 | } 42 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/listeners/FileOpenedListener.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.listeners 2 | 3 | import com.dprint.config.ProjectConfiguration 4 | import com.dprint.services.DprintService 5 | import com.dprint.utils.isFormattableFile 6 | import com.intellij.openapi.components.service 7 | import com.intellij.openapi.fileEditor.FileEditorManager 8 | import com.intellij.openapi.fileEditor.FileEditorManagerListener 9 | import com.intellij.openapi.vfs.VirtualFile 10 | 11 | /** 12 | * This listener will fire a request to get the canFormat status of a file. The result will then be cached in the 13 | * EditorServiceManager so we don't need to wait for background threads in the main EDT thread. 14 | * 15 | * With the new state-based architecture, cache priming only happens when the service is ready, 16 | * eliminating race conditions during initialization. 17 | */ 18 | class FileOpenedListener : FileEditorManagerListener { 19 | override fun fileOpened( 20 | source: FileEditorManager, 21 | file: VirtualFile, 22 | ) { 23 | super.fileOpened(source, file) 24 | val projectConfig = source.project.service().state 25 | if (!projectConfig.enabled || 26 | !source.project.isOpen || 27 | !source.project.isInitialized || 28 | source.project.isDisposed 29 | ) { 30 | return 31 | } 32 | 33 | // We ignore scratch files as they are never part of config 34 | if (!isFormattableFile(source.project, file)) return 35 | 36 | val dprintService = source.project.service() 37 | 38 | // The service will check if the service is ready before priming 39 | // If not ready, the cache will be primed when initialization completes 40 | dprintService.primeCanFormatCacheForFile(file) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/listeners/OnSaveAction.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.listeners 2 | 3 | import com.dprint.config.ProjectConfiguration 4 | import com.dprint.config.UserConfiguration 5 | import com.dprint.i18n.DprintBundle 6 | import com.dprint.services.FormatterService 7 | import com.dprint.utils.infoLogWithConsole 8 | import com.intellij.codeInsight.actions.ReformatCodeProcessor 9 | import com.intellij.ide.actionsOnSave.impl.ActionsOnSaveFileDocumentManagerListener 10 | import com.intellij.openapi.command.CommandProcessor 11 | import com.intellij.openapi.components.service 12 | import com.intellij.openapi.diagnostic.logger 13 | import com.intellij.openapi.editor.Document 14 | import com.intellij.openapi.fileEditor.FileDocumentManager 15 | import com.intellij.openapi.project.Project 16 | 17 | private val LOGGER = logger() 18 | 19 | /** 20 | * This listener sets up format on save functionality. 21 | */ 22 | class OnSaveAction : ActionsOnSaveFileDocumentManagerListener.ActionOnSave() { 23 | override fun isEnabledForProject(project: Project): Boolean { 24 | val projectConfig = project.service().state 25 | val userConfig = project.service().state 26 | return projectConfig.enabled && userConfig.runOnSave 27 | } 28 | 29 | override fun processDocuments( 30 | project: Project, 31 | documents: Array, 32 | ) { 33 | val currentCommandName = CommandProcessor.getInstance().currentCommandName 34 | if (currentCommandName == ReformatCodeProcessor.getCommandName()) { 35 | return 36 | } 37 | val formatterService = project.service() 38 | val manager = FileDocumentManager.getInstance() 39 | for (document in documents) { 40 | manager.getFile(document)?.let { vfile -> 41 | infoLogWithConsole(DprintBundle.message("save.action.run", vfile.path), project, LOGGER) 42 | formatterService.format(vfile, document) 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/messages/DprintAction.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.messages 2 | 3 | import com.dprint.utils.errorLogWithConsole 4 | import com.intellij.openapi.diagnostic.logger 5 | import com.intellij.openapi.project.Project 6 | import com.intellij.util.messages.Topic 7 | 8 | interface DprintAction { 9 | companion object { 10 | val DPRINT_ACTION_TOPIC: Topic = Topic.create("dprint.action", DprintAction::class.java) 11 | 12 | private val LOGGER = logger() 13 | 14 | fun publishFormattingStarted( 15 | project: Project, 16 | filePath: String, 17 | ) { 18 | publishSafely(project) { 19 | project.messageBus 20 | .syncPublisher(DPRINT_ACTION_TOPIC) 21 | .formattingStarted(filePath) 22 | } 23 | } 24 | 25 | fun publishFormattingSucceeded( 26 | project: Project, 27 | filePath: String, 28 | timeElapsed: Long, 29 | ) { 30 | publishSafely(project) { 31 | project.messageBus 32 | .syncPublisher(DPRINT_ACTION_TOPIC) 33 | .formattingSucceeded(filePath, timeElapsed) 34 | } 35 | } 36 | 37 | fun publishFormattingFailed( 38 | project: Project, 39 | filePath: String, 40 | message: String?, 41 | ) { 42 | publishSafely(project) { 43 | project.messageBus.syncPublisher(DPRINT_ACTION_TOPIC).formattingFailed(filePath, message) 44 | } 45 | } 46 | 47 | private fun publishSafely( 48 | project: Project, 49 | runnable: () -> Unit, 50 | ) { 51 | try { 52 | runnable() 53 | } catch (e: Exception) { 54 | errorLogWithConsole("Failed to publish dprint action message", e, project, LOGGER) 55 | } 56 | } 57 | } 58 | 59 | fun formattingStarted(filePath: String) 60 | 61 | fun formattingSucceeded( 62 | filePath: String, 63 | timeElapsed: Long, 64 | ) 65 | 66 | fun formattingFailed( 67 | filePath: String, 68 | message: String?, 69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /src/test/kotlin/com/dprint/services/FormatterServiceTest.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services 2 | 3 | import com.dprint.utils.isFormattableFile 4 | import com.intellij.openapi.components.service 5 | import com.intellij.openapi.editor.Document 6 | import com.intellij.openapi.project.Project 7 | import com.intellij.openapi.vfs.VirtualFile 8 | import io.kotest.core.spec.style.FunSpec 9 | import io.mockk.clearAllMocks 10 | import io.mockk.every 11 | import io.mockk.mockk 12 | import io.mockk.mockkStatic 13 | import io.mockk.verify 14 | 15 | class FormatterServiceTest : 16 | FunSpec({ 17 | val testPath = "/test/path" 18 | val testText = "val test = \"test\"" 19 | 20 | mockkStatic(::isFormattableFile) 21 | 22 | val virtualFile = mockk() 23 | val document = mockk() 24 | val project = mockk() 25 | val dprintService = mockk(relaxed = true) 26 | 27 | val formatterService = FormatterService(project) 28 | 29 | beforeEach { 30 | every { virtualFile.path } returns testPath 31 | every { document.text } returns testText 32 | every { project.service() } returns dprintService 33 | } 34 | 35 | afterEach { 36 | clearAllMocks() 37 | } 38 | 39 | test("It doesn't format if cached can format result is false") { 40 | every { isFormattableFile(project, virtualFile) } returns true 41 | every { dprintService.canFormatCached(testPath) } returns false 42 | 43 | formatterService.format(virtualFile, document) 44 | 45 | verify(exactly = 0) { dprintService.format(testPath, testPath, any()) } 46 | } 47 | 48 | test("It doesn't format if cached can format result is null") { 49 | every { isFormattableFile(project, virtualFile) } returns true 50 | every { dprintService.canFormatCached(testPath) } returns null 51 | 52 | formatterService.format(virtualFile, document) 53 | 54 | verify(exactly = 0) { dprintService.format(testPath, testPath, any()) } 55 | } 56 | 57 | test("It formats if cached can format result is true") { 58 | every { isFormattableFile(project, virtualFile) } returns true 59 | every { dprintService.canFormatCached(testPath) } returns true 60 | 61 | formatterService.format(virtualFile, document) 62 | 63 | verify(exactly = 1) { dprintService.format(testPath, testText, any()) } 64 | } 65 | }) 66 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/utils/LogUtils.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.utils 2 | 3 | import com.dprint.messages.DprintMessage 4 | import com.intellij.openapi.diagnostic.Logger 5 | import com.intellij.openapi.project.Project 6 | import com.intellij.util.messages.MessageBus 7 | 8 | data class MessageWithThrowable( 9 | val message: String, 10 | val throwable: Throwable?, 11 | ) { 12 | override fun toString(): String { 13 | if (throwable != null) { 14 | return "$message\n\t$throwable" 15 | } 16 | return message 17 | } 18 | } 19 | 20 | fun infoConsole( 21 | message: String, 22 | project: Project, 23 | ) { 24 | maybeGetMessageBus(project)?.syncPublisher(DprintMessage.DPRINT_MESSAGE_TOPIC)?.info(message) 25 | } 26 | 27 | fun infoLogWithConsole( 28 | message: String, 29 | project: Project, 30 | logger: Logger, 31 | ) { 32 | logger.info(message) 33 | infoConsole(message, project) 34 | } 35 | 36 | fun warnLogWithConsole( 37 | message: String, 38 | project: Project, 39 | logger: Logger, 40 | ) { 41 | // Always use info for system level logging as it throws notifications into the UI 42 | logger.info(message) 43 | maybeGetMessageBus(project)?.syncPublisher(DprintMessage.DPRINT_MESSAGE_TOPIC)?.warn(message) 44 | } 45 | 46 | fun warnLogWithConsole( 47 | message: String, 48 | throwable: Throwable?, 49 | project: Project, 50 | logger: Logger, 51 | ) { 52 | // Always use info for system level logging as it throws notifications into the UI 53 | logger.warn(message, throwable) 54 | maybeGetMessageBus( 55 | project, 56 | )?.syncPublisher(DprintMessage.DPRINT_MESSAGE_TOPIC)?.warn(MessageWithThrowable(message, throwable).toString()) 57 | } 58 | 59 | fun errorLogWithConsole( 60 | message: String, 61 | project: Project, 62 | logger: Logger, 63 | ) { 64 | errorLogWithConsole(message, null, project, logger) 65 | } 66 | 67 | fun errorLogWithConsole( 68 | message: String, 69 | throwable: Throwable?, 70 | project: Project, 71 | logger: Logger, 72 | ) { 73 | // Always use info for system level logging as it throws notifications into the UI 74 | logger.error(message, throwable) 75 | maybeGetMessageBus( 76 | project, 77 | )?.syncPublisher(DprintMessage.DPRINT_MESSAGE_TOPIC)?.error(MessageWithThrowable(message, throwable).toString()) 78 | } 79 | 80 | private fun maybeGetMessageBus(project: Project): MessageBus? { 81 | if (project.isDisposed) { 82 | return null 83 | } 84 | 85 | return project.messageBus 86 | } 87 | -------------------------------------------------------------------------------- /src/test/kotlin/com/dprint/services/editorservice/EditorServiceInitializerTest.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services.editorservice 2 | 3 | import com.dprint.config.ProjectConfiguration 4 | import com.intellij.notification.NotificationGroupManager 5 | import com.intellij.notification.NotificationType 6 | import com.intellij.openapi.components.service 7 | import com.intellij.openapi.project.Project 8 | import io.kotest.core.spec.style.FunSpec 9 | import io.mockk.clearAllMocks 10 | import io.mockk.every 11 | import io.mockk.just 12 | import io.mockk.mockk 13 | import io.mockk.mockkStatic 14 | import io.mockk.runs 15 | import io.mockk.verify 16 | 17 | class EditorServiceInitializerTest : 18 | FunSpec({ 19 | val mockProject = mockk() 20 | val mockCache = mockk(relaxed = true) 21 | val mockProjectConfiguration = mockk() 22 | val mockState = mockk() 23 | 24 | // Mock static functions that might be called 25 | mockkStatic(NotificationGroupManager::class) 26 | val mockNotificationGroupManager = mockk() 27 | val mockNotificationGroup = mockk() 28 | val mockNotification = mockk() 29 | 30 | beforeEach { 31 | // Setup project service mock 32 | every { mockProject.service() } returns mockProjectConfiguration 33 | every { mockProjectConfiguration.state } returns mockState 34 | 35 | // Setup notification mocking 36 | every { NotificationGroupManager.getInstance() } returns mockNotificationGroupManager 37 | every { mockNotificationGroupManager.getNotificationGroup("Dprint") } returns mockNotificationGroup 38 | every { 39 | mockNotificationGroup.createNotification(any(), any(), any()) 40 | } returns mockNotification 41 | every { mockNotification.notify(any()) } just runs 42 | } 43 | 44 | afterEach { 45 | clearAllMocks() 46 | } 47 | 48 | val initializer = EditorServiceInitializer(mockProject) 49 | 50 | // Test removed - hasAttemptedInitialisation is no longer needed with state-based architecture 51 | 52 | // Tests for createRestartTask removed - this functionality is now handled directly in DprintService 53 | 54 | test("notifyFailedToStart shows notification") { 55 | // When 56 | initializer.notifyFailedToStart() 57 | 58 | // Then - verify notification creation and display 59 | verify { mockNotificationGroup.createNotification(any(), any(), any()) } 60 | verify { mockNotification.notify(mockProject) } 61 | } 62 | }) 63 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/services/editorservice/process/StdErrListener.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services.editorservice.process 2 | 3 | import com.dprint.utils.errorLogWithConsole 4 | import com.intellij.openapi.diagnostic.logger 5 | import com.intellij.openapi.project.Project 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.Job 9 | import kotlinx.coroutines.SupervisorJob 10 | import kotlinx.coroutines.cancel 11 | import kotlinx.coroutines.isActive 12 | import kotlinx.coroutines.launch 13 | import kotlinx.coroutines.withContext 14 | import java.nio.BufferUnderflowException 15 | 16 | private val LOGGER = logger() 17 | 18 | class StdErrListener( 19 | private val project: Project, 20 | private val process: Process, 21 | ) { 22 | private var listenerJob: Job? = null 23 | private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) 24 | 25 | fun listen() { 26 | listenerJob = 27 | scope.launch { 28 | try { 29 | process.errorStream?.bufferedReader()?.use { reader -> 30 | while (isActive) { 31 | try { 32 | val line = 33 | withContext(Dispatchers.IO) { 34 | reader.readLine() 35 | } 36 | 37 | if (line != null) { 38 | errorLogWithConsole("Dprint daemon ${process.pid()}: $line", project, LOGGER) 39 | } else { 40 | // End of stream reached 41 | break 42 | } 43 | } catch (e: Exception) { 44 | if (isActive) { 45 | when (e) { 46 | is BufferUnderflowException -> { 47 | // Happens when the editor service is shut down while reading 48 | LOGGER.info("Buffer underflow while reading stderr", e) 49 | } 50 | else -> { 51 | LOGGER.info("Error reading stderr", e) 52 | } 53 | } 54 | } 55 | break 56 | } 57 | } 58 | } 59 | } catch (e: Exception) { 60 | if (isActive) { 61 | LOGGER.info("Error in stderr listener", e) 62 | } 63 | } 64 | } 65 | } 66 | 67 | fun dispose() { 68 | listenerJob?.cancel() 69 | scope.cancel() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/services/editorservice/v5/OutgoingMessage.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services.editorservice.v5 2 | 3 | import com.dprint.i18n.DprintBundle 4 | import com.dprint.services.editorservice.exceptions.UnsupportedMessagePartException 5 | import java.nio.ByteBuffer 6 | import java.util.concurrent.atomic.AtomicInteger 7 | 8 | private var messageId = AtomicInteger(0) 9 | 10 | fun createNewMessage(type: MessageType): OutgoingMessage = OutgoingMessage(getNextMessageId(), type) 11 | 12 | fun getNextMessageId(): Int = messageId.incrementAndGet() 13 | 14 | class OutgoingMessage( 15 | val id: Int, 16 | private val type: MessageType, 17 | ) { 18 | // Dprint uses unsigned bytes of 4x255 for the success message and that translates 19 | // to 4x-1 in the jvm's signed bytes. 20 | private val successMessage = byteArrayOf(-1, -1, -1, -1) 21 | private val u32ByteSize = 4 22 | private var parts = mutableListOf() 23 | 24 | fun addString(str: String) { 25 | parts.add(str.encodeToByteArray()) 26 | } 27 | 28 | fun addInt(int: Int) { 29 | parts.add(int) 30 | } 31 | 32 | private fun intToFourByteArray(int: Int): ByteArray { 33 | val buffer = ByteBuffer.allocate(u32ByteSize) 34 | buffer.putInt(int) 35 | return buffer.array() 36 | } 37 | 38 | fun build(): ByteArray { 39 | var bodyLength = 0 40 | for (part in parts) { 41 | when (part) { 42 | is Int -> bodyLength += u32ByteSize 43 | is ByteArray -> bodyLength += (part.size + u32ByteSize) 44 | } 45 | } 46 | val byteLength = bodyLength + u32ByteSize * u32ByteSize 47 | val buffer = ByteBuffer.allocate(byteLength) 48 | 49 | buffer.put(intToFourByteArray(id)) 50 | buffer.put(intToFourByteArray(type.intValue)) 51 | buffer.put(intToFourByteArray(bodyLength)) 52 | 53 | for (part in parts) { 54 | when (part) { 55 | is ByteArray -> { 56 | buffer.put(intToFourByteArray(part.size)) 57 | buffer.put(part) 58 | } 59 | 60 | is Int -> { 61 | buffer.put(intToFourByteArray(part)) 62 | } 63 | 64 | else -> { 65 | throw UnsupportedMessagePartException( 66 | DprintBundle.message("editor.service.unsupported.message.type", part::class.java.simpleName), 67 | ) 68 | } 69 | } 70 | } 71 | 72 | buffer.put(successMessage) 73 | 74 | if (buffer.hasRemaining()) { 75 | val message = 76 | DprintBundle.message( 77 | "editor.service.incorrect.message.size", 78 | byteLength, 79 | byteLength - buffer.remaining(), 80 | ) 81 | throw UnsupportedMessagePartException(message) 82 | } 83 | return buffer.array() 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/plugin.xml: -------------------------------------------------------------------------------- 1 | 2 | com.dprint.intellij.plugin 3 | Dprint 4 | dprint 5 | 6 | com.intellij.modules.platform 7 | 8 | messages.Bundle 9 | 10 | 11 | 12 | 18 | 21 | 23 | 24 | 25 | 26 | 29 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 48 | 49 | 50 | 51 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/services/editorservice/IEditorService.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services.editorservice 2 | 3 | import com.dprint.i18n.DprintBundle 4 | import com.dprint.services.editorservice.exceptions.HandlerNotImplementedException 5 | import com.intellij.openapi.Disposable 6 | 7 | interface IEditorService : Disposable { 8 | /** 9 | * If enabled, initialises the dprint editor-service so it is ready to format 10 | */ 11 | fun initialiseEditorService() 12 | 13 | /** 14 | * Shuts down the editor service and destroys the process. 15 | */ 16 | fun destroyEditorService() 17 | 18 | /** 19 | * Returns whether dprint can format the given file path based on the config used in the editor service. 20 | */ 21 | suspend fun canFormat(filePath: String): Boolean? 22 | 23 | /** 24 | * This runs dprint using the editor service with the supplied file path and content as stdin. 25 | * @param formatId The id of the message that is passed to the underlying editor service. This is exposed at this 26 | * level, so we can cancel requests if need be. 27 | * @param filePath The path of the file being formatted. This is needed so the correct dprint configuration file 28 | * located. 29 | * @param content The content of the file as a string. This is formatted via Dprint and returned via the result. 30 | * @return A result object containing the formatted content is successful or an error. 31 | */ 32 | suspend fun fmt( 33 | filePath: String, 34 | content: String, 35 | formatId: Int?, 36 | ): FormatResult 37 | 38 | /** 39 | * This runs dprint using the editor service with the supplied file path and content as stdin. 40 | * @param formatId The id of the message that is passed to the underlying editor service. This is exposed at this 41 | * level, so we can cancel requests if need be. 42 | * @param filePath The path of the file being formatted. This is needed so the correct dprint configuration file 43 | * located. 44 | * @param content The content of the file as a string. This is formatted via Dprint and returned via the result. 45 | * @param startIndex The char index indicating where to start range formatting from 46 | * @param endIndex The char indicating where to range format up to (not inclusive) 47 | * @return A result object containing the formatted content is successful or an error. 48 | */ 49 | suspend fun fmt( 50 | filePath: String, 51 | content: String, 52 | formatId: Int?, 53 | startIndex: Int?, 54 | endIndex: Int?, 55 | ): FormatResult 56 | 57 | /** 58 | * Whether the editor service implementation supports range formatting. 59 | */ 60 | fun canRangeFormat(): Boolean 61 | 62 | /** 63 | * Whether the editor service implementation supports cancellation of formats. 64 | */ 65 | fun canCancelFormat(): Boolean 66 | 67 | /** 68 | * Gets a formatting message id if the editor service supports messages with id's, this starts at schema version 5. 69 | */ 70 | fun maybeGetFormatId(): Int? 71 | 72 | /** 73 | * Cancels the format for a given id if the service supports it. Will throw HandlerNotImplementedException if the 74 | * service doesn't. 75 | */ 76 | fun cancelFormat(formatId: Int): Unit = 77 | throw HandlerNotImplementedException(DprintBundle.message("error.cancel.format.not.implemented")) 78 | } 79 | -------------------------------------------------------------------------------- /src/test/kotlin/com/dprint/services/editorservice/process/EditorProcessTest.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services.editorservice.process 2 | 3 | import com.dprint.config.UserConfiguration 4 | import com.dprint.utils.getValidConfigPath 5 | import com.dprint.utils.getValidExecutablePath 6 | import com.dprint.utils.infoLogWithConsole 7 | import com.intellij.execution.configurations.GeneralCommandLine 8 | import com.intellij.openapi.components.service 9 | import com.intellij.openapi.project.Project 10 | import io.kotest.core.spec.style.FunSpec 11 | import io.mockk.EqMatcher 12 | import io.mockk.clearAllMocks 13 | import io.mockk.every 14 | import io.mockk.mockk 15 | import io.mockk.mockkConstructor 16 | import io.mockk.mockkStatic 17 | import io.mockk.verify 18 | import java.io.File 19 | import java.util.concurrent.CompletableFuture 20 | 21 | class EditorProcessTest : 22 | FunSpec({ 23 | mockkStatic(ProcessHandle::current) 24 | mockkStatic(::infoLogWithConsole) 25 | mockkStatic("com.dprint.utils.FileUtilsKt") 26 | 27 | val project = mockk() 28 | val processHandle = mockk() 29 | val process = mockk() 30 | val userConfig = mockk() 31 | 32 | val editorProcess = EditorProcess(project) 33 | 34 | beforeEach { 35 | every { infoLogWithConsole(any(), project, any()) } returns Unit 36 | every { project.service() } returns userConfig 37 | } 38 | 39 | afterEach { 40 | clearAllMocks() 41 | } 42 | 43 | test("it creates a process with the correct args") { 44 | val execPath = "/bin/dprint" 45 | val configPath = "./dprint.json" 46 | val workingDir = "/working/dir" 47 | val parentProcessId = 1L 48 | 49 | mockkConstructor(GeneralCommandLine::class) 50 | mockkConstructor(File::class) 51 | mockkConstructor(StdErrListener::class) 52 | 53 | every { getValidExecutablePath(project) } returns execPath 54 | every { getValidConfigPath(project) } returns configPath 55 | 56 | every { ProcessHandle.current() } returns processHandle 57 | every { processHandle.pid() } returns parentProcessId 58 | every { userConfig.state } returns UserConfiguration.State() 59 | every { constructedWith(EqMatcher(configPath)).parent } returns workingDir 60 | every { process.pid() } returns 2L 61 | every { process.onExit() } returns CompletableFuture.completedFuture(process) 62 | every { anyConstructed().listen() } returns Unit 63 | 64 | val expectedArgs = 65 | listOf( 66 | execPath, 67 | "editor-service", 68 | "--config", 69 | configPath, 70 | "--parent-pid", 71 | parentProcessId.toString(), 72 | "--verbose", 73 | ) 74 | 75 | // This essentially tests the correct args are passed in. 76 | every { constructedWith(EqMatcher(expectedArgs)).createProcess() } returns process 77 | 78 | editorProcess.initialize() 79 | 80 | verify( 81 | exactly = 1, 82 | ) { constructedWith(EqMatcher(expectedArgs)).withWorkDirectory(workingDir) } 83 | verify(exactly = 1) { constructedWith(EqMatcher(expectedArgs)).createProcess() } 84 | } 85 | }) 86 | -------------------------------------------------------------------------------- /src/test/kotlin/com/dprint/services/editorservice/v5/OutgoingMessageTest.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services.editorservice.v5 2 | 3 | import com.intellij.util.io.toByteArray 4 | import io.kotest.core.spec.style.FunSpec 5 | import io.kotest.matchers.shouldBe 6 | import java.nio.ByteBuffer 7 | 8 | val SUCCESS_MESSAGE = byteArrayOf(-1, -1, -1, -1) 9 | 10 | internal class OutgoingMessageTest : 11 | FunSpec({ 12 | test("It builds a string message") { 13 | val id = 1 14 | val type = MessageType.Active 15 | val text = "blah!" 16 | val textAsBytes = text.toByteArray() 17 | val outgoingMessage = OutgoingMessage(id, type) 18 | outgoingMessage.addString(text) 19 | 20 | // 4 is for the size of the part, it has a single part 21 | val bodyLength = 4 + text.length 22 | // id + message type + body size + part size + part content + success message 23 | val expectedSize = 4 * 3 + 4 + text.length + SUCCESS_MESSAGE.size 24 | val expected = ByteBuffer.allocate(expectedSize) 25 | expected.put(createIntBytes(id)) 26 | expected.put(createIntBytes(type.intValue)) 27 | expected.put(createIntBytes(bodyLength)) 28 | expected.put(createIntBytes(text.length)) 29 | expected.put(textAsBytes) 30 | expected.put(SUCCESS_MESSAGE) 31 | 32 | outgoingMessage.build() shouldBe expected.array() 33 | } 34 | 35 | test("It builds an int message") { 36 | val id = 1 37 | val type = MessageType.Active 38 | val int = 2 39 | val outgoingMessage = OutgoingMessage(id, type) 40 | outgoingMessage.addInt(int) 41 | 42 | // id + message type + body size + part content + success message 43 | val expectedSize = 4 * 3 + 4 + SUCCESS_MESSAGE.size 44 | val expected = ByteBuffer.allocate(expectedSize) 45 | expected.put(createIntBytes(id)) 46 | expected.put(createIntBytes(type.intValue)) 47 | expected.put(createIntBytes(4)) // body length 48 | expected.put(createIntBytes(int)) 49 | expected.put(SUCCESS_MESSAGE) 50 | 51 | outgoingMessage.build() shouldBe expected.array() 52 | } 53 | 54 | test("It builds a combined message") { 55 | val id = 1 56 | val type = MessageType.Active 57 | val int = 2 58 | val text = "blah!" 59 | val textAsBytes = text.toByteArray() 60 | val outgoingMessage = OutgoingMessage(id, type) 61 | outgoingMessage.addInt(int) 62 | outgoingMessage.addString(text) 63 | 64 | // body length 65 | val bodyLength = 4 + 4 + text.length 66 | // id + message type + body size + int part + string part size + string part content + success message 67 | val expectedSize = 4 * 3 + 4 + 4 + text.length + SUCCESS_MESSAGE.size 68 | val expected = ByteBuffer.allocate(expectedSize) 69 | expected.put(createIntBytes(id)) 70 | expected.put(createIntBytes(type.intValue)) 71 | expected.put(createIntBytes(bodyLength)) 72 | expected.put(createIntBytes(int)) 73 | expected.put(createIntBytes(text.length)) 74 | expected.put(textAsBytes) 75 | expected.put(SUCCESS_MESSAGE) 76 | 77 | outgoingMessage.build() shouldBe expected.array() 78 | } 79 | }) 80 | 81 | private fun createIntBytes(int: Int): ByteArray { 82 | val buffer = ByteBuffer.allocate(4) 83 | buffer.putInt(int) 84 | return buffer.toByteArray() 85 | } 86 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflow created for handling the release process based on the draft release prepared with the Build workflow. 2 | # Running the publishPlugin task requires all following secrets to be provided: PUBLISH_TOKEN, PRIVATE_KEY, PRIVATE_KEY_PASSWORD, CERTIFICATE_CHAIN. 3 | # See https://plugins.jetbrains.com/docs/intellij/plugin-signing.html for more information. 4 | 5 | name: Release 6 | on: 7 | release: 8 | types: [ prereleased, released ] 9 | 10 | jobs: 11 | 12 | # Prepare and publish the plugin to JetBrains Marketplace repository 13 | release: 14 | name: Publish Plugin 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: write 18 | pull-requests: write 19 | steps: 20 | 21 | # Free GitHub Actions Environment Disk Space 22 | - name: Maximize Build Space 23 | uses: jlumbroso/free-disk-space@v1.3.1 24 | with: 25 | tool-cache: false 26 | large-packages: false 27 | 28 | # Check out the current repository 29 | - name: Fetch Sources 30 | uses: actions/checkout@v5 31 | with: 32 | ref: ${{ github.event.release.tag_name }} 33 | 34 | # Set up Java environment for the next steps 35 | - name: Setup Java 36 | uses: actions/setup-java@v5 37 | with: 38 | distribution: zulu 39 | java-version: 21 40 | 41 | # Setup Gradle 42 | - name: Setup Gradle 43 | uses: gradle/actions/setup-gradle@v5 44 | with: 45 | cache-read-only: true 46 | 47 | # Update the Unreleased section with the current release note 48 | - name: Patch Changelog 49 | if: ${{ github.event.release.body != '' }} 50 | env: 51 | CHANGELOG: ${{ github.event.release.body }} 52 | run: | 53 | RELEASE_NOTE="./build/tmp/release_note.txt" 54 | mkdir -p "$(dirname "$RELEASE_NOTE")" 55 | echo "$CHANGELOG" > $RELEASE_NOTE 56 | ./gradlew patchChangelog --release-note-file=$RELEASE_NOTE 57 | 58 | # Publish the plugin to JetBrains Marketplace 59 | - name: Publish Plugin 60 | env: 61 | PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} 62 | CERTIFICATE_CHAIN: ${{ secrets.CERTIFICATE_CHAIN }} 63 | PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} 64 | PRIVATE_KEY_PASSWORD: ${{ secrets.PRIVATE_KEY_PASSWORD }} 65 | run: ./gradlew publishPlugin 66 | 67 | # Upload artifact as a release asset 68 | - name: Upload Release Asset 69 | env: 70 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 71 | run: gh release upload ${{ github.event.release.tag_name }} ./build/distributions/* 72 | 73 | # Create a pull request 74 | - name: Create Pull Request 75 | if: ${{ github.event.release.body != '' }} 76 | env: 77 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | run: | 79 | VERSION="${{ github.event.release.tag_name }}" 80 | BRANCH="changelog-update-$VERSION" 81 | LABEL="release changelog" 82 | 83 | git config user.email "action@github.com" 84 | git config user.name "GitHub Action" 85 | 86 | git checkout -b $BRANCH 87 | git commit -am "Changelog update - $VERSION" 88 | git push --set-upstream origin $BRANCH 89 | gh label create "$LABEL" \ 90 | --description "Pull requests with release changelog update" \ 91 | --force \ 92 | || true 93 | 94 | gh pr create \ 95 | --title "Changelog update - \`$VERSION\`" \ 96 | --body "Current pull request contains patched \`CHANGELOG.md\` file for the \`$VERSION\` version." \ 97 | --label "$LABEL" \ 98 | --head $BRANCH -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/actions/ReformatAction.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.actions 2 | 3 | import com.dprint.config.ProjectConfiguration 4 | import com.dprint.i18n.DprintBundle 5 | import com.dprint.services.FormatterService 6 | import com.dprint.utils.infoLogWithConsole 7 | import com.intellij.openapi.actionSystem.AnAction 8 | import com.intellij.openapi.actionSystem.AnActionEvent 9 | import com.intellij.openapi.actionSystem.PlatformDataKeys 10 | import com.intellij.openapi.components.service 11 | import com.intellij.openapi.diagnostic.logger 12 | import com.intellij.openapi.editor.Document 13 | import com.intellij.openapi.project.Project 14 | import com.intellij.openapi.vfs.ReadonlyStatusHandler 15 | import com.intellij.openapi.vfs.VirtualFile 16 | import com.intellij.psi.PsiDocumentManager 17 | import com.intellij.psi.PsiManager 18 | 19 | private val LOGGER = logger() 20 | 21 | /** 22 | * This action is intended to be hooked up to a menu option or a key command. It handles events for two separate data 23 | * types, editor and virtual files. 24 | * 25 | * For editor data, it will pull the virtual file from the editor and for both it will send the virtual file to 26 | * DprintFormatterService to be formatted and saved. 27 | */ 28 | class ReformatAction : AnAction() { 29 | override fun actionPerformed(event: AnActionEvent) { 30 | event.project?.let { project -> 31 | val projectConfig = project.service().state 32 | if (!projectConfig.enabled) return@let 33 | 34 | val editor = event.getData(PlatformDataKeys.EDITOR) 35 | if (editor != null) { 36 | formatDocument(project, editor.document) 37 | } else { 38 | event.getData(PlatformDataKeys.VIRTUAL_FILE)?.let { virtualFile -> 39 | formatVirtualFile(project, virtualFile) 40 | } 41 | } 42 | } 43 | } 44 | 45 | private fun formatDocument( 46 | project: Project, 47 | document: Document, 48 | ) { 49 | val formatterService = project.service() 50 | val documentManager = PsiDocumentManager.getInstance(project) 51 | documentManager.getPsiFile(document)?.virtualFile?.let { virtualFile -> 52 | infoLogWithConsole(DprintBundle.message("reformat.action.run", virtualFile.path), project, LOGGER) 53 | formatterService.format(virtualFile, document) 54 | } 55 | } 56 | 57 | private fun formatVirtualFile( 58 | project: Project, 59 | virtualFile: VirtualFile, 60 | ) { 61 | val formatterService = project.service() 62 | infoLogWithConsole(DprintBundle.message("reformat.action.run", virtualFile.path), project, LOGGER) 63 | getDocument(project, virtualFile)?.let { 64 | formatterService.format(virtualFile, it) 65 | } 66 | } 67 | 68 | private fun isFileWriteable( 69 | project: Project, 70 | virtualFile: VirtualFile, 71 | ): Boolean { 72 | val readonlyStatusHandler = ReadonlyStatusHandler.getInstance(project) 73 | return !virtualFile.isDirectory && 74 | virtualFile.isValid && 75 | virtualFile.isInLocalFileSystem && 76 | !readonlyStatusHandler.ensureFilesWritable(listOf(virtualFile)).hasReadonlyFiles() 77 | } 78 | 79 | private fun getDocument( 80 | project: Project, 81 | virtualFile: VirtualFile, 82 | ): Document? { 83 | if (isFileWriteable(project, virtualFile)) { 84 | PsiManager.getInstance(project).findFile(virtualFile)?.let { 85 | return PsiDocumentManager.getInstance(project).getDocument(it) 86 | } 87 | } 88 | 89 | return null 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/test/kotlin/com/dprint/formatter/DprintFormattingTaskTest.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.formatter 2 | 3 | import com.dprint.services.DprintService 4 | import com.dprint.services.editorservice.FormatResult 5 | import com.dprint.utils.errorLogWithConsole 6 | import com.dprint.utils.infoLogWithConsole 7 | import com.intellij.formatting.service.AsyncFormattingRequest 8 | import com.intellij.openapi.project.Project 9 | import io.kotest.core.spec.style.FunSpec 10 | import io.mockk.clearAllMocks 11 | import io.mockk.coEvery 12 | import io.mockk.coVerify 13 | import io.mockk.every 14 | import io.mockk.mockk 15 | import io.mockk.mockkStatic 16 | import io.mockk.verify 17 | 18 | class DprintFormattingTaskTest : 19 | FunSpec({ 20 | val path = "/some/path" 21 | 22 | mockkStatic(::infoLogWithConsole) 23 | 24 | val project = mockk() 25 | val dprintService = mockk() 26 | val formattingRequest = mockk(relaxed = true) 27 | lateinit var dprintFormattingTask: DprintFormattingTask 28 | 29 | beforeEach { 30 | every { infoLogWithConsole(any(), project, any()) } returns Unit 31 | every { dprintService.maybeGetFormatId() } returnsMany mutableListOf(1, 2, 3) 32 | 33 | dprintFormattingTask = DprintFormattingTask(project, dprintService, formattingRequest, path) 34 | } 35 | 36 | afterEach { clearAllMocks() } 37 | 38 | test("it calls dprintService.formatSuspend correctly when range formatting is disabled") { 39 | val testContent = "val test = \"test\"" 40 | val successContent = "val test = \"test\"" 41 | val formatResult = FormatResult(formattedContent = successContent) 42 | 43 | every { formattingRequest.documentText } returns testContent 44 | coEvery { 45 | dprintService.formatSuspend(path, testContent, 1) 46 | } returns formatResult 47 | 48 | dprintFormattingTask.run() 49 | 50 | coVerify(exactly = 1) { dprintService.formatSuspend(path, testContent, 1) } 51 | verify { formattingRequest.onTextReady(successContent) } 52 | } 53 | 54 | test("it calls dprintService.cancel with the format id when cancelled") { 55 | val testContent = "val test = \"test\"" 56 | 57 | mockkStatic("com.dprint.utils.LogUtilsKt") 58 | every { infoLogWithConsole(any(), project, any()) } returns Unit 59 | every { errorLogWithConsole(any(), any(), project, any()) } returns Unit 60 | every { formattingRequest.documentText } returns testContent 61 | every { dprintService.canCancelFormat() } returns true 62 | every { dprintService.cancelFormat(1) } returns Unit 63 | every { dprintService.maybeGetFormatId() } returns 1 64 | 65 | dprintFormattingTask.cancel() 66 | dprintFormattingTask.run() 67 | 68 | // When cancelled before any formatting starts, no format IDs are generated 69 | // so cancelFormat won't be called, but the cancellation flag prevents formatting 70 | verify(exactly = 0) { formattingRequest.onTextReady(any()) } 71 | } 72 | 73 | test("it calls formattingRequest.onError when the format returns a failure state") { 74 | val testContent = "val test = \"test\"" 75 | val testFailure = "Test failure" 76 | val formatResult = FormatResult(error = testFailure) 77 | 78 | every { formattingRequest.documentText } returns testContent 79 | coEvery { 80 | dprintService.formatSuspend(path, testContent, 1) 81 | } returns formatResult 82 | 83 | dprintFormattingTask.run() 84 | 85 | coVerify(exactly = 1) { dprintService.formatSuspend(path, testContent, 1) } 86 | verify { formattingRequest.onError(any(), testFailure) } 87 | } 88 | }) 89 | -------------------------------------------------------------------------------- /src/test/kotlin/com/dprint/services/editorservice/EditorServiceCacheTest.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services.editorservice 2 | 3 | import com.dprint.config.ProjectConfiguration 4 | import com.dprint.services.DprintTaskExecutor 5 | import com.dprint.services.TaskType 6 | import com.dprint.utils.warnLogWithConsole 7 | import com.intellij.openapi.components.service 8 | import com.intellij.openapi.project.Project 9 | import io.kotest.core.spec.style.FunSpec 10 | import io.kotest.matchers.shouldBe 11 | import io.mockk.clearAllMocks 12 | import io.mockk.coEvery 13 | import io.mockk.every 14 | import io.mockk.just 15 | import io.mockk.mockk 16 | import io.mockk.mockkStatic 17 | import io.mockk.runs 18 | import io.mockk.verify 19 | 20 | class EditorServiceCacheTest : 21 | FunSpec({ 22 | val mockProject = mockk() 23 | val mockEditorService = mockk() 24 | val mockTaskExecutor = mockk(relaxed = true) 25 | val mockProjectConfiguration = mockk() 26 | val mockState = mockk() 27 | 28 | // Mock static logging functions 29 | mockkStatic("com.dprint.utils.LogUtilsKt") 30 | 31 | beforeEach { 32 | // Setup project service mock 33 | every { mockProject.service() } returns mockProjectConfiguration 34 | every { mockProjectConfiguration.state } returns mockState 35 | every { mockState.commandTimeout } returns 5000L 36 | 37 | // Mock logging functions to do nothing 38 | every { warnLogWithConsole(any(), any(), any()) } just runs 39 | } 40 | 41 | afterEach { 42 | clearAllMocks() 43 | } 44 | 45 | test("canFormatCached when result exists returns result") { 46 | // Given 47 | val cache = EditorServiceCache(mockProject) 48 | val path = "/test/file.ts" 49 | cache.putCanFormat(path, true) 50 | 51 | // When 52 | val result = cache.canFormatCached(path) 53 | 54 | // Then 55 | result shouldBe true 56 | } 57 | 58 | test("canFormatCached when result does not exist returns null") { 59 | // Given 60 | val cache = EditorServiceCache(mockProject) 61 | val path = "/test/file.ts" 62 | 63 | // When 64 | val result = cache.canFormatCached(path) 65 | 66 | // Then 67 | result shouldBe null 68 | } 69 | 70 | test("clearCanFormatCache removes all entries") { 71 | // Given 72 | val cache = EditorServiceCache(mockProject) 73 | cache.putCanFormat("/test/file1.ts", true) 74 | cache.putCanFormat("/test/file2.ts", false) 75 | 76 | // When 77 | cache.clearCanFormatCache() 78 | 79 | // Then 80 | cache.canFormatCached("/test/file1.ts") shouldBe null 81 | cache.canFormatCached("/test/file2.ts") shouldBe null 82 | } 83 | 84 | test("createPrimeCanFormatTask when editor service exists creates task and updates cache") { 85 | // Given 86 | val cache = EditorServiceCache(mockProject) 87 | val path = "/test/file.ts" 88 | coEvery { mockEditorService.canFormat(path) } returns true 89 | 90 | // When 91 | cache.createPrimeCanFormatTask(path, { mockEditorService }, mockTaskExecutor) 92 | 93 | // Then 94 | verify { 95 | mockTaskExecutor.createTaskWithTimeout( 96 | match { it.taskType == TaskType.PrimeCanFormat && it.path == path }, 97 | any(), 98 | any(), 99 | 5000L, 100 | ) 101 | } 102 | } 103 | 104 | test("putCanFormat overwrites existing value") { 105 | // Given 106 | val cache = EditorServiceCache(mockProject) 107 | val path = "/test/file.ts" 108 | cache.putCanFormat(path, true) 109 | 110 | // When 111 | cache.putCanFormat(path, false) 112 | 113 | // Then 114 | cache.canFormatCached(path) shouldBe false 115 | } 116 | }) 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dprint 2 | 3 | 4 | This plugin adds support for dprint, a flexible and extensible code formatter ([dprint.dev](https://dprint.dev/)). 5 | Please report bugs and feature requests to our [github](https://github.com/dprint/dprint-intellij/issues). 6 | 7 | N.B. Currently only UTF-8 file formats are supported correctly. 8 | 9 | To use this plugin: 10 | 11 | - Install and configure dprint for your repository, [dprint.dev/setup](https://dprint.dev/setup/) 12 | - Configure this plugin at `Preferences` -> `Tools` -> `dprint`. 13 | - Ensure `Enable dprint` is checked 14 | - To run dprint on save check `Run dprint on save`. 15 | - To enable overriding the default IntelliJ formatter check `Default formatter override`. If a file can be 16 | formatted via dprint, the default IntelliJ formatter will be overridden and dprint will be run in its place when 17 | using Option+Shift+Cmd+L on macOS or Alt+Shift+Ctrl+L on Windows and Linux. 18 | - To enable verbose logging from the underlying dprint daemon process check `Verbose daemon logging` 19 | - If your `dprint.json` config file isn't at the base of your project, provide the absolute path to your config in 20 | the `Config file path` field, otherwise it will be detected automatically. 21 | - If dprint isn't on your path or in `node_modules`, provide the absolute path to the dprint executable in 22 | the `Executable path` field, otherwise it will be detected automatically. 23 | - Use the "Reformat with dprint" action by using the "Find Action" popup (Cmd/Ctrl+Shift+A). 24 | - Output from the plugin will be displayed in the dprint tool window. This includes config errors and any syntax errors 25 | that may be stopping your file from being formatted. 26 | 27 | This plugin uses a long-running process known as the `editor-service`. If you change your `dprint.json` file outside of 28 | IntelliJ or dprint is not formatting as expected, run the `Restart dprint` action. If you need to reset all settings 29 | to defaults, use the `Reset to Defaults` button in `Preferences` -> `Tools` -> `dprint`. This will reset all 30 | configuration values and restart the editor service. 31 | 32 | The plugin supports both dprint schema v4 and v5 and will automatically detect the appropriate version based on your dprint installation. 33 | 34 | Please report any issues with this Intellij plugin to the 35 | [github repository](https://github.com/dprint/dprint-intellij/issues). 36 | 37 | 38 | ## Installation 39 | 40 | - Manually: 41 | 42 | Download the [latest release](https://github.com/dprint/dprint-intellij/releases/latest) and install it 43 | manually using 44 | Settings/Preferences > Plugins > ⚙️ > Install plugin from disk... 45 | 46 | --- 47 | 48 | ## Development 49 | 50 | This project is currently built using JDK 21 and targets IntelliJ 2025.1+. To install on a mac with homebrew run `brew install openjdk@21` and set that 51 | as your project SDK. 52 | 53 | ### Dprint setup 54 | 55 | To test this plugin, you will require dprint installed. To install on a mac with homebrew run `brew install dprint`. 56 | When running the plugin via the `Run Plugin` configuration, add a default dprint config file by running `dprint init`. 57 | 58 | ### Intellij Setup 59 | 60 | - Set up linting settings, run Gradle > Tasks > help > ktlintGenerateBaseline. 61 | This sets up IntelliJ with appropriate formatting settings. 62 | 63 | ### Running 64 | 65 | There are 3 default run configs set up 66 | 67 | - Run Plugin - This starts up an instance of Intellij Ultimate with the plugin installed and enabled. 68 | - Run Tests - This runs linting and tests. 69 | - Run Verifications - This verifies the plugin is publishable. 70 | 71 | Depending on the version of IntelliJ you are running for development, you will need to change the `platformType` property 72 | in `gradle.properties`. It is IU for IntelliJ Ultimate and IC for IntelliJ Community. 73 | 74 | ### Plugin Architecture 75 | 76 | The plugin uses a simplified architecture centered around: 77 | - **DprintService**: Central service managing formatting operations and state 78 | - **DprintTaskExecutor**: Handles background task execution using Kotlin coroutines 79 | - **EditorService**: Communicates with the dprint CLI daemon (supports v4 and v5 schemas) 80 | - **DprintExternalFormatter**: Integrates with IntelliJ's formatting system 81 | - **Configuration**: Project and user-level settings with reset functionality 82 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/services/editorservice/v5/MessageChannel.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services.editorservice.v5 2 | 3 | import com.intellij.openapi.components.Service 4 | import com.intellij.openapi.project.Project 5 | import kotlinx.coroutines.CompletableDeferred 6 | import kotlinx.coroutines.TimeoutCancellationException 7 | import kotlinx.coroutines.withTimeout 8 | import java.util.concurrent.ConcurrentHashMap 9 | 10 | /** 11 | * Request data including the deferred result and timestamp for stale detection 12 | */ 13 | data class PendingRequest( 14 | val deferred: CompletableDeferred, 15 | val timestamp: Long, 16 | ) 17 | 18 | /** 19 | * Channel-based message handling that replaces PendingMessages. 20 | * Uses CompletableDeferred for cleaner async operations and built-in timeout handling. 21 | * Includes proper timestamp-based stale message detection. 22 | */ 23 | @Service(Service.Level.PROJECT) 24 | class MessageChannel( 25 | private val project: Project, 26 | ) { 27 | private val pendingRequests = ConcurrentHashMap() 28 | 29 | companion object { 30 | private const val DEFAULT_STALE_TIMEOUT_MS = 30_000L // 30 seconds - increased from original 10s 31 | } 32 | 33 | /** 34 | * Result wrapper that matches the original PendingMessages.Result structure 35 | */ 36 | data class Result( 37 | val type: MessageType, 38 | val data: Any?, 39 | ) 40 | 41 | /** 42 | * Send a request and wait for the response with timeout 43 | */ 44 | suspend fun sendRequest( 45 | id: Int, 46 | timeoutMs: Long, 47 | ): Result? { 48 | val deferred = CompletableDeferred() 49 | pendingRequests[id] = PendingRequest(deferred, System.currentTimeMillis()) 50 | 51 | return try { 52 | withTimeout(timeoutMs) { 53 | deferred.await() 54 | } 55 | } catch (e: TimeoutCancellationException) { 56 | pendingRequests.remove(id) 57 | null 58 | } 59 | } 60 | 61 | /** 62 | * Receive a response for a pending request 63 | */ 64 | fun receiveResponse( 65 | id: Int, 66 | result: Result, 67 | ) { 68 | pendingRequests.remove(id)?.deferred?.complete(result) 69 | } 70 | 71 | /** 72 | * Cancel a specific request 73 | */ 74 | fun cancelRequest(id: Int): Boolean = 75 | pendingRequests.remove(id)?.let { pendingRequest -> 76 | pendingRequest.deferred.complete(Result(MessageType.Dropped, null)) 77 | true 78 | } ?: false 79 | 80 | /** 81 | * Cancel all pending requests and return their IDs 82 | */ 83 | fun cancelAllRequests(): List { 84 | val cancelledIds = pendingRequests.keys.toList() 85 | val droppedResult = Result(MessageType.Dropped, null) 86 | 87 | pendingRequests.values.forEach { pendingRequest -> 88 | pendingRequest.deferred.complete(droppedResult) 89 | } 90 | pendingRequests.clear() 91 | 92 | return cancelledIds 93 | } 94 | 95 | /** 96 | * Check if there are any stale requests based on timestamp. 97 | * Stale requests are those that have been pending longer than the timeout threshold. 98 | * This replaces the broken hasStaleMessages logic with proper time-based detection. 99 | */ 100 | fun hasStaleRequests(staleTimeoutMs: Long = DEFAULT_STALE_TIMEOUT_MS): Boolean { 101 | val now = System.currentTimeMillis() 102 | return pendingRequests.values.any { pendingRequest -> 103 | now - pendingRequest.timestamp > staleTimeoutMs 104 | } 105 | } 106 | 107 | /** 108 | * Remove and cancel any stale requests that are older than the timeout. 109 | * Returns the number of stale requests that were removed. 110 | */ 111 | fun removeStaleRequests(staleTimeoutMs: Long = DEFAULT_STALE_TIMEOUT_MS): Int { 112 | val now = System.currentTimeMillis() 113 | val staleIds = mutableListOf() 114 | val droppedResult = Result(MessageType.Dropped, null) 115 | 116 | pendingRequests.forEach { (id, pendingRequest) -> 117 | if (now - pendingRequest.timestamp > staleTimeoutMs) { 118 | staleIds.add(id) 119 | pendingRequest.deferred.complete(droppedResult) 120 | } 121 | } 122 | 123 | staleIds.forEach { id -> 124 | pendingRequests.remove(id) 125 | } 126 | 127 | return staleIds.size 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/lifecycle/DprintPluginLifecycleManager.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.lifecycle 2 | 3 | import com.dprint.i18n.DprintBundle 4 | import com.dprint.services.DprintService 5 | import com.dprint.utils.infoLogWithConsole 6 | import com.intellij.ide.plugins.DynamicPluginListener 7 | import com.intellij.ide.plugins.IdeaPluginDescriptor 8 | import com.intellij.openapi.components.service 9 | import com.intellij.openapi.diagnostic.logger 10 | import com.intellij.openapi.project.ProjectManager 11 | 12 | private val LOGGER = logger() 13 | private const val DPRINT_PLUGIN_ID = "com.dprint.intellij.plugin" 14 | 15 | /** 16 | * Manages the lifecycle of the Dprint plugin during dynamic plugin operations. 17 | * Ensures proper cleanup on plugin unload and reinitialization on plugin load. 18 | * 19 | * **Note on Dynamic Plugin Support:** 20 | * 21 | * While this plugin is not fully dynamic (still requires IDE restart for installation/updates), 22 | * implementing this lifecycle manager provides several important benefits: 23 | * 24 | * 1. **Cleaner Plugin Upgrades** - Even with restart required, ensures proper shutdown 25 | * of dprint processes and clean state transitions between plugin versions 26 | * 27 | * 2. **Resource Cleanup** - Prevents orphaned dprint CLI processes and memory leaks 28 | * during plugin unload, making upgrades more reliable 29 | * 30 | * **Why This Plugin Requires Restart:** 31 | * - Tool window registration (`` extension) 32 | * - Formatting service integration (`` extension) 33 | * - Startup activities (`` extension) 34 | * - Deep integration with IntelliJ's core editor and formatting systems 35 | * 36 | * This is normal and expected behavior for plugins with similar functionality 37 | * (ESLint, Prettier, SonarLint, etc. all require restart). 38 | */ 39 | class DprintPluginLifecycleManager : DynamicPluginListener { 40 | override fun beforePluginUnload( 41 | pluginDescriptor: IdeaPluginDescriptor, 42 | isUpdate: Boolean, 43 | ) { 44 | if (pluginDescriptor.pluginId.idString != DPRINT_PLUGIN_ID) { 45 | return 46 | } 47 | 48 | LOGGER.info("Dprint plugin unloading (isUpdate: $isUpdate). Shutting down all services.") 49 | shutdownAllDprintServices() 50 | } 51 | 52 | override fun pluginLoaded(pluginDescriptor: IdeaPluginDescriptor) { 53 | if (pluginDescriptor.pluginId.idString != DPRINT_PLUGIN_ID) { 54 | return 55 | } 56 | 57 | LOGGER.info("Dprint plugin loaded. Initializing services for open projects.") 58 | initializeAllDprintServices() 59 | } 60 | 61 | /** 62 | * Shuts down DprintService for all open projects to ensure clean plugin unload. 63 | */ 64 | private fun shutdownAllDprintServices() { 65 | val projects = ProjectManager.getInstance().openProjects 66 | LOGGER.info("Shutting down Dprint services for ${projects.size} open projects") 67 | 68 | projects.forEach { project -> 69 | try { 70 | if (!project.isDisposed) { 71 | val dprintService = project.service() 72 | infoLogWithConsole( 73 | DprintBundle.message("lifecycle.plugin.shutdown.service"), 74 | project, 75 | LOGGER, 76 | ) 77 | dprintService.destroyEditorService() 78 | } 79 | } catch (e: Exception) { 80 | LOGGER.warn("Failed to shutdown Dprint service for project ${project.name}", e) 81 | } 82 | } 83 | } 84 | 85 | /** 86 | * Initializes DprintService for all open projects after plugin load. 87 | */ 88 | private fun initializeAllDprintServices() { 89 | val projects = ProjectManager.getInstance().openProjects 90 | LOGGER.info("Initializing Dprint services for ${projects.size} open projects") 91 | 92 | projects.forEach { project -> 93 | try { 94 | if (!project.isDisposed) { 95 | val dprintService = project.service() 96 | infoLogWithConsole( 97 | DprintBundle.message("lifecycle.plugin.initialize.service"), 98 | project, 99 | LOGGER, 100 | ) 101 | dprintService.initializeEditorService() 102 | } 103 | } catch (e: Exception) { 104 | LOGGER.warn("Failed to initialize Dprint service for project ${project.name}", e) 105 | } 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/services/editorservice/EditorServiceCache.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services.editorservice 2 | 3 | import com.dprint.config.ProjectConfiguration 4 | import com.dprint.i18n.DprintBundle 5 | import com.dprint.services.DprintTaskExecutor 6 | import com.dprint.services.TaskInfo 7 | import com.dprint.services.TaskType 8 | import com.dprint.utils.warnLogWithConsole 9 | import com.intellij.openapi.components.service 10 | import com.intellij.openapi.diagnostic.logger 11 | import com.intellij.openapi.project.Project 12 | import kotlinx.coroutines.runBlocking 13 | import org.apache.commons.collections4.map.LRUMap 14 | 15 | private val LOGGER = logger() 16 | 17 | /** 18 | * Manages caching of canFormat results so that the external formatter can do fast synchronous checks. 19 | * Uses an LRU cache to limit memory usage while maintaining performance. 20 | */ 21 | class EditorServiceCache( 22 | private val project: Project, 23 | ) { 24 | private var canFormatCache = LRUMap() 25 | 26 | /** 27 | * Gets a cached canFormat result. If a result doesn't exist this will return null and start a request to fill the 28 | * value in the cache. 29 | */ 30 | fun canFormatCached(path: String): Boolean? { 31 | val result = canFormatCache[path] 32 | 33 | if (result == null) { 34 | warnLogWithConsole( 35 | DprintBundle.message("editor.service.manager.no.cached.can.format", path), 36 | project, 37 | LOGGER, 38 | ) 39 | } 40 | 41 | return result 42 | } 43 | 44 | /** 45 | * Stores a canFormat result in the cache. 46 | */ 47 | fun putCanFormat( 48 | path: String, 49 | canFormat: Boolean, 50 | ) { 51 | canFormatCache[path] = canFormat 52 | } 53 | 54 | /** 55 | * Clears all cached canFormat results. 56 | */ 57 | fun clearCanFormatCache() { 58 | canFormatCache.clear() 59 | } 60 | 61 | /** 62 | * Creates a task to prime the canFormat cache for the given path. 63 | * This is used to populate the cache asynchronously. 64 | */ 65 | fun createPrimeCanFormatTask( 66 | path: String, 67 | editorServiceProvider: () -> IEditorService?, 68 | taskExecutor: DprintTaskExecutor, 69 | ) { 70 | val timeout = project.service().state.commandTimeout 71 | taskExecutor.createTaskWithTimeout( 72 | TaskInfo( 73 | taskType = TaskType.PrimeCanFormat, 74 | path = path, 75 | formatId = null, 76 | ), 77 | DprintBundle.message("editor.service.manager.priming.can.format.cache", path), 78 | { 79 | val editorService = editorServiceProvider() 80 | if (editorService == null) { 81 | warnLogWithConsole( 82 | DprintBundle.message("editor.service.manager.not.initialised"), 83 | project, 84 | LOGGER, 85 | ) 86 | return@createTaskWithTimeout 87 | } 88 | 89 | try { 90 | runBlocking { 91 | val canFormat = editorService.canFormat(path) 92 | if (canFormat == null) { 93 | com.dprint.utils.infoLogWithConsole( 94 | "Unable to determine if $path can be formatted.", 95 | project, 96 | LOGGER, 97 | ) 98 | } else { 99 | putCanFormat(path, canFormat) 100 | com.dprint.utils.infoLogWithConsole( 101 | "$path ${if (canFormat) "can" else "cannot"} be formatted", 102 | project, 103 | LOGGER, 104 | ) 105 | } 106 | } 107 | } catch (e: com.dprint.services.editorservice.exceptions.ProcessUnavailableException) { 108 | warnLogWithConsole( 109 | "Editor service process not ready for $path, will retry on next access", 110 | project, 111 | LOGGER, 112 | ) 113 | } catch (e: Exception) { 114 | warnLogWithConsole( 115 | "Failed to check if $path can be formatted: ${e.message}", 116 | project, 117 | LOGGER, 118 | ) 119 | } 120 | }, 121 | timeout, 122 | ) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/services/editorservice/v4/EditorServiceV4.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services.editorservice.v4 2 | 3 | import com.dprint.config.ProjectConfiguration 4 | import com.dprint.i18n.DprintBundle 5 | import com.dprint.services.editorservice.FormatResult 6 | import com.dprint.services.editorservice.IEditorService 7 | import com.dprint.services.editorservice.process.EditorProcess 8 | import com.dprint.utils.infoLogWithConsole 9 | import com.dprint.utils.warnLogWithConsole 10 | import com.intellij.openapi.components.Service 11 | import com.intellij.openapi.components.service 12 | import com.intellij.openapi.diagnostic.logger 13 | import com.intellij.openapi.project.Project 14 | 15 | private const val CHECK_COMMAND = 1 16 | private const val FORMAT_COMMAND = 2 17 | 18 | private val LOGGER = logger() 19 | 20 | @Service(Service.Level.PROJECT) 21 | class EditorServiceV4( 22 | private val project: Project, 23 | ) : IEditorService { 24 | private var editorProcess = project.service() 25 | 26 | override fun initialiseEditorService() { 27 | // If not enabled we don't start the editor service 28 | if (!project.service().state.enabled) return 29 | editorProcess.initialize() 30 | infoLogWithConsole( 31 | DprintBundle.message("editor.service.initialize", getName()), 32 | project, 33 | LOGGER, 34 | ) 35 | } 36 | 37 | override fun dispose() { 38 | destroyEditorService() 39 | } 40 | 41 | override fun destroyEditorService() { 42 | infoLogWithConsole(DprintBundle.message("editor.service.destroy", getName()), project, LOGGER) 43 | editorProcess.destroy() 44 | } 45 | 46 | override suspend fun canFormat(filePath: String): Boolean? { 47 | infoLogWithConsole(DprintBundle.message("formatting.checking.can.format", filePath), project, LOGGER) 48 | 49 | editorProcess.writeInt(CHECK_COMMAND) 50 | editorProcess.writeString(filePath) 51 | editorProcess.writeSuccess() 52 | 53 | // https://github.com/dprint/dprint/blob/main/docs/editor-extension-development.md 54 | // this command sequence returns 1 if the file can be formatted 55 | val status: Int = editorProcess.readInt() 56 | editorProcess.readAndAssertSuccess() 57 | 58 | val result = status == 1 59 | when (result) { 60 | true -> infoLogWithConsole(DprintBundle.message("formatting.can.format", filePath), project, LOGGER) 61 | false -> infoLogWithConsole(DprintBundle.message("formatting.cannot.format", filePath), project, LOGGER) 62 | } 63 | return result 64 | } 65 | 66 | override suspend fun fmt( 67 | filePath: String, 68 | content: String, 69 | formatId: Int?, 70 | startIndex: Int?, 71 | endIndex: Int?, 72 | ): FormatResult = fmt(filePath, content, formatId) 73 | 74 | override suspend fun fmt( 75 | filePath: String, 76 | content: String, 77 | formatId: Int?, 78 | ): FormatResult { 79 | var result = FormatResult() 80 | 81 | infoLogWithConsole(DprintBundle.message("formatting.file", filePath), project, LOGGER) 82 | 83 | editorProcess.writeInt(FORMAT_COMMAND) 84 | editorProcess.writeString(filePath) 85 | editorProcess.writeString(content) 86 | editorProcess.writeSuccess() 87 | 88 | when (editorProcess.readInt()) { 89 | 0 -> { 90 | infoLogWithConsole( 91 | DprintBundle.message("editor.service.format.not.needed", filePath), 92 | project, 93 | LOGGER, 94 | ) 95 | } // no-op as content didn't change 96 | 1 -> { 97 | result = FormatResult(formattedContent = editorProcess.readString()) 98 | infoLogWithConsole( 99 | DprintBundle.message("editor.service.format.succeeded", filePath), 100 | project, 101 | LOGGER, 102 | ) 103 | } 104 | 105 | 2 -> { 106 | val error = editorProcess.readString() 107 | result = FormatResult(error = error) 108 | warnLogWithConsole( 109 | DprintBundle.message("editor.service.format.failed", filePath, error), 110 | project, 111 | LOGGER, 112 | ) 113 | } 114 | } 115 | 116 | editorProcess.readAndAssertSuccess() 117 | return result 118 | } 119 | 120 | override fun canRangeFormat(): Boolean = false 121 | 122 | override fun canCancelFormat(): Boolean = false 123 | 124 | override fun maybeGetFormatId(): Int? = null 125 | 126 | private fun getName(): String = this::class.java.simpleName 127 | } 128 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/formatter/DprintFormattingTask.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.formatter 2 | 3 | import com.dprint.i18n.DprintBundle 4 | import com.dprint.services.DprintService 5 | import com.dprint.services.editorservice.FormatResult 6 | import com.dprint.utils.errorLogWithConsole 7 | import com.dprint.utils.infoLogWithConsole 8 | import com.intellij.formatting.service.AsyncFormattingRequest 9 | import com.intellij.openapi.diagnostic.logger 10 | import com.intellij.openapi.project.Project 11 | import kotlinx.coroutines.CancellationException 12 | import kotlinx.coroutines.TimeoutCancellationException 13 | import kotlinx.coroutines.runBlocking 14 | import kotlinx.coroutines.withTimeout 15 | import kotlin.time.Duration.Companion.seconds 16 | 17 | private val LOGGER = logger() 18 | private val FORMATTING_TIMEOUT = 10.seconds 19 | 20 | class DprintFormattingTask( 21 | private val project: Project, 22 | private val dprintService: DprintService, 23 | private val formattingRequest: AsyncFormattingRequest, 24 | private val path: String, 25 | ) { 26 | private var formattingIds = mutableListOf() 27 | private var isCancelled = false 28 | 29 | fun run() { 30 | val content = formattingRequest.documentText 31 | 32 | infoLogWithConsole( 33 | DprintBundle.message("external.formatter.running.task", path), 34 | project, 35 | LOGGER, 36 | ) 37 | 38 | val result = runFormatting(content) 39 | 40 | // If cancelled there is no need to utilise the formattingRequest finalising methods 41 | if (isCancelled) return 42 | 43 | // If the result is null we don't want to change the document text, so we just set it to be the original. 44 | // This should only happen if formatting throws. 45 | if (result == null) { 46 | formattingRequest.onTextReady(content) 47 | return 48 | } 49 | 50 | val error = result.error 51 | if (error != null) { 52 | formattingRequest.onError(DprintBundle.message("formatting.error"), error) 53 | } else { 54 | // If the result is a no op it will be null, in which case we pass the original content back in 55 | formattingRequest.onTextReady(result.formattedContent ?: content) 56 | } 57 | } 58 | 59 | private fun runFormatting(content: String): FormatResult? { 60 | return try { 61 | runBlocking { 62 | withTimeout(FORMATTING_TIMEOUT) { 63 | if (isCancelled) return@withTimeout null 64 | // Need to update the formatting id so the correct job would be cancelled 65 | val formatId = dprintService.maybeGetFormatId() 66 | formatId?.let { 67 | formattingIds.add(it) 68 | } 69 | 70 | dprintService.formatSuspend( 71 | path = path, 72 | content = content, 73 | formatId = formatId, 74 | ) 75 | } 76 | } 77 | } catch (e: CancellationException) { 78 | // This is expected when the user cancels the operation 79 | infoLogWithConsole(DprintBundle.message("error.format.cancelled.by.user"), project, LOGGER) 80 | null 81 | } catch (e: TimeoutCancellationException) { 82 | errorLogWithConsole( 83 | DprintBundle.message("error.format.timeout", FORMATTING_TIMEOUT.inWholeSeconds), 84 | e, 85 | project, 86 | LOGGER, 87 | ) 88 | formattingRequest.onError( 89 | DprintBundle.message("dialog.title.dprint.formatter"), 90 | DprintBundle.message("error.format.process.timeout", FORMATTING_TIMEOUT.inWholeSeconds), 91 | ) 92 | dprintService.restartEditorService() 93 | null 94 | } catch (e: Exception) { 95 | val message = 96 | DprintBundle.message("error.format.unexpected", e.javaClass.simpleName, e.message ?: "unknown") 97 | errorLogWithConsole( 98 | message, 99 | e, 100 | project, 101 | LOGGER, 102 | ) 103 | formattingRequest.onError( 104 | DprintBundle.message("dialog.title.dprint.formatter"), 105 | message, 106 | ) 107 | // Only restart service for unexpected errors that might indicate a corrupted state 108 | dprintService.restartEditorService() 109 | null 110 | } 111 | } 112 | 113 | fun cancel(): Boolean { 114 | if (!dprintService.canCancelFormat()) return false 115 | 116 | isCancelled = true 117 | for (id in formattingIds) { 118 | infoLogWithConsole( 119 | DprintBundle.message("external.formatter.cancelling.task", id), 120 | project, 121 | LOGGER, 122 | ) 123 | dprintService.cancelFormat(id) 124 | } 125 | 126 | return true 127 | } 128 | 129 | fun isRunUnderProgress(): Boolean = true 130 | } 131 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/services/editorservice/v5/StdoutListener.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services.editorservice.v5 2 | 3 | import com.dprint.i18n.DprintBundle 4 | import com.dprint.services.editorservice.process.EditorProcess 5 | import com.intellij.openapi.diagnostic.logger 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.Job 9 | import kotlinx.coroutines.SupervisorJob 10 | import kotlinx.coroutines.cancel 11 | import kotlinx.coroutines.delay 12 | import kotlinx.coroutines.isActive 13 | import kotlinx.coroutines.launch 14 | import java.nio.BufferUnderflowException 15 | 16 | private const val SLEEP_TIME = 500L 17 | 18 | private val LOGGER = logger() 19 | 20 | class StdoutListener( 21 | private val editorProcess: EditorProcess, 22 | private val messageChannel: MessageChannel, 23 | ) { 24 | private var listenerJob: Job? = null 25 | private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) 26 | 27 | fun listen() { 28 | listenerJob = 29 | scope.launch { 30 | try { 31 | LOGGER.info(DprintBundle.message("editor.service.started.stdout.listener")) 32 | while (isActive) { 33 | try { 34 | handleStdout() 35 | } catch (e: BufferUnderflowException) { 36 | // Happens when the editor service is shut down while waiting to read output 37 | if (isActive) { 38 | LOGGER.info("Buffer underflow while reading stdout", e) 39 | } 40 | break 41 | } catch (e: Exception) { 42 | if (isActive) { 43 | LOGGER.error(DprintBundle.message("editor.service.read.failed"), e) 44 | delay(SLEEP_TIME) 45 | } else { 46 | break 47 | } 48 | } 49 | } 50 | } catch (e: Exception) { 51 | if (isActive) { 52 | LOGGER.error("Error in stdout listener", e) 53 | } 54 | } 55 | } 56 | } 57 | 58 | fun dispose() { 59 | listenerJob?.cancel() 60 | scope.cancel() 61 | } 62 | 63 | private suspend fun handleStdout() { 64 | val messageId = editorProcess.readInt() 65 | val messageType = editorProcess.readInt() 66 | val bodyLength = editorProcess.readInt() 67 | val body = IncomingMessage(editorProcess.readBuffer(bodyLength)) 68 | editorProcess.readAndAssertSuccess() 69 | 70 | when (messageType) { 71 | MessageType.SuccessResponse.intValue -> { 72 | val responseId = body.readInt() 73 | val result = MessageChannel.Result(MessageType.SuccessResponse, null) 74 | messageChannel.receiveResponse(responseId, result) 75 | } 76 | 77 | MessageType.ErrorResponse.intValue -> { 78 | val responseId = body.readInt() 79 | val errorMessage = body.readSizedString() 80 | LOGGER.info(DprintBundle.message("editor.service.received.error.response", errorMessage)) 81 | val result = MessageChannel.Result(MessageType.ErrorResponse, errorMessage) 82 | messageChannel.receiveResponse(responseId, result) 83 | } 84 | 85 | MessageType.Active.intValue -> { 86 | sendSuccess(messageId) 87 | } 88 | 89 | MessageType.CanFormatResponse.intValue -> { 90 | val responseId = body.readInt() 91 | val canFormatResult = body.readInt() 92 | val result = MessageChannel.Result(MessageType.CanFormatResponse, canFormatResult == 1) 93 | messageChannel.receiveResponse(responseId, result) 94 | } 95 | 96 | MessageType.FormatFileResponse.intValue -> { 97 | val responseId = body.readInt() 98 | val hasChanged = body.readInt() 99 | val text = 100 | when (hasChanged == 1) { 101 | true -> body.readSizedString() 102 | false -> null 103 | } 104 | val result = MessageChannel.Result(MessageType.FormatFileResponse, text) 105 | messageChannel.receiveResponse(responseId, result) 106 | } 107 | 108 | else -> { 109 | val errorMessage = DprintBundle.message("editor.service.unsupported.message.type", messageType) 110 | LOGGER.info(errorMessage) 111 | sendFailure(messageId, errorMessage) 112 | } 113 | } 114 | } 115 | 116 | private suspend fun sendSuccess(messageId: Int) { 117 | val message = createNewMessage(MessageType.SuccessResponse) 118 | message.addInt(messageId) 119 | sendResponse(message) 120 | } 121 | 122 | private suspend fun sendFailure( 123 | messageId: Int, 124 | errorMessage: String, 125 | ) { 126 | val message = createNewMessage(MessageType.ErrorResponse) 127 | message.addInt(messageId) 128 | message.addString(errorMessage) 129 | sendResponse(message) 130 | } 131 | 132 | private suspend fun sendResponse(outgoingMessage: OutgoingMessage) { 133 | editorProcess.writeBuffer(outgoingMessage.build()) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/utils/FileUtils.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.utils 2 | 3 | import com.dprint.config.ProjectConfiguration 4 | import com.dprint.i18n.DprintBundle 5 | import com.intellij.diff.util.DiffUtil 6 | import com.intellij.execution.configurations.GeneralCommandLine 7 | import com.intellij.execution.util.ExecUtil 8 | import com.intellij.ide.scratch.ScratchUtil 9 | import com.intellij.openapi.components.service 10 | import com.intellij.openapi.diagnostic.logger 11 | import com.intellij.openapi.project.Project 12 | import com.intellij.openapi.vfs.VirtualFile 13 | import java.io.File 14 | 15 | // Need this for the IntelliJ logger 16 | private object FileUtils 17 | 18 | private val LOGGER = logger() 19 | private val DEFAULT_CONFIG_NAMES = 20 | listOf( 21 | "dprint.json", 22 | ".dprint.json", 23 | "dprint.jsonc", 24 | ".dprint.jsonc", 25 | ) 26 | 27 | /* 28 | * Utils for checking that dprint is configured correctly outside intellij. 29 | */ 30 | 31 | /** 32 | * Validates that a path is a valid json file 33 | */ 34 | fun validateConfigFile(path: String): Boolean { 35 | val file = File(path) 36 | return file.exists() && (file.extension == "json" || file.extension == "jsonc") 37 | } 38 | 39 | /** 40 | * Gets a valid config file path 41 | */ 42 | fun getValidConfigPath(project: Project): String? { 43 | val config = project.service() 44 | val configuredPath = config.state.configLocation 45 | 46 | when { 47 | validateConfigFile(configuredPath) -> return configuredPath 48 | configuredPath.isNotBlank() -> 49 | infoLogWithConsole( 50 | DprintBundle.message("notification.invalid.config.path"), 51 | project, 52 | LOGGER, 53 | ) 54 | } 55 | 56 | val basePath = project.basePath 57 | val allDirs = mutableListOf() 58 | 59 | // get all parent directories 60 | var currentDir = basePath 61 | while (currentDir != null) { 62 | allDirs.add(currentDir) 63 | currentDir = File(currentDir).parent 64 | } 65 | 66 | // look for the first valid dprint config file by looking in the project base directory and 67 | // moving up its parents until one is found 68 | for (dir in allDirs) { 69 | for (fileName in DEFAULT_CONFIG_NAMES) { 70 | val file = File(dir, fileName) 71 | when { 72 | file.exists() -> return file.path 73 | file.exists() -> 74 | warnLogWithConsole( 75 | DprintBundle.message("notification.invalid.default.config", file.path), 76 | project, 77 | LOGGER, 78 | ) 79 | } 80 | } 81 | } 82 | 83 | infoLogWithConsole(DprintBundle.message("notification.config.not.found"), project, LOGGER) 84 | 85 | return null 86 | } 87 | 88 | /** 89 | * Helper function to find out if a given virtual file is formattable. Some files, 90 | * such as scratch files and diff views will never be formattable by dprint, so 91 | * we use this to identify them early and thus save the trip to the dprint daemon. 92 | */ 93 | fun isFormattableFile( 94 | project: Project, 95 | virtualFile: VirtualFile, 96 | ): Boolean { 97 | val isScratch = ScratchUtil.isScratch(virtualFile) 98 | if (isScratch) { 99 | infoLogWithConsole(DprintBundle.message("formatting.scratch.files", virtualFile.path), project, LOGGER) 100 | } 101 | 102 | return virtualFile.isWritable && 103 | virtualFile.isInLocalFileSystem && 104 | !isScratch && 105 | !DiffUtil.isFileWithoutContent(virtualFile) 106 | } 107 | 108 | /** 109 | * Validates a path ends with 'dprint' or 'dprint.exe' and is executable 110 | */ 111 | fun validateExecutablePath(path: String): Boolean = path.endsWith(getExecutableFile()) && File(path).canExecute() 112 | 113 | /** 114 | * Attempts to get the dprint executable location by checking to see if it is discoverable. 115 | */ 116 | private fun getLocationFromThePath(): String? { 117 | val args = listOf(if (System.getProperty("os.name").lowercase().contains("win")) "where" else "which", "dprint") 118 | val commandLine = GeneralCommandLine(args) 119 | val output = ExecUtil.execAndGetOutput(commandLine) 120 | 121 | if (output.checkSuccess(LOGGER)) { 122 | val maybePath = output.stdout.trim() 123 | if (File(maybePath).canExecute()) { 124 | return maybePath 125 | } 126 | } 127 | 128 | return null 129 | } 130 | 131 | /** 132 | * Attempts to get the dprint executable location by checking node modules 133 | */ 134 | private fun getLocationFromTheNodeModules(basePath: String?): String? { 135 | basePath?.let { 136 | val path = "$it/node_modules/dprint/${getExecutableFile()}" 137 | if (validateExecutablePath(path)) return path 138 | } 139 | return null 140 | } 141 | 142 | private fun getExecutableFile(): String = 143 | when (System.getProperty("os.name").lowercase().contains("win")) { 144 | true -> "dprint.exe" 145 | false -> "dprint" 146 | } 147 | 148 | /** 149 | * Gets a valid dprint executable path. It will try to use the configured path and will fall 150 | * back to a path that is discoverable via the command line 151 | * 152 | * TODO Cache this per session so we only need to get the location from the path once 153 | */ 154 | fun getValidExecutablePath(project: Project): String? { 155 | val config = project.service() 156 | val configuredExecutablePath = config.state.executableLocation 157 | 158 | when { 159 | validateExecutablePath(configuredExecutablePath) -> return configuredExecutablePath 160 | configuredExecutablePath.isNotBlank() -> 161 | errorLogWithConsole( 162 | DprintBundle.message("notification.invalid.executable.path"), 163 | project, 164 | LOGGER, 165 | ) 166 | } 167 | 168 | getLocationFromTheNodeModules(project.basePath)?.let { 169 | return it 170 | } 171 | getLocationFromThePath()?.let { 172 | return it 173 | } 174 | 175 | errorLogWithConsole(DprintBundle.message("notification.executable.not.found"), project, LOGGER) 176 | 177 | return null 178 | } 179 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # dprint Changelog 4 | 5 | ## [Unreleased] 6 | 7 | ## 0.9.0 - 2025-12-23 8 | 9 | - Rearchitected to use coroutines for asynchronous operations, hopefully improving CPU utilisation. Please report any issues to the github, as there should be no changes to the experience. 10 | - Updated build system to Gradle 9.2.1 and IntelliJ Platform Gradle Plugin 2.10.5 11 | - Fixed test configuration to properly support Kotest with JUnit 5 while maintaining compatibility with Gradle test infrastructure 12 | 13 | ## 0.8.4 - 2025-06-25 14 | 15 | - Fix issue where characters with special character encodings were causing the supplied range to the dprint daemon to 16 | be incorrect. This is a side effect of https://github.com/dprint/dprint-intellij/pull/107 as IJ now sends a full file 17 | range when the internal IJ formatter is overridden. 18 | 19 | ## 0.8.3 - 2025-06-07 20 | 21 | - While range formatting is not currently available, allow format fragment flows to come through so logging occurs. 22 | - Publish formatting stats through the DprintAction message bus topic. 23 | 24 | ## 0.8.2 - 2025-01-14 25 | 26 | - Update plugin name for marketplace verification 27 | 28 | ## 0.8.1 - 2024-12-06 29 | 30 | - Reinstate require restart, even though the plugin doesn't technically need it, it avoids issues that a restart would 31 | fix. 32 | 33 | ## 0.8.0 - 2024-12-05 34 | 35 | - Removed json validation of config file 36 | - Set minimum versions to 2024 37 | - Updated to newer build tooling 38 | 39 | ## 0.7.0 - 2024-07-20 40 | 41 | - Fixed an issue where initialisation could get into an infinite loop. 42 | - Default to the IntelliJ formatter when the canFormat cache is cold for a file. 43 | - Stop self-healing restarts and show error baloons every time a startup fails. 44 | 45 | ## 0.6.0 - 2024-02-08 46 | 47 | - Fixed issue where on save actions were running twice per save. 48 | - Enforce that only a initialisation or format can be queued to run at a time. 49 | - Added checkbox to `Tools -> Actions on Save` menu. 50 | - Added notification when the underlying dprint daemon cannot initialise. 51 | - Fixed an issue where an error could be thrown when closing or changing projects due to a race condition. 52 | - Added timeout configuration for the initialisation of the dprint daemon and for commands. 53 | 54 | ## 0.5.0 - 2023-12-22 55 | 56 | - Add check for dead processes to warn users that the dprint daemon is not responding 57 | - Increase severity of logging in the event processes die or errors are seen in process communication 58 | - This may be a little noisy, and if so disabling the plugin is recommended unless the underlying issue with the 59 | process can be fixed 60 | - For intermittent or one off errors, just restart the dprint plugin via the `Restart dprint` action 61 | - Upgrade dependencies 62 | - Attempt to fix changelog update on publish 63 | 64 | ## 0.4.4 - 2023-11-23 65 | 66 | - Fixed issue for plugins that require the underlying process to be running in the working projects git repository. 67 | 68 | ## 0.4.3 - 2023-10-11 69 | 70 | - Update to latest dependencies 71 | 72 | ## 0.4.2 73 | 74 | - Fix issue with run on save config not saving 75 | 76 | ## 0.4.1 77 | 78 | - Fix null pointer issue in external formatter 79 | 80 | ## 0.4.0 81 | 82 | - Run dprint after Eslint fixes have been applied 83 | - Ensure dprint doesn't attempt to check/format scratch files (perf optimisation) or diff views 84 | - Add verbose logging config 85 | - Add override default IntelliJ formatter config 86 | 87 | ## 0.3.9 88 | 89 | - Fix issue where windows systems reported invalid executables 90 | - Add automatic node module detection from the base project path 91 | - Add 2022.2 to supported versions 92 | 93 | ## 0.3.8 94 | 95 | - Fix issue causing IntelliJ to hang on shutdown 96 | 97 | ## 0.3.7 98 | 99 | - Performance improvements 100 | - Invalidate cache on restart 101 | 102 | ## 0.3.6 103 | 104 | - Fix issue where using the IntelliJ formatter would result in a no-op on every second format, IntelliJ is reporting 105 | larger formatting ranges that content length and dprint would not format these files 106 | - Better handling of virtual files 107 | - Silence an error that is thrown when restarting dprint 108 | - Improve verbose logging in the console 109 | - Add a listener to detect config changes, note this only detects changes made inside IntelliJ 110 | 111 | ## 0.3.5 112 | 113 | - Fix issue when performing code refactoring 114 | 115 | ## 0.3.4 116 | 117 | - Reduce timeout when checking if a file can be formatted in the external formatter 118 | - Cache whether files can be formatted by dprint and create an action to clear this 119 | - Remove custom synchronization and move to an IntelliJ background task queue for dprint tasks (this appears to solve 120 | the hard to reproduce lock up issues) 121 | 122 | ## 0.3.3 123 | 124 | - Handle execution exceptions when running can format 125 | - Ensure on save action is only run when on user triggered saves 126 | 127 | ## 0.3.2 128 | 129 | - Fix intermittent lock up when running format 130 | 131 | ## 0.3.1 132 | 133 | - Fix versioning to allow for 2021.3.x installs 134 | 135 | ## 0.3.0 136 | 137 | - Introduced support for v5 of the dprint schema 138 | - Added a dprint tool window to provide better output of the formatting process 139 | - Added the `Restart Dprint` action so the underlying editor service can be restarted without needed to go to 140 | preferences 141 | - Removed the default key command of `cmd/ctrl+shift+option+D`, it clashed with too many other key commands. Users can 142 | still map this manually should they want it. 143 | 144 | ## 0.2.3 145 | 146 | - Support all future versions of IntelliJ 147 | 148 | ## 0.2.2 149 | 150 | - Fix error that was thrown when code refactoring tools were used 151 | - Synchronize the editor service so two processes can't interrupt each others formatting 152 | - Reduce log spam 153 | 154 | ## 0.2.1 155 | 156 | - Fix issues with changelog 157 | 158 | ## 0.2.0 159 | 160 | - Added support for the inbuilt IntelliJ formatter. This allows dprint to be run at the same time as optimizing imports 161 | using `shift + option + command + L` 162 | 163 | ## 0.1.3 164 | 165 | - Fix issue where the inability to parse the schema would stop a project form opening. 166 | 167 | ## 0.1.2 168 | 169 | - Release first public version of the plugin. 170 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/services/DprintTaskExecutor.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services 2 | 3 | import com.dprint.i18n.DprintBundle 4 | import com.dprint.utils.errorLogWithConsole 5 | import com.dprint.utils.infoLogWithConsole 6 | import com.intellij.openapi.Disposable 7 | import com.intellij.openapi.diagnostic.logger 8 | import com.intellij.openapi.progress.ProgressIndicator 9 | import com.intellij.openapi.progress.ProgressManager 10 | import com.intellij.openapi.progress.Task 11 | import com.intellij.openapi.project.Project 12 | import kotlinx.coroutines.CoroutineScope 13 | import kotlinx.coroutines.Dispatchers 14 | import kotlinx.coroutines.SupervisorJob 15 | import kotlinx.coroutines.cancel 16 | import kotlinx.coroutines.channels.Channel 17 | import kotlinx.coroutines.launch 18 | import kotlinx.coroutines.runBlocking 19 | import kotlinx.coroutines.withContext 20 | import kotlinx.coroutines.withTimeout 21 | import kotlin.coroutines.CoroutineContext 22 | import kotlin.time.Duration.Companion.milliseconds 23 | 24 | enum class TaskType { 25 | Initialisation, 26 | PrimeCanFormat, 27 | Format, 28 | Cancel, 29 | } 30 | 31 | data class TaskInfo( 32 | val taskType: TaskType, 33 | val path: String?, 34 | val formatId: Int?, 35 | ) 36 | 37 | private val LOGGER = logger() 38 | 39 | /** 40 | * Simplified task executor that combines the functionality of EditorServiceTaskQueue 41 | * and CoroutineBackgroundTaskQueue into a single, more maintainable class. 42 | */ 43 | class DprintTaskExecutor( 44 | private val project: Project, 45 | ) : CoroutineScope, 46 | Disposable { 47 | private val job = SupervisorJob() 48 | private val taskQueue = Channel(Channel.UNLIMITED) 49 | private val activeTasks = HashSet() 50 | 51 | override val coroutineContext: CoroutineContext 52 | get() = Dispatchers.Default + job 53 | 54 | init { 55 | launch { 56 | for (task in taskQueue) { 57 | try { 58 | executeTask(task) 59 | } catch (e: Exception) { 60 | LOGGER.error("Task failed: ${task.title}", e) 61 | } 62 | } 63 | } 64 | } 65 | 66 | data class QueuedTask( 67 | val taskInfo: TaskInfo, 68 | val title: String, 69 | val operation: () -> Unit, 70 | val timeout: Long, 71 | val canBeCancelled: Boolean = true, 72 | val onFailure: ((Throwable) -> Unit)? = null, 73 | ) 74 | 75 | fun createTaskWithTimeout( 76 | taskInfo: TaskInfo, 77 | title: String, 78 | operation: () -> Unit, 79 | timeout: Long, 80 | ) { 81 | createTaskWithTimeout(taskInfo, title, operation, timeout, null) 82 | } 83 | 84 | fun createTaskWithTimeout( 85 | taskInfo: TaskInfo, 86 | title: String, 87 | operation: () -> Unit, 88 | timeout: Long, 89 | onFailure: ((Throwable) -> Unit)?, 90 | ) { 91 | if (activeTasks.contains(taskInfo)) { 92 | infoLogWithConsole(DprintBundle.message("error.task.already.queued", taskInfo), project, LOGGER) 93 | return 94 | } 95 | activeTasks.add(taskInfo) 96 | 97 | val task = 98 | QueuedTask( 99 | taskInfo = taskInfo, 100 | title = title, 101 | operation = { 102 | try { 103 | operation() 104 | } finally { 105 | // Always remove from active tasks, whether success or failure 106 | activeTasks.remove(taskInfo) 107 | } 108 | }, 109 | timeout = timeout, 110 | canBeCancelled = true, 111 | onFailure = { e -> 112 | onFailure?.invoke(e) 113 | errorLogWithConsole(DprintBundle.message("error.unexpected", title), e, project, LOGGER) 114 | }, 115 | ) 116 | 117 | launch { 118 | taskQueue.send(task) 119 | } 120 | } 121 | 122 | private fun executeTask(queueTask: QueuedTask) { 123 | val task = 124 | object : Task.Backgroundable(project, queueTask.title, queueTask.canBeCancelled) { 125 | override fun run(indicator: ProgressIndicator) { 126 | indicator.text = queueTask.title 127 | LOGGER.info(DprintBundle.message("status.task.executor.running", queueTask.title)) 128 | 129 | runBlocking { 130 | try { 131 | withTimeout(queueTask.timeout.milliseconds) { 132 | withContext(Dispatchers.Default) { 133 | queueTask.operation() 134 | } 135 | } 136 | } catch (e: kotlinx.coroutines.TimeoutCancellationException) { 137 | queueTask.onFailure?.invoke( 138 | java.util.concurrent.TimeoutException( 139 | DprintBundle.message("error.operation.timeout", queueTask.timeout), 140 | ), 141 | ) 142 | LOGGER.error("Timeout: ${queueTask.title}", e) 143 | } catch (e: Exception) { 144 | queueTask.onFailure?.invoke(e) 145 | LOGGER.error("Exception in task: ${queueTask.title}", e) 146 | } 147 | } 148 | } 149 | } 150 | 151 | ProgressManager.getInstance().run(task) 152 | } 153 | 154 | override fun dispose() { 155 | LOGGER.info("Disposing DprintTaskExecutor for project: ${project.name}") 156 | try { 157 | // Clear active tasks 158 | activeTasks.clear() 159 | 160 | // Close the task queue 161 | taskQueue.close() 162 | 163 | // Cancel the coroutine job with a descriptive message 164 | job.cancel(DprintBundle.message("error.task.executor.disposed")) 165 | 166 | LOGGER.info("Successfully disposed DprintTaskExecutor for project: ${project.name}") 167 | } catch (e: Exception) { 168 | LOGGER.error("Error disposing DprintTaskExecutor for project: ${project.name}", e) 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/formatter/DprintRangeFormattingTask.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.formatter 2 | 3 | import com.dprint.i18n.DprintBundle 4 | import com.dprint.services.DprintService 5 | import com.dprint.services.editorservice.FormatResult 6 | import com.dprint.utils.errorLogWithConsole 7 | import com.dprint.utils.infoLogWithConsole 8 | import com.intellij.formatting.service.AsyncFormattingRequest 9 | import com.intellij.openapi.diagnostic.logger 10 | import com.intellij.openapi.project.Project 11 | import com.intellij.openapi.util.TextRange 12 | import kotlinx.coroutines.CancellationException 13 | import kotlinx.coroutines.TimeoutCancellationException 14 | import kotlinx.coroutines.runBlocking 15 | import kotlinx.coroutines.withTimeout 16 | import kotlin.time.Duration.Companion.seconds 17 | 18 | private val LOGGER = logger() 19 | private val FORMATTING_TIMEOUT = 10.seconds 20 | 21 | class DprintRangeFormattingTask( 22 | private val project: Project, 23 | private val dprintService: DprintService, 24 | private val formattingRequest: AsyncFormattingRequest, 25 | private val path: String, 26 | ) { 27 | private var formattingIds = mutableListOf() 28 | private var isCancelled = false 29 | 30 | fun run() { 31 | val content = formattingRequest.documentText 32 | val ranges = formattingRequest.formattingRanges 33 | 34 | infoLogWithConsole( 35 | DprintBundle.message("external.formatter.running.task", path), 36 | project, 37 | LOGGER, 38 | ) 39 | 40 | val result = runRangeFormatting(content, ranges) 41 | 42 | // If cancelled there is no need to utilise the formattingRequest finalising methods 43 | if (isCancelled) return 44 | 45 | // If the result is null we don't want to change the document text, so we just set it to be the original. 46 | // This should only happen if formatting throws. 47 | if (result == null) { 48 | formattingRequest.onTextReady(content) 49 | return 50 | } 51 | 52 | val error = result.error 53 | if (error != null) { 54 | formattingRequest.onError(DprintBundle.message("formatting.error"), error) 55 | } else { 56 | // If the result is a no op it will be null, in which case we pass the original content back in 57 | formattingRequest.onTextReady(result.formattedContent ?: content) 58 | } 59 | } 60 | 61 | private fun runRangeFormatting( 62 | content: String, 63 | ranges: List, 64 | ): FormatResult? { 65 | return try { 66 | runBlocking { 67 | withTimeout(FORMATTING_TIMEOUT) { 68 | if (isCancelled) return@withTimeout null 69 | 70 | // For multiple ranges, we need to format them sequentially 71 | // starting from the end to avoid offset issues 72 | val sortedRanges = ranges.sortedByDescending { it.startOffset } 73 | var currentContent = content 74 | 75 | for (range in sortedRanges) { 76 | if (isCancelled) return@withTimeout null 77 | 78 | // Need to update the formatting id so the correct job would be cancelled 79 | val formatId = dprintService.maybeGetFormatId() 80 | formatId?.let { 81 | formattingIds.add(it) 82 | } 83 | 84 | val result = 85 | dprintService.formatSuspend( 86 | path = path, 87 | content = currentContent, 88 | formatId = formatId, 89 | startIndex = range.startOffset, 90 | endIndex = range.endOffset, 91 | ) 92 | 93 | if (result == null || result.error != null) { 94 | return@withTimeout result 95 | } 96 | 97 | // Update content for next iteration if formatting was successful 98 | currentContent = result.formattedContent ?: currentContent 99 | } 100 | 101 | FormatResult(formattedContent = currentContent) 102 | } 103 | } 104 | } catch (e: CancellationException) { 105 | // This is expected when the user cancels the operation 106 | infoLogWithConsole(DprintBundle.message("error.format.cancelled.by.user"), project, LOGGER) 107 | null 108 | } catch (e: TimeoutCancellationException) { 109 | errorLogWithConsole( 110 | DprintBundle.message("error.format.timeout", FORMATTING_TIMEOUT.inWholeSeconds), 111 | e, 112 | project, 113 | LOGGER, 114 | ) 115 | formattingRequest.onError( 116 | DprintBundle.message("dialog.title.dprint.formatter"), 117 | DprintBundle.message("error.format.process.timeout", FORMATTING_TIMEOUT.inWholeSeconds), 118 | ) 119 | dprintService.restartEditorService() 120 | null 121 | } catch (e: Exception) { 122 | val message = 123 | DprintBundle.message("error.format.unexpected", e.javaClass.simpleName, e.message ?: "unknown") 124 | errorLogWithConsole( 125 | message, 126 | e, 127 | project, 128 | LOGGER, 129 | ) 130 | formattingRequest.onError( 131 | DprintBundle.message("dialog.title.dprint.formatter"), 132 | message, 133 | ) 134 | // Only restart service for unexpected errors that might indicate a corrupted state 135 | dprintService.restartEditorService() 136 | null 137 | } 138 | } 139 | 140 | fun cancel(): Boolean { 141 | if (!dprintService.canCancelFormat()) return false 142 | 143 | isCancelled = true 144 | for (id in formattingIds) { 145 | infoLogWithConsole( 146 | DprintBundle.message("external.formatter.cancelling.task", id), 147 | project, 148 | LOGGER, 149 | ) 150 | dprintService.cancelFormat(id) 151 | } 152 | 153 | return true 154 | } 155 | 156 | fun isRunUnderProgress(): Boolean = true 157 | } 158 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflow is created for testing and preparing the plugin release in the following steps: 2 | # - Run 'test' and 'verifyPlugin' tasks. 3 | # - Run the 'buildPlugin' task and prepare artifact for further tests. 4 | # - Run the 'runPluginVerifier' task. 5 | # - Create a draft release. 6 | # 7 | # The workflow is triggered on push and pull_request events. 8 | # 9 | # GitHub Actions reference: https://help.github.com/en/actions 10 | # 11 | ## JBIJPPTPL 12 | 13 | name: Build 14 | on: 15 | # Trigger the workflow on pushes to only the 'main' branch (this avoids duplicate checks being run e.g., for dependabot pull requests) 16 | push: 17 | branches: [ main ] 18 | # Trigger the workflow on any pull request 19 | pull_request: 20 | 21 | # Allow manually triggering 22 | workflow_dispatch: 23 | 24 | 25 | concurrency: 26 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 27 | cancel-in-progress: true 28 | 29 | jobs: 30 | 31 | # Prepare environment and build the plugin 32 | build: 33 | name: Build 34 | runs-on: ubuntu-latest 35 | steps: 36 | 37 | # Free GitHub Actions Environment Disk Space 38 | - name: Maximize Build Space 39 | uses: jlumbroso/free-disk-space@v1.3.1 40 | with: 41 | tool-cache: false 42 | large-packages: false 43 | 44 | # Check out the current repository 45 | - name: Fetch Sources 46 | uses: actions/checkout@v5 47 | 48 | # Set up Java environment for the next steps 49 | - name: Setup Java 50 | uses: actions/setup-java@v5 51 | with: 52 | distribution: zulu 53 | java-version: 21 54 | 55 | # Setup Gradle 56 | - name: Setup Gradle 57 | uses: gradle/actions/setup-gradle@v5 58 | 59 | # Build plugin 60 | - name: Build plugin 61 | run: ./gradlew buildPlugin 62 | 63 | # Prepare plugin archive content for creating artifact 64 | - name: Prepare Plugin Artifact 65 | id: artifact 66 | shell: bash 67 | run: | 68 | cd ${{ github.workspace }}/build/distributions 69 | FILENAME=`ls *.zip` 70 | unzip "$FILENAME" -d content 71 | 72 | echo "filename=${FILENAME:0:-4}" >> $GITHUB_OUTPUT 73 | 74 | # Store already-built plugin as an artifact for downloading 75 | - name: Upload artifact 76 | uses: actions/upload-artifact@v5 77 | with: 78 | name: ${{ steps.artifact.outputs.filename }} 79 | path: ./build/distributions/content/*/* 80 | 81 | # Run tests and upload a code coverage report 82 | test: 83 | name: Test 84 | needs: [ build ] 85 | runs-on: ubuntu-latest 86 | steps: 87 | 88 | # Free GitHub Actions Environment Disk Space 89 | - name: Maximize Build Space 90 | uses: jlumbroso/free-disk-space@v1.3.1 91 | with: 92 | tool-cache: false 93 | large-packages: false 94 | 95 | # Check out the current repository 96 | - name: Fetch Sources 97 | uses: actions/checkout@v5 98 | 99 | # Set up Java environment for the next steps 100 | - name: Setup Java 101 | uses: actions/setup-java@v5 102 | with: 103 | distribution: zulu 104 | java-version: 21 105 | 106 | # Setup Gradle 107 | - name: Setup Gradle 108 | uses: gradle/actions/setup-gradle@v5 109 | with: 110 | cache-read-only: true 111 | 112 | # Run tests 113 | - name: Run Tests 114 | run: ./gradlew check 115 | 116 | # Collect Tests Result of failed tests 117 | - name: Collect Tests Result 118 | if: ${{ failure() }} 119 | uses: actions/upload-artifact@v5 120 | with: 121 | name: tests-result 122 | path: ${{ github.workspace }}/build/reports/tests 123 | 124 | # Run plugin structure verification along with IntelliJ Plugin Verifier 125 | verify: 126 | name: Verify plugin 127 | needs: [ build ] 128 | runs-on: ubuntu-latest 129 | steps: 130 | 131 | # Free GitHub Actions Environment Disk Space 132 | - name: Maximize Build Space 133 | uses: jlumbroso/free-disk-space@v1.3.1 134 | with: 135 | tool-cache: false 136 | large-packages: false 137 | 138 | # Check out the current repository 139 | - name: Fetch Sources 140 | uses: actions/checkout@v5 141 | 142 | # Set up Java environment for the next steps 143 | - name: Setup Java 144 | uses: actions/setup-java@v5 145 | with: 146 | distribution: zulu 147 | java-version: 21 148 | 149 | # Setup Gradle 150 | - name: Setup Gradle 151 | uses: gradle/actions/setup-gradle@v5 152 | with: 153 | cache-read-only: true 154 | 155 | # Run Verify Plugin task and IntelliJ Plugin Verifier tool 156 | - name: Run Plugin Verification tasks 157 | run: ./gradlew verifyPlugin 158 | 159 | # Collect Plugin Verifier Result 160 | - name: Collect Plugin Verifier Result 161 | if: ${{ always() }} 162 | uses: actions/upload-artifact@v5 163 | with: 164 | name: pluginVerifier-result 165 | path: ${{ github.workspace }}/build/reports/pluginVerifier 166 | 167 | # Prepare a draft release for GitHub Releases page for the manual verification 168 | # If accepted and published, release workflow would be triggered 169 | releaseDraft: 170 | name: Release draft 171 | if: github.event_name != 'pull_request' 172 | needs: [ build, test, verify ] 173 | runs-on: ubuntu-latest 174 | permissions: 175 | contents: write 176 | steps: 177 | 178 | # Check out the current repository 179 | - name: Fetch Sources 180 | uses: actions/checkout@v5 181 | 182 | # Setup Gradle 183 | - name: Setup Gradle 184 | uses: gradle/actions/setup-gradle@v5 185 | with: 186 | cache-read-only: true 187 | 188 | # Remove old release drafts by using the curl request for the available releases with a draft flag 189 | - name: Remove Old Release Drafts 190 | env: 191 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 192 | run: | 193 | gh api repos/{owner}/{repo}/releases \ 194 | --jq '.[] | select(.draft == true) | .id' \ 195 | | xargs -I '{}' gh api -X DELETE repos/{owner}/{repo}/releases/{} 196 | 197 | # Create a new release draft which is not publicly visible and requires manual acceptance 198 | - name: Create Release Draft 199 | env: 200 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 201 | run: | 202 | VERSION=$(./gradlew properties --property version --quiet --console=plain | tail -n 1 | cut -f2- -d ' ') 203 | RELEASE_NOTE="./build/tmp/release_note.txt" 204 | mkdir -p "$(dirname "$RELEASE_NOTE")" 205 | ./gradlew getChangelog --unreleased --no-header --quiet --console=plain --output-file=$RELEASE_NOTE 206 | gh release create "v$VERSION" \ 207 | --draft \ 208 | --title "v$VERSION" \ 209 | --notes-file $RELEASE_NOTE -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/formatter/DprintExternalFormatter.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.formatter 2 | 3 | import com.dprint.config.ProjectConfiguration 4 | import com.dprint.config.UserConfiguration 5 | import com.dprint.i18n.DprintBundle 6 | import com.dprint.services.DprintService 7 | import com.dprint.utils.infoConsole 8 | import com.dprint.utils.infoLogWithConsole 9 | import com.dprint.utils.isFormattableFile 10 | import com.dprint.utils.warnLogWithConsole 11 | import com.intellij.formatting.service.AsyncDocumentFormattingService 12 | import com.intellij.formatting.service.AsyncFormattingRequest 13 | import com.intellij.formatting.service.FormattingService 14 | import com.intellij.openapi.components.service 15 | import com.intellij.openapi.diagnostic.logger 16 | import com.intellij.psi.PsiFile 17 | 18 | private val LOGGER = logger() 19 | private const val NAME = "dprintfmt" 20 | 21 | /** 22 | * This class is the recommended way to implement an external formatter in the IJ 23 | * framework. 24 | * 25 | * How it works is that extends AsyncDocumentFormattingService and IJ 26 | * will use the `canFormat` method to determine if this formatter should be used 27 | * for a given file. If yes, then this will be run and the IJ formatter will not. 28 | * If no, it passes through his formatter and checks the next registered formatter 29 | * until it eventually gets to the IJ formatter as a last resort. 30 | */ 31 | class DprintExternalFormatter : AsyncDocumentFormattingService() { 32 | override fun getFeatures(): MutableSet = 33 | mutableSetOf(FormattingService.Feature.FORMAT_FRAGMENTS) 34 | 35 | override fun canFormat(file: PsiFile): Boolean { 36 | val projectConfig = file.project.service().state 37 | val userConfig = file.project.service().state 38 | val dprintService = file.project.service() 39 | 40 | if (!projectConfig.enabled) return false 41 | 42 | if (!userConfig.overrideIntelliJFormatter) { 43 | infoConsole(DprintBundle.message("external.formatter.not.configured.to.override"), file.project) 44 | } 45 | 46 | // If we don't have a cached can format response then we return true and let the formatting task figure that 47 | // out. Worse case scenario is that a file that cannot be formatted by dprint won't trigger the default IntelliJ 48 | // formatter. This is a minor issue and should be resolved if they run it again. We need to take this approach 49 | // as it appears that blocking the EDT here causes quite a few issues. Also, we ignore scratch files as a perf 50 | // optimisation because they are not part of the project and thus never in config. 51 | val virtualFile = file.virtualFile ?: file.originalFile.virtualFile 52 | val canFormat = 53 | if (virtualFile != null && isFormattableFile(file.project, virtualFile)) { 54 | dprintService.canFormatCached(virtualFile.path) 55 | } else { 56 | false 57 | } 58 | 59 | if (canFormat == null) { 60 | warnLogWithConsole(DprintBundle.message("external.formatter.can.format.unknown"), file.project, LOGGER) 61 | return false 62 | } else if (canFormat) { 63 | infoConsole(DprintBundle.message("external.formatter.can.format", virtualFile.path), file.project) 64 | } else if (virtualFile?.path != null) { 65 | // If a virtual file path doesn't exist then it is an ephemeral file such as a scratch file. Dprint needs 66 | // real files to work. I have tried to log this in the past but it seems to be called frequently resulting 67 | // in log spam, so in the case the path doesn't exist, we just do nothing. 68 | infoConsole(DprintBundle.message("external.formatter.cannot.format", virtualFile.path), file.project) 69 | } 70 | 71 | return canFormat 72 | } 73 | 74 | override fun createFormattingTask(formattingRequest: AsyncFormattingRequest): FormattingTask? { 75 | val project = formattingRequest.context.project 76 | 77 | val dprintService = project.service() 78 | val path = formattingRequest.ioFile?.path 79 | 80 | if (path == null) { 81 | infoLogWithConsole(DprintBundle.message("formatting.cannot.determine.file.path"), project, LOGGER) 82 | return null 83 | } 84 | 85 | if (!dprintService.canRangeFormat() && isRangeFormat(formattingRequest)) { 86 | // When range formatting is available we need to add support here. 87 | infoLogWithConsole(DprintBundle.message("external.formatter.range.formatting"), project, LOGGER) 88 | return null 89 | } 90 | 91 | if (dprintService.canRangeFormat() && isRangeFormat(formattingRequest)) { 92 | infoLogWithConsole(DprintBundle.message("external.formatter.range.formatting"), project, LOGGER) 93 | 94 | return object : FormattingTask { 95 | val dprintTask = DprintRangeFormattingTask(project, dprintService, formattingRequest, path) 96 | 97 | override fun run() = dprintTask.run() 98 | 99 | override fun cancel(): Boolean = dprintTask.cancel() 100 | 101 | override fun isRunUnderProgress(): Boolean = dprintTask.isRunUnderProgress() 102 | } 103 | } 104 | 105 | if (doAnyRangesIntersect(formattingRequest)) { 106 | infoLogWithConsole(DprintBundle.message("external.formatter.range.overlapping"), project, LOGGER) 107 | return null 108 | } 109 | 110 | infoLogWithConsole(DprintBundle.message("external.formatter.creating.task", path), project, LOGGER) 111 | 112 | return object : FormattingTask { 113 | val dprintTask = DprintFormattingTask(project, dprintService, formattingRequest, path) 114 | 115 | override fun run() = dprintTask.run() 116 | 117 | override fun cancel(): Boolean = dprintTask.cancel() 118 | 119 | override fun isRunUnderProgress(): Boolean = dprintTask.isRunUnderProgress() 120 | } 121 | } 122 | 123 | /** 124 | * We make assumptions that ranges do not overlap each other in our formatting algorithm. 125 | */ 126 | private fun doAnyRangesIntersect(formattingRequest: AsyncFormattingRequest): Boolean { 127 | val ranges = formattingRequest.formattingRanges.sortedBy { textRange -> textRange.startOffset } 128 | for (i in ranges.indices) { 129 | if (i + 1 >= ranges.size) { 130 | continue 131 | } 132 | val current = ranges[i] 133 | val next = ranges[i + 1] 134 | 135 | if (current.intersects(next)) { 136 | return true 137 | } 138 | } 139 | return false 140 | } 141 | 142 | private fun isRangeFormat(formattingRequest: AsyncFormattingRequest): Boolean { 143 | return when { 144 | formattingRequest.formattingRanges.size > 1 -> true 145 | formattingRequest.formattingRanges.size == 1 -> { 146 | val range = formattingRequest.formattingRanges[0] 147 | return range.startOffset > 0 || range.endOffset < formattingRequest.documentText.length 148 | } 149 | 150 | else -> false 151 | } 152 | } 153 | 154 | override fun getNotificationGroupId(): String = "Dprint" 155 | 156 | override fun getName(): String = NAME 157 | } 158 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Build Commands 6 | 7 | - **Build Plugin:** `./gradlew build` 8 | - **Run Format:** `./gradlew ktlintFormat` 9 | - **Run Tests:** `./gradlew check` 10 | - **Run Verification:** `./gradlew runPluginVerifier` 11 | - **Clean Build:** `./gradlew clean` 12 | 13 | ## Project Architecture 14 | 15 | The dprint-intellij project is an IntelliJ plugin that integrates the dprint code formatter (https://dprint.dev/) into 16 | IntelliJ IDEs. The plugin allows users to format their code using dprint directly from the IDE. 17 | 18 | ### Key Components 19 | 20 | 1. **DprintService** - Central service that manages the dprint process and coordinates formatting requests 21 | - Handles initialization of the appropriate editor service based on dprint schema version 22 | - Manages a cache of files that can be formatted 23 | - Maintains service state (`UNINITIALIZED`, `INITIALIZING`, `READY`, `ERROR`) 24 | - Provides both callback and coroutine-based formatting APIs 25 | - Thread-safe using `AtomicReference` 26 | 27 | 2. **DprintTaskExecutor** - Handles background task execution and coordination 28 | - Implements `CoroutineScope` for modern Kotlin coroutines 29 | - Uses `Channel` for task queuing with deduplication 30 | - Ensures only one task of each type runs at a time 31 | - Integrates with IntelliJ's `ProgressManager` for UI feedback 32 | - Handles timeouts and proper cancellation support 33 | 34 | 3. **EditorService Implementations** 35 | - `EditorServiceV4` and `EditorServiceV5` - Implement different versions of the dprint editor service protocol 36 | - `EditorServiceInitializer` - Detects schema version and creates appropriate service with improved error handling 37 | - `EditorServiceCache` - LRU cache for `canFormat` results to improve performance 38 | - Handle communication with the dprint CLI process with better config discovery and logging 39 | 40 | 4. **DprintExternalFormatter** - Integration with IntelliJ's external formatter API 41 | - Determines if a file can be formatted by dprint 42 | - Creates `DprintFormattingTask` instances for eligible files 43 | - Integrates with `AsyncDocumentFormattingService` 44 | 45 | 5. **Configuration** 46 | - `ProjectConfiguration` - Project-level settings (stored in .idea/dprintProjectConfig.xml) 47 | - `UserConfiguration` - User-level settings (stored in .idea/dprintUserConfig.xml) 48 | - `DprintConfigurable` - UI for configuring the plugin with reset functionality 49 | 50 | 6. **Actions** 51 | - `ReformatAction` - Triggers dprint formatting 52 | - `RestartAction` - Restarts the dprint editor service 53 | - `ClearCacheAction` - Clears the format cache 54 | 55 | ### Data Flow 56 | 57 | 1. User action (manual format or save) triggers the formatter 58 | 2. `DprintExternalFormatter` is used by the internal IntelliJ formatter and keybinds if dprint can format the file 59 | 3. If eligible, a `DprintFormattingTask` is created and executed via `DprintService` 60 | 4. `DprintService` delegates the task to `DprintTaskExecutor` for background processing 61 | 5. The task is executed by the appropriate `EditorService` implementation (V4 or V5) 62 | 6. The dprint CLI daemon formats the file and returns the result 63 | 7. The formatted content is applied to the document 64 | 65 | ## Development Notes 66 | 67 | 1. The plugin is developed using Kotlin and targets IntelliJ 2025.1+ 68 | 2. JDK 21 is required for development 69 | 3. To test the plugin locally: 70 | - Install dprint CLI (`brew install dprint` on macOS) 71 | - Run `dprint init` to create a default config 72 | - Use the "Run IDE with Plugin" run configuration 73 | 74 | 4. Plugin requires a working dprint executable and config file. It can: 75 | - Auto-detect dprint in PATH or node_modules 76 | - Auto-detect config in project root 77 | - Accept custom paths for both executable and config 78 | 79 | 5. The plugin supports both dprint schema v4 and v5 80 | 81 | 6. The plugin uses Kotlin coroutines for background task processing 82 | - Uses `DprintTaskExecutor` with `Channel` for managing asynchronous operations 83 | - Properly handles task cancellation, timeouts, and deduplication 84 | - Integrates with IntelliJ's progress system for UI feedback 85 | 86 | ## Recent Improvements (v0.9.0) 87 | 88 | ### Major Architectural Refactoring 89 | - **Coroutines migration** - Complete rearchitecture from callback-based to coroutine-based asynchronous operations for improved CPU utilization 90 | - **New service architecture** - Introduced `DprintService` as central coordinator and `DprintTaskExecutor` for background task management using Kotlin coroutines 91 | - **Editor service refactoring** - Split monolithic `EditorServiceManager` into focused components: `EditorServiceInitializer`, `EditorServiceCache`, and improved V4/V5 implementations 92 | - **State management overhaul** - New `BaseConfiguration` abstraction for type-safe configuration management 93 | 94 | ### Range Formatting Enhancements 95 | - **Range formatting re-implementation** - Added `DprintRangeFormattingTask` with improved character encoding handling 96 | - **Fixed character encoding issues** - Resolved problems with special characters causing incorrect range calculations 97 | - **Better integration** - Improved range formatting integration with IntelliJ's formatting system 98 | 99 | ### Performance & Reliability Improvements 100 | - **Task queue optimization** - New `Channel` based system with deduplication and proper cancellation support 101 | - **Caching improvements** - Enhanced `EditorServiceCache` with LRU caching for `canFormat` results 102 | - **Timeout handling** - Better timeout management and error recovery throughout the plugin 103 | - **Memory management** - Improved resource cleanup and lifecycle management 104 | 105 | ### Developer Experience Enhancements 106 | - **Enhanced error handling** - Fixed JSON parsing errors with graceful handling for empty `dprint editor-info` output 107 | - **Improved config discovery** - Better working directory handling and config file detection with user-friendly logging 108 | - **Reset to defaults functionality** - Replaced restart button with comprehensive reset functionality covering all configuration settings 109 | - **Better logging** - User-friendly messages showing which config files are being used and actionable error guidance 110 | - **Progress integration** - Better integration with IntelliJ's progress system for background operations 111 | 112 | ### Technical Updates 113 | - **Build system modernization** - Updated to Gradle 9.2.1 and IntelliJ Platform Gradle Plugin 2.10.5 114 | - **Java toolchain update** - Upgraded from Java 17 to Java 21 for better performance and modern language features 115 | - **Dependency updates** - Updated all dependencies for 2025, including Gradle foojay-resolver-convention plugin (0.7.0 → 1.0.0) 116 | - **IntelliJ platform updates** - Updated to target IntelliJ 2025.1+ with latest platform APIs 117 | - **Deprecated code removal** - Cleaned up deprecated class usage and modernized codebase 118 | - **Test configuration improvements** - Fixed Kotest integration with JUnit 5 to work seamlessly with Gradle test infrastructure 119 | - **Test improvements** - Added comprehensive test suite for new architecture including `DprintServiceUnitTest`, `EditorServiceCacheTest`, and improved V5 service tests 120 | 121 | ### Configuration & UI Improvements 122 | - **Configuration persistence** - Better state management with separate project and user configurations 123 | - **Bundle message improvements** - Enhanced localized messages for better user experience 124 | - **Git ignore updates** - Improved `.gitignore` for better development workflow -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/services/editorservice/EditorServiceInitializer.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services.editorservice 2 | 3 | import com.dprint.config.ProjectConfiguration 4 | import com.dprint.i18n.DprintBundle 5 | import com.dprint.messages.DprintMessage 6 | import com.dprint.services.editorservice.v4.EditorServiceV4 7 | import com.dprint.services.editorservice.v5.EditorServiceV5 8 | import com.dprint.utils.errorLogWithConsole 9 | import com.dprint.utils.getValidConfigPath 10 | import com.dprint.utils.getValidExecutablePath 11 | import com.dprint.utils.infoLogWithConsole 12 | import com.intellij.execution.configurations.GeneralCommandLine 13 | import com.intellij.execution.util.ExecUtil 14 | import com.intellij.notification.NotificationGroupManager 15 | import com.intellij.notification.NotificationType 16 | import com.intellij.openapi.components.service 17 | import com.intellij.openapi.diagnostic.logger 18 | import com.intellij.openapi.project.Project 19 | import kotlinx.serialization.json.Json 20 | import kotlinx.serialization.json.int 21 | import kotlinx.serialization.json.jsonObject 22 | import kotlinx.serialization.json.jsonPrimitive 23 | import java.io.File 24 | 25 | private val LOGGER = logger() 26 | private const val SCHEMA_V4 = 4 27 | private const val SCHEMA_V5 = 5 28 | 29 | /** 30 | * Handles initialization and lifecycle management of editor services. 31 | * Detects schema versions and creates appropriate service implementations. 32 | */ 33 | class EditorServiceInitializer( 34 | private val project: Project, 35 | ) { 36 | private fun getSchemaVersion(configPath: String?): Int? { 37 | val executablePath = getValidExecutablePath(project) 38 | if (executablePath == null) { 39 | errorLogWithConsole( 40 | DprintBundle.message("error.executable.path"), 41 | project, 42 | LOGGER, 43 | ) 44 | return null 45 | } 46 | 47 | val timeout = project.service().state.initialisationTimeout 48 | 49 | val commandLine = 50 | GeneralCommandLine( 51 | executablePath, 52 | "editor-info", 53 | ) 54 | 55 | // Set working directory - use config directory if available, otherwise use project base path 56 | val workingDir = configPath?.let { File(it).parent } ?: project.basePath 57 | workingDir?.let { commandLine.withWorkDirectory(it) } 58 | 59 | val result = ExecUtil.execAndGetOutput(commandLine, timeout.toInt()) 60 | 61 | return try { 62 | val jsonText = result.stdout.trim() 63 | infoLogWithConsole(DprintBundle.message("config.dprint.editor.info", jsonText), project, LOGGER) 64 | 65 | if (jsonText.isEmpty()) { 66 | val workingDir = configPath?.let { File(it).parent } ?: project.basePath 67 | errorLogWithConsole( 68 | DprintBundle.message("error.dprint.editor.info.empty", workingDir ?: "unknown"), 69 | project, 70 | LOGGER, 71 | ) 72 | return null 73 | } 74 | 75 | Json 76 | .parseToJsonElement(jsonText) 77 | .jsonObject["schemaVersion"] 78 | ?.jsonPrimitive 79 | ?.int 80 | } catch (e: RuntimeException) { 81 | val stdout = result.stdout.trim() 82 | val stderr = result.stderr.trim() 83 | val message = 84 | when { 85 | stdout.isEmpty() && stderr.isNotEmpty() -> 86 | DprintBundle.message( 87 | "error.failed.to.parse.json.schema.error", 88 | result.stderr.trim(), 89 | ) 90 | 91 | stdout.isNotEmpty() && stderr.isEmpty() -> 92 | DprintBundle.message( 93 | "error.failed.to.parse.json.schema.received", 94 | result.stdout.trim(), 95 | ) 96 | 97 | stdout.isNotEmpty() && stderr.isNotEmpty() -> 98 | DprintBundle.message( 99 | "error.failed.to.parse.json.schema.received.error", 100 | result.stdout.trim(), 101 | result.stderr.trim(), 102 | ) 103 | 104 | else -> DprintBundle.message("error.failed.to.parse.json.schema") 105 | } 106 | errorLogWithConsole( 107 | message, 108 | project, 109 | LOGGER, 110 | ) 111 | throw e 112 | } 113 | } 114 | 115 | /** 116 | * Initializes a fresh editor service based on the detected schema version. 117 | * Returns the initialized service and config path. 118 | */ 119 | fun initialiseFreshEditorService(): Pair { 120 | val configPath = getValidConfigPath(project) 121 | 122 | // Log which config file is being used or if none was found 123 | if (configPath != null) { 124 | infoLogWithConsole( 125 | DprintBundle.message("config.dprint.using.config", configPath), 126 | project, 127 | LOGGER, 128 | ) 129 | } else { 130 | infoLogWithConsole( 131 | DprintBundle.message("config.dprint.no.config.found", project.basePath ?: "unknown"), 132 | project, 133 | LOGGER, 134 | ) 135 | } 136 | 137 | val schemaVersion = getSchemaVersion(configPath) 138 | infoLogWithConsole( 139 | DprintBundle.message( 140 | "editor.service.manager.received.schema.version", 141 | schemaVersion ?: "none", 142 | ), 143 | project, 144 | LOGGER, 145 | ) 146 | 147 | val editorService = 148 | when { 149 | schemaVersion == null -> { 150 | project.messageBus.syncPublisher(DprintMessage.DPRINT_MESSAGE_TOPIC).info( 151 | DprintBundle.message("config.dprint.schemaVersion.not.found"), 152 | ) 153 | null 154 | } 155 | 156 | schemaVersion < SCHEMA_V4 -> { 157 | project.messageBus 158 | .syncPublisher(DprintMessage.DPRINT_MESSAGE_TOPIC) 159 | .info( 160 | DprintBundle.message("config.dprint.schemaVersion.older"), 161 | ) 162 | null 163 | } 164 | 165 | schemaVersion == SCHEMA_V4 -> project.service() 166 | schemaVersion == SCHEMA_V5 -> project.service() 167 | schemaVersion > SCHEMA_V5 -> { 168 | infoLogWithConsole( 169 | DprintBundle.message("config.dprint.schemaVersion.newer"), 170 | project, 171 | LOGGER, 172 | ) 173 | null 174 | } 175 | 176 | else -> null 177 | } 178 | 179 | editorService?.initialiseEditorService() 180 | return Pair(editorService, configPath) 181 | } 182 | 183 | /** 184 | * Shows a notification when the editor service fails to start. 185 | */ 186 | fun notifyFailedToStart() { 187 | NotificationGroupManager 188 | .getInstance() 189 | .getNotificationGroup("Dprint") 190 | .createNotification( 191 | DprintBundle.message( 192 | "editor.service.manager.initialising.editor.service.failed.title", 193 | ), 194 | DprintBundle.message( 195 | "editor.service.manager.initialising.editor.service.failed.content", 196 | ), 197 | NotificationType.ERROR, 198 | ).notify(project) 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/services/editorservice/process/EditorProcess.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services.editorservice.process 2 | 3 | import com.dprint.config.UserConfiguration 4 | import com.dprint.i18n.DprintBundle 5 | import com.dprint.messages.DprintMessage 6 | import com.dprint.services.editorservice.exceptions.ProcessUnavailableException 7 | import com.dprint.utils.getValidConfigPath 8 | import com.dprint.utils.getValidExecutablePath 9 | import com.dprint.utils.infoLogWithConsole 10 | import com.intellij.execution.configurations.GeneralCommandLine 11 | import com.intellij.openapi.components.Service 12 | import com.intellij.openapi.components.service 13 | import com.intellij.openapi.diagnostic.logger 14 | import com.intellij.openapi.project.Project 15 | import kotlinx.coroutines.Dispatchers 16 | import kotlinx.coroutines.withContext 17 | import java.io.File 18 | import java.nio.ByteBuffer 19 | 20 | private const val BUFFER_SIZE = 1024 21 | private const val ZERO = 0 22 | private const val U32_BYTE_SIZE = 4 23 | 24 | private val LOGGER = logger() 25 | 26 | // Dprint uses unsigned bytes of 4x255 for the success message and that translates 27 | // to 4x-1 in the jvm's signed bytes. 28 | private val SUCCESS_MESSAGE = byteArrayOf(-1, -1, -1, -1) 29 | 30 | @Service(Service.Level.PROJECT) 31 | class EditorProcess( 32 | private val project: Project, 33 | ) { 34 | private var process: Process? = null 35 | private var stderrListener: StdErrListener? = null 36 | 37 | fun initialize() { 38 | val executablePath = getValidExecutablePath(project) 39 | val configPath = getValidConfigPath(project) 40 | 41 | if (this.process != null) { 42 | destroy() 43 | } 44 | 45 | when { 46 | configPath.isNullOrBlank() -> { 47 | project.messageBus 48 | .syncPublisher(DprintMessage.DPRINT_MESSAGE_TOPIC) 49 | .info(DprintBundle.message("error.config.path")) 50 | } 51 | 52 | executablePath.isNullOrBlank() -> { 53 | project.messageBus 54 | .syncPublisher(DprintMessage.DPRINT_MESSAGE_TOPIC) 55 | .info(DprintBundle.message("error.executable.path")) 56 | } 57 | 58 | else -> { 59 | process = createDprintDaemon(executablePath, configPath) 60 | process?.let { actualProcess -> 61 | actualProcess.onExit().thenApply { 62 | destroy() 63 | } 64 | createStderrListener(actualProcess) 65 | } 66 | } 67 | } 68 | } 69 | 70 | /** 71 | * Shuts down the editor service and destroys the process. 72 | */ 73 | fun destroy() { 74 | stderrListener?.dispose() 75 | process?.destroy() 76 | process = null 77 | } 78 | 79 | private fun createStderrListener(actualProcess: Process): StdErrListener { 80 | val stdErrListener = StdErrListener(project, actualProcess) 81 | stdErrListener.listen() 82 | return stdErrListener 83 | } 84 | 85 | private fun createDprintDaemon( 86 | executablePath: String, 87 | configPath: String, 88 | ): Process { 89 | val ijPid = ProcessHandle.current().pid() 90 | 91 | val args = 92 | mutableListOf( 93 | executablePath, 94 | "editor-service", 95 | "--config", 96 | configPath, 97 | "--parent-pid", 98 | ijPid.toString(), 99 | ) 100 | 101 | if (project.service().state.enableEditorServiceVerboseLogging) args.add("--verbose") 102 | 103 | val commandLine = GeneralCommandLine(args) 104 | val workingDir = File(configPath).parent 105 | 106 | when { 107 | workingDir != null -> { 108 | commandLine.withWorkDirectory(workingDir) 109 | infoLogWithConsole( 110 | DprintBundle.message("editor.service.starting", executablePath, configPath, workingDir), 111 | project, 112 | LOGGER, 113 | ) 114 | } 115 | 116 | else -> 117 | infoLogWithConsole( 118 | DprintBundle.message("editor.service.starting.working.dir", executablePath, configPath), 119 | project, 120 | LOGGER, 121 | ) 122 | } 123 | 124 | val rtnProcess = commandLine.createProcess() 125 | rtnProcess.onExit().thenApply { exitedProcess -> 126 | infoLogWithConsole( 127 | DprintBundle.message("process.shut.down", exitedProcess.pid()), 128 | project, 129 | LOGGER, 130 | ) 131 | } 132 | return rtnProcess 133 | } 134 | 135 | private fun getProcess(): Process { 136 | val boundProcess = process 137 | 138 | if (boundProcess?.isAlive == true) { 139 | return boundProcess 140 | } 141 | throw ProcessUnavailableException( 142 | DprintBundle.message( 143 | "editor.process.cannot.get.editor.service.process", 144 | ), 145 | ) 146 | } 147 | 148 | suspend fun writeSuccess() { 149 | LOGGER.debug(DprintBundle.message("formatting.sending.success.to.editor.service")) 150 | withContext(Dispatchers.IO) { 151 | val stdin = getProcess().outputStream 152 | stdin.write(SUCCESS_MESSAGE) 153 | stdin.flush() 154 | } 155 | } 156 | 157 | suspend fun writeInt(i: Int) { 158 | LOGGER.debug(DprintBundle.message("formatting.sending.to.editor.service", i)) 159 | withContext(Dispatchers.IO) { 160 | val stdin = getProcess().outputStream 161 | val buffer = ByteBuffer.allocate(U32_BYTE_SIZE) 162 | buffer.putInt(i) 163 | stdin.write(buffer.array()) 164 | stdin.flush() 165 | } 166 | } 167 | 168 | suspend fun writeString(string: String) { 169 | val byteArray = string.encodeToByteArray() 170 | var pointer = 0 171 | 172 | writeInt(byteArray.size) 173 | 174 | while (pointer < byteArray.size) { 175 | if (pointer != 0) { 176 | readInt() 177 | } 178 | val end = if (byteArray.size - pointer < BUFFER_SIZE) byteArray.size else pointer + BUFFER_SIZE 179 | val range = IntRange(pointer, end - 1) 180 | val chunk = byteArray.slice(range).toByteArray() 181 | 182 | withContext(Dispatchers.IO) { 183 | val stdin = getProcess().outputStream 184 | stdin.write(chunk) 185 | stdin.flush() 186 | } 187 | pointer = end 188 | } 189 | } 190 | 191 | suspend fun readAndAssertSuccess() { 192 | val stdout = process?.inputStream 193 | if (stdout != null) { 194 | withContext(Dispatchers.IO) { 195 | val bytes = stdout.readNBytes(U32_BYTE_SIZE) 196 | for (i in 0 until U32_BYTE_SIZE) { 197 | assert(bytes[i] == SUCCESS_MESSAGE[i]) 198 | } 199 | } 200 | LOGGER.debug(DprintBundle.message("formatting.received.success")) 201 | } else { 202 | LOGGER.debug(DprintBundle.message("editor.process.cannot.get.editor.service.process")) 203 | initialize() 204 | } 205 | } 206 | 207 | suspend fun readInt(): Int { 208 | val result = 209 | withContext(Dispatchers.IO) { 210 | val stdout = getProcess().inputStream 211 | ByteBuffer.wrap(stdout.readNBytes(U32_BYTE_SIZE)).int 212 | } 213 | LOGGER.debug(DprintBundle.message("formatting.received.value", result)) 214 | return result 215 | } 216 | 217 | suspend fun readString(): String { 218 | val totalBytes = readInt() 219 | var result = ByteArray(0) 220 | var index = 0 221 | 222 | while (index < totalBytes) { 223 | if (index != 0) { 224 | writeInt(ZERO) 225 | } 226 | 227 | val numBytes = if (totalBytes - index < BUFFER_SIZE) totalBytes - index else BUFFER_SIZE 228 | val bytes = 229 | withContext(Dispatchers.IO) { 230 | val stdout = getProcess().inputStream 231 | stdout.readNBytes(numBytes) 232 | } 233 | result += bytes 234 | index += numBytes 235 | } 236 | 237 | val decodedResult = result.decodeToString() 238 | LOGGER.debug(DprintBundle.message("formatting.received.value", decodedResult)) 239 | return decodedResult 240 | } 241 | 242 | suspend fun writeBuffer(byteArray: ByteArray) { 243 | withContext(Dispatchers.IO) { 244 | val stdin = getProcess().outputStream 245 | stdin.write(byteArray) 246 | stdin.flush() 247 | } 248 | } 249 | 250 | suspend fun readBuffer(totalBytes: Int): ByteArray = 251 | withContext(Dispatchers.IO) { 252 | val stdout = getProcess().inputStream 253 | stdout.readNBytes(totalBytes) 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH="\\\"\\\"" 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /src/main/resources/messages/Bundle.properties: -------------------------------------------------------------------------------- 1 | name=dprint 2 | action.com.dprint.actions.ClearCacheAction.description=Clear the dprint plugin cache of files that can be formatted 3 | action.com.dprint.actions.ClearCacheAction.text=Clear Dprint Plugin Cache 4 | action.com.dprint.actions.ReformatAction.description=Reformat the currently open file with dprint 5 | action.com.dprint.actions.ReformatAction.text=Reformat With Dprint 6 | action.com.dprint.actions.RestartAction.description=Restart the dprint editor-service daemon 7 | action.com.dprint.actions.RestartAction.text=Restart Dprint 8 | config.dprint.actions.on.save.run.dprint=Run dprint 9 | config.changed.run="Restarting due to config change." 10 | config.dprint.config.invalid=Invalid config file 11 | config.dprint.config.path=Config file path 12 | config.dprint.config.path.description=The absolute path for dprint.json. If left blank, the plugin will\ 13 | try to find dprint.json in the project root. 14 | config.dprint.command.timeout=Command timeout (ms) 15 | config.dprint.command.timeout.description=The timeout in ms for a single dprint command to run. 16 | config.dprint.command.timeout.error=Invalid number format 17 | config.dprint.initialisation.timeout=Initialisation timeout (ms) 18 | config.dprint.initialisation.timeout.description=The timeout in ms for the dprint daemon to start up. 19 | config.dprint.initialisation.timeout.error=Invalid number format 20 | config.dprint.editor.info=Received editor info: {0} 21 | config.dprint.using.config=Using dprint config file: {0} 22 | config.dprint.no.config.found=No dprint config file found. Searching in project root: {0} 23 | error.dprint.editor.info.empty=dprint editor-info returned empty output. Working directory: {0}. Please ensure dprint is properly installed and a config file exists. 24 | config.dprint.executable.invalid=Invalid executable 25 | config.dprint.executable.path=Executable path 26 | config.dprint.executable.path.description=The absolute path for the dprint executable. If left blank, the plugin will \ 27 | try to find an executable on the path or in node_modules. 28 | config.dprint.schemaVersion.newer=Please upgrade your editor extension to be compatible with the installed version of \ 29 | dprint 30 | config.dprint.schemaVersion.not.found=Unable to determine a schema version 31 | config.dprint.schemaVersion.older=Your installed version of dprint is out of date. Apologies, but please update to the \ 32 | latest version 33 | config.enable=Enable dprint 34 | config.enable.description=Enables or disabled dprint for the project. Overrides enablement of other formatting settings. 35 | config.override.intellij.formatter=Default formatter override 36 | config.override.intellij.formatter.description=If enabled, dprint will replace the default IntelliJ formatter if the \ 37 | file can be formatted by dprint. If the file cannot be formatted by dprint the IntelliJ formatter will run as per usual. 38 | config.name=Dprint 39 | config.reset=Reset to Defaults 40 | config.reset.description=Resets all dprint configuration values to their defaults and restarts the dprint daemon. 41 | config.run.on.save=Run dprint on save 42 | config.run.on.save.description=When a file is saved dprint will determine if the file can be formatted and will format \ 43 | it if so. This is not the same as enabling the default formatter override and running the IntelliJ formatter on save. 44 | config.verbose.logging=Verbose daemon logging 45 | config.verbose.logging.description=Enables verbose logging for the underlying dprint daemon. Logging for this will be \ 46 | delivered in the dprint console and in the IntelliJ logs. 47 | editor.process.cannot.get.editor.service.process=Cannot get a running editor service 48 | editor.service.cancel.format=Cancelling format {0} 49 | editor.service.clearing.message=Clearing message {0} 50 | editor.service.created.formatting.task=Created formatting task for {0} with id {1} 51 | editor.service.destroy=Destroying {0} 52 | editor.service.format.check.failed=dprint failed to check of the file {0} can be formatted due to:\n{1} 53 | editor.service.format.failed=dprint failed to format the file {0} due to:\n{1} 54 | editor.service.format.not.needed=No need to format {0} 55 | editor.service.format.succeeded=Successfully formatted {0} 56 | editor.service.incorrect.message.size=Incorrect message size, expected {0} and got {1} 57 | editor.service.initialize=Initializing {0} 58 | editor.service.manager.creating.formatting.task=Creating formatting task for {0} 59 | editor.service.manager.initialising.editor.service.failed.title=Dprint failed to initialise 60 | editor.service.manager.initialising.editor.service.failed.content=Please check the IDE errors and the dprint console \ 61 | tool window to diagnose the issue. It is likely that dprint took too long to resolve your editor schema. Try running \ 62 | `dprint editor-info` to start to diagnose. 63 | editor.service.manager.not.initialised=Editor Service is not initialised. Please check your environment and restart \ 64 | the dprint plugin. 65 | editor.service.manager.no.cached.can.format=Did not find cached can format result for {0} 66 | editor.service.manager.priming.can.format.cache=Priming can format cache for {0} 67 | editor.service.manager.received.schema.version=Received schema version {0} 68 | editor.service.read.failed=Failed to read stdout of the editor service 69 | editor.service.received.error.response=Received failure message: {0}. 70 | status.stale.requests.removed=Removed {0} stale requests from the message channel. 71 | editor.service.shutting.down.timed.out=Timed out shutting down editor process 72 | editor.service.started.stdout.listener=Started stdout listener 73 | editor.service.starting.working.dir=Starting editor service with executable {0}, config {1}. No working dir found. 74 | editor.service.starting=Starting editor service with executable {0}, config {1} and working directory {2}. 75 | editor.service.unsupported.message.type=Received unsupported message type {0}. 76 | error.config.path=Unable to retrieve a valid dprint configuration path. 77 | error.executable.path=Unable to retrieve a valid dprint executable path. 78 | error.failed.to.parse.json.schema=Failed to parse JSON schema. 79 | error.failed.to.parse.json.schema.received=Failed to parse JSON schema.\n\tReceived: {0}. 80 | error.failed.to.parse.json.schema.error=Failed to parse JSON schema.\n\tError: {0}. 81 | error.failed.to.parse.json.schema.received.error=Failed to parse JSON schema.\n\tReceived: {0}.\n\tError: {1}. 82 | external.formatter.can.format=Dprint can format {0}, overriding default IntelliJ formatter. 83 | external.formatter.can.format.unknown=Unable to determine if dprint can format the file, falling back to the IntelliJ formatter. 84 | external.formatter.cancelling.task=Cancelling CodeStyle formatting task {0} 85 | external.formatter.cannot.format=Dprint cannot format {0}, IntelliJ formatter will be used. 86 | external.formatter.creating.task=Creating IntelliJ CodeStyle Formatting Task for {0}. 87 | external.formatter.not.configured.to.override=Dprint is not configured to override the IntelliJ formatter. 88 | external.formatter.range.formatting=Range formatting is not currently implemented, maybe soon. 89 | external.formatter.range.overlapping=Formatting ranges overlap and dprint cannot format these. Consider formatting the \ 90 | whole file. 91 | external.formatter.running.task=Running CodeStyle formatting task {0} 92 | formatting.can.format={0} can be formatted 93 | formatting.cannot.determine.file.path=Cannot determine file path to format. 94 | formatting.cannot.format=Cannot format {0}. 95 | formatting.checking.can.format=Checking if {0} can be formatted. 96 | formatting.error=Formatting error 97 | formatting.file=Formatting {0} 98 | formatting.received.success=Received success bytes. 99 | formatting.received.value=Received value: {0} 100 | formatting.scratch.files=Cannot format scratch files, file: {0} 101 | formatting.sending.success.to.editor.service=Sending success to editor service. 102 | formatting.sending.to.editor.service=Sending to editor service: {0}. 103 | notification.config.not.found=Could not detect a dprint config file. Please ensure dprint.json exists in your project root or parent directories. 104 | notification.executable.not.found=Could not find a dprint executable. Please install dprint or ensure it's in your PATH. 105 | notification.group.name=dprint 106 | notification.invalid.config.path=The configured dprint config file is invalid 107 | notification.invalid.default.config=Found invalid config file {0} 108 | notification.invalid.executable.path=The configured dprint executable is invalid 109 | process.shut.down=Dprint process {0} has shut down 110 | clear.cache.action.run=Running the clear canFormat cache action 111 | reformat.action.run=Running reformat action on {0}. 112 | restart.action.run=Running restart action 113 | save.action.run=Running save action on {0}. 114 | # Error messages 115 | error.cancel.format.not.implemented=Cancel format has not been implemented 116 | error.task.already.queued=Task is already queued so this will be dropped: {0} 117 | error.unexpected=Unexpected error: {0} 118 | error.operation.timeout=Operation timed out after {0}ms 119 | error.task.executor.disposed=Task executor disposed 120 | error.format.operation.failed=Format operation failed 121 | error.range.format.operation.failed=Range format operation failed 122 | error.service.initialization.null=Service initialization returned null 123 | error.config.path.null=Config path is null 124 | error.unknown.initialization=Unknown initialization error 125 | error.service.initialization.failed=Service initialization failed 126 | error.format.cancelled.by.user=Format operation was cancelled by user 127 | error.format.timeout=Format operation timed out after {0}s 128 | error.format.process.timeout=Format process timed out after {0} seconds 129 | error.format.unexpected=Unexpected error during formatting: {0}: {1} 130 | # Status messages 131 | status.initialising.editor.service=Initialising editor service 132 | status.cancelling.format=Cancelling format {0} 133 | status.task.executor.running=Dprint task executor: Running {0} 134 | # Dialog titles 135 | dialog.title.dprint.formatter=Dprint external formatter 136 | # Lifecycle messages 137 | lifecycle.plugin.shutdown.service=Shutting down Dprint service for plugin unload 138 | lifecycle.plugin.initialize.service=Initializing Dprint service after plugin load 139 | -------------------------------------------------------------------------------- /src/test/kotlin/com/dprint/services/DprintServiceUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services 2 | 3 | import com.dprint.config.ProjectConfiguration 4 | import com.dprint.services.editorservice.FormatResult 5 | import com.dprint.services.editorservice.IEditorService 6 | import com.intellij.openapi.components.service 7 | import com.intellij.openapi.fileEditor.FileEditorManager 8 | import com.intellij.openapi.project.Project 9 | import com.intellij.openapi.vfs.VirtualFile 10 | import io.kotest.core.spec.style.FunSpec 11 | import io.kotest.matchers.shouldBe 12 | import io.kotest.matchers.shouldNotBe 13 | import io.mockk.clearAllMocks 14 | import io.mockk.coEvery 15 | import io.mockk.coVerify 16 | import io.mockk.every 17 | import io.mockk.just 18 | import io.mockk.mockk 19 | import io.mockk.mockkStatic 20 | import io.mockk.runs 21 | import io.mockk.verify 22 | import kotlinx.coroutines.runBlocking 23 | 24 | /** 25 | * Unit tests for DprintService focusing on testable functionality 26 | * without requiring full IntelliJ platform initialization. 27 | */ 28 | class DprintServiceUnitTest : 29 | FunSpec({ 30 | val mockProject = mockk(relaxed = true) 31 | val mockProjectConfig = mockk() 32 | val mockState = mockk() 33 | val mockEditorService = mockk(relaxed = true) 34 | val mockVirtualFile = mockk() 35 | val mockFileEditorManager = mockk(relaxed = true) 36 | 37 | // Mock static functions - avoid file system operations in tests 38 | mockkStatic(FileEditorManager::class) 39 | 40 | lateinit var dprintService: DprintService 41 | 42 | beforeEach { 43 | clearAllMocks() 44 | 45 | // Setup minimal project service mocks 46 | every { mockProject.service() } returns mockProjectConfig 47 | every { mockProjectConfig.state } returns mockState 48 | every { mockState.enabled } returns true 49 | every { mockState.initialisationTimeout } returns 5000L 50 | every { mockState.commandTimeout } returns 10000L 51 | 52 | // Setup FileEditorManager mock to avoid file system access 53 | every { FileEditorManager.getInstance(mockProject) } returns mockFileEditorManager 54 | every { mockFileEditorManager.openFiles } returns emptyArray() 55 | 56 | dprintService = DprintService(mockProject) 57 | } 58 | 59 | afterEach { 60 | clearAllMocks() 61 | } 62 | 63 | // Test basic state management using the test helpers 64 | test("isReady returns false when not initialized") { 65 | // Given - service starts uninitialized 66 | 67 | // When/Then 68 | dprintService.isReady shouldBe false 69 | dprintService.isInitializedForTesting() shouldBe false 70 | dprintService.isRestartingForTesting() shouldBe false 71 | dprintService.getCurrentServiceForTesting() shouldBe null 72 | dprintService.getLastErrorForTesting() shouldBe null 73 | } 74 | 75 | test("isReady returns true when properly initialized with test helper") { 76 | // Given 77 | dprintService.setCurrentServiceForTesting(mockEditorService, "/config/path") 78 | 79 | // When/Then 80 | dprintService.isReady shouldBe true 81 | dprintService.isInitializedForTesting() shouldBe true 82 | dprintService.getCurrentServiceForTesting() shouldNotBe null 83 | dprintService.getConfigPath() shouldBe "/config/path" 84 | dprintService.getLastErrorForTesting() shouldBe null 85 | } 86 | 87 | test("isReady returns false when service has error") { 88 | // Given 89 | dprintService.setCurrentServiceForTesting(mockEditorService, "/config/path") 90 | dprintService.setErrorForTesting("Test error") 91 | 92 | // When/Then 93 | dprintService.isReady shouldBe false 94 | dprintService.getLastErrorForTesting() shouldBe "Test error" 95 | dprintService.isInitializedForTesting() shouldBe true // Still initialized, but has error 96 | } 97 | 98 | test("maybeGetFormatId returns null when no editor service") { 99 | // Given - no editor service set 100 | dprintService.getCurrentServiceForTesting() shouldBe null 101 | 102 | // When/Then 103 | dprintService.maybeGetFormatId() shouldBe null 104 | } 105 | 106 | test("maybeGetFormatId delegates to editor service when available") { 107 | // Given 108 | every { mockEditorService.maybeGetFormatId() } returns 42 109 | dprintService.setCurrentServiceForTesting(mockEditorService, "/config/path") 110 | 111 | // When/Then 112 | dprintService.maybeGetFormatId() shouldBe 42 113 | verify { mockEditorService.maybeGetFormatId() } 114 | } 115 | 116 | test("canCancelFormat returns false when no editor service") { 117 | // Given - no editor service set 118 | dprintService.getCurrentServiceForTesting() shouldBe null 119 | 120 | // When/Then 121 | dprintService.canCancelFormat() shouldBe false 122 | } 123 | 124 | test("canCancelFormat delegates to editor service when available") { 125 | // Given 126 | every { mockEditorService.canCancelFormat() } returns true 127 | dprintService.setCurrentServiceForTesting(mockEditorService, "/config/path") 128 | 129 | // When/Then 130 | dprintService.canCancelFormat() shouldBe true 131 | verify { mockEditorService.canCancelFormat() } 132 | } 133 | 134 | // Test suspend formatting operations 135 | test("formatSuspend returns null when no editor service") { 136 | runBlocking { 137 | // Given - no editor service set 138 | dprintService.getCurrentServiceForTesting() shouldBe null 139 | 140 | // When 141 | val result = 142 | dprintService.formatSuspend( 143 | path = "/test/file.ts", 144 | content = "test content", 145 | formatId = 1, 146 | ) 147 | 148 | // Then 149 | result shouldBe null 150 | } 151 | } 152 | 153 | test("formatSuspend returns result when editor service available") { 154 | runBlocking { 155 | // Given 156 | val expectedResult = FormatResult(formattedContent = "formatted content") 157 | coEvery { 158 | mockEditorService.fmt(any(), any(), any()) 159 | } returns expectedResult 160 | 161 | dprintService.setCurrentServiceForTesting(mockEditorService, "/config/path") 162 | 163 | // When 164 | val result = 165 | dprintService.formatSuspend( 166 | path = "/test/file.ts", 167 | content = "test content", 168 | formatId = 1, 169 | ) 170 | 171 | // Then 172 | result shouldBe expectedResult 173 | coVerify { mockEditorService.fmt("/test/file.ts", "test content", 1) } 174 | } 175 | } 176 | 177 | // Note: Exception handling test removed as it depends on implementation details 178 | // The important thing is that the service can handle normal operations 179 | 180 | // Test lifecycle operations 181 | test("initializeEditorService does nothing when disabled") { 182 | // Given 183 | every { mockState.enabled } returns false 184 | 185 | // When 186 | dprintService.initializeEditorService() 187 | 188 | // Then - state should remain unchanged 189 | dprintService.isReady shouldBe false 190 | dprintService.isInitializedForTesting() shouldBe false 191 | } 192 | 193 | test("initializeEditorService does nothing when already initialized") { 194 | // Given - service is already initialized 195 | dprintService.setCurrentServiceForTesting(mockEditorService, "/config/path") 196 | val wasReady = dprintService.isReady 197 | wasReady shouldBe true 198 | 199 | // When 200 | dprintService.initializeEditorService() 201 | 202 | // Then - should not change state 203 | dprintService.isReady shouldBe wasReady 204 | dprintService.getCurrentServiceForTesting() shouldBe mockEditorService 205 | } 206 | 207 | test("destroyEditorService resets all state") { 208 | // Given - service is initialized 209 | every { mockEditorService.destroyEditorService() } just runs 210 | dprintService.setCurrentServiceForTesting(mockEditorService, "/config/path") 211 | dprintService.isReady shouldBe true 212 | 213 | // When 214 | dprintService.destroyEditorService() 215 | 216 | // Then 217 | dprintService.isReady shouldBe false 218 | dprintService.getCurrentServiceForTesting() shouldBe null 219 | dprintService.getConfigPath() shouldBe null 220 | dprintService.isInitializedForTesting() shouldBe false 221 | dprintService.getLastErrorForTesting() shouldBe null 222 | verify { mockEditorService.destroyEditorService() } 223 | } 224 | 225 | // Test basic cache operations (without requiring task execution) 226 | test("primeCanFormatCacheForFile does nothing when service not ready") { 227 | // Given - service is not ready 228 | every { mockVirtualFile.path } returns "/test/file.ts" 229 | dprintService.isReady shouldBe false 230 | 231 | // When 232 | dprintService.primeCanFormatCacheForFile(mockVirtualFile) 233 | 234 | // Then - should not crash, method should handle gracefully 235 | // No direct verification possible without accessing internal state 236 | } 237 | 238 | test("clearCanFormatCache does not crash") { 239 | // When 240 | dprintService.clearCanFormatCache() 241 | 242 | // Then - should not crash 243 | // Internal cache should be cleared but we can't verify directly 244 | } 245 | }) 246 | -------------------------------------------------------------------------------- /src/main/kotlin/com/dprint/services/editorservice/v5/EditorServiceV5.kt: -------------------------------------------------------------------------------- 1 | package com.dprint.services.editorservice.v5 2 | 3 | import com.dprint.config.ProjectConfiguration 4 | import com.dprint.i18n.DprintBundle 5 | import com.dprint.services.editorservice.FormatResult 6 | import com.dprint.services.editorservice.IEditorService 7 | import com.dprint.services.editorservice.process.EditorProcess 8 | import com.dprint.utils.errorLogWithConsole 9 | import com.dprint.utils.infoLogWithConsole 10 | import com.dprint.utils.warnLogWithConsole 11 | import com.intellij.openapi.components.Service 12 | import com.intellij.openapi.components.service 13 | import com.intellij.openapi.diagnostic.logger 14 | import com.intellij.openapi.project.Project 15 | import kotlinx.coroutines.TimeoutCancellationException 16 | import kotlinx.coroutines.launch 17 | import kotlinx.coroutines.runBlocking 18 | import kotlinx.coroutines.withTimeout 19 | 20 | private val LOGGER = logger() 21 | private const val SHUTDOWN_TIMEOUT = 1000L 22 | 23 | @Service(Service.Level.PROJECT) 24 | class EditorServiceV5( 25 | private val project: Project, 26 | ) : IEditorService { 27 | private var stdoutListener: StdoutListener? = null 28 | 29 | private fun createStdoutListener(): StdoutListener { 30 | val stdoutListener = StdoutListener(project.service(), project.service()) 31 | stdoutListener.listen() 32 | return stdoutListener 33 | } 34 | 35 | override fun initialiseEditorService() { 36 | infoLogWithConsole( 37 | DprintBundle.message("editor.service.initialize", getName()), 38 | project, 39 | LOGGER, 40 | ) 41 | dropMessages() 42 | if (stdoutListener != null) { 43 | stdoutListener?.dispose() 44 | stdoutListener = null 45 | } 46 | 47 | project.service().initialize() 48 | stdoutListener = createStdoutListener() 49 | } 50 | 51 | override fun dispose() { 52 | destroyEditorService() 53 | } 54 | 55 | override fun destroyEditorService() { 56 | infoLogWithConsole(DprintBundle.message("editor.service.destroy", getName()), project, LOGGER) 57 | val message = createNewMessage(MessageType.ShutDownProcess) 58 | try { 59 | runBlocking { 60 | withTimeout(SHUTDOWN_TIMEOUT) { 61 | launch { 62 | project.service().writeBuffer(message.build()) 63 | } 64 | } 65 | } 66 | } catch (e: TimeoutCancellationException) { 67 | errorLogWithConsole(DprintBundle.message("editor.service.shutting.down.timed.out"), e, project, LOGGER) 68 | } finally { 69 | stdoutListener?.dispose() 70 | dropMessages() 71 | project.service().destroy() 72 | } 73 | } 74 | 75 | override suspend fun canFormat(filePath: String): Boolean? { 76 | handleStaleMessages() 77 | 78 | infoLogWithConsole(DprintBundle.message("formatting.checking.can.format", filePath), project, LOGGER) 79 | val message = createNewMessage(MessageType.CanFormat) 80 | message.addString(filePath) 81 | 82 | val timeout = project.service().state.commandTimeout 83 | project.service().writeBuffer(message.build()) 84 | val result = project.service().sendRequest(message.id, timeout) 85 | return handleCanFormatResult(result, filePath) 86 | } 87 | 88 | private fun handleCanFormatResult( 89 | result: MessageChannel.Result?, 90 | filePath: String, 91 | ): Boolean? { 92 | if (result == null) { 93 | return null 94 | } 95 | return when { 96 | (result.type == MessageType.CanFormatResponse && result.data is Boolean) -> { 97 | result.data 98 | } 99 | 100 | (result.type == MessageType.ErrorResponse && result.data is String) -> { 101 | infoLogWithConsole( 102 | DprintBundle.message("editor.service.format.check.failed", filePath, result.data), 103 | project, 104 | LOGGER, 105 | ) 106 | null 107 | } 108 | 109 | (result.type === MessageType.Dropped) -> { 110 | null 111 | } 112 | 113 | else -> { 114 | infoLogWithConsole( 115 | DprintBundle.message("editor.service.unsupported.message.type", result.type), 116 | project, 117 | LOGGER, 118 | ) 119 | null 120 | } 121 | } 122 | } 123 | 124 | /** 125 | * If we find pending messages we assume there is an issue with the underlying process and try restart. In the event 126 | * that doesn't work, it is likely there is a problem with the underlying daemon and the IJ process that runs on top 127 | * of it is not aware of its unhealthy state. 128 | */ 129 | private fun handleStaleMessages() { 130 | val messageChannel = project.service() 131 | if (messageChannel.hasStaleRequests()) { 132 | val removedCount = messageChannel.removeStaleRequests() 133 | infoLogWithConsole( 134 | DprintBundle.message("status.stale.requests.removed", removedCount), 135 | project, 136 | LOGGER, 137 | ) 138 | this.initialiseEditorService() 139 | } 140 | } 141 | 142 | override suspend fun fmt( 143 | filePath: String, 144 | content: String, 145 | formatId: Int?, 146 | ): FormatResult = fmt(filePath, content, formatId, null, null) 147 | 148 | override suspend fun fmt( 149 | filePath: String, 150 | content: String, 151 | formatId: Int?, 152 | startIndex: Int?, 153 | endIndex: Int?, 154 | ): FormatResult { 155 | infoLogWithConsole(DprintBundle.message("formatting.file", filePath), project, LOGGER) 156 | val message = createFormatMessage(formatId, filePath, content, startIndex, endIndex) 157 | val timeout = project.service().state.commandTimeout 158 | 159 | project.service().writeBuffer(message.build()) 160 | val result = project.service().sendRequest(message.id, timeout) 161 | val formatResult: FormatResult = mapResultToFormatResult(result, filePath) 162 | 163 | infoLogWithConsole( 164 | DprintBundle.message("editor.service.created.formatting.task", filePath, message.id), 165 | project, 166 | LOGGER, 167 | ) 168 | 169 | return formatResult 170 | } 171 | 172 | private fun createFormatMessage( 173 | formatId: Int?, 174 | filePath: String, 175 | content: String, 176 | startIndex: Int?, 177 | endIndex: Int?, 178 | ): OutgoingMessage { 179 | val outgoingMessage = OutgoingMessage(formatId ?: getNextMessageId(), MessageType.FormatFile) 180 | outgoingMessage.addString(filePath) 181 | 182 | // Converting string indices to bytes 183 | val startByteIndex = 184 | if (startIndex != null) { 185 | getByteIndex(content, startIndex) 186 | } else { 187 | 0 188 | } 189 | 190 | val endByteIndex = 191 | if (endIndex != null) { 192 | getByteIndex(content, endIndex) 193 | } else { 194 | content.encodeToByteArray().size 195 | } 196 | 197 | outgoingMessage.addInt(startByteIndex) // for range formatting add starting index 198 | outgoingMessage.addInt(endByteIndex) // add ending index 199 | outgoingMessage.addInt(0) // Override config 200 | outgoingMessage.addString(content) 201 | return outgoingMessage 202 | } 203 | 204 | private fun getByteIndex( 205 | content: String, 206 | stringIndex: Int, 207 | ): Int { 208 | // Handle edge cases 209 | if (stringIndex <= 0) return 0 210 | if (stringIndex >= content.length) return content.encodeToByteArray().size 211 | 212 | // Get substring up to the string index and convert to bytes 213 | return content.substring(0, stringIndex).encodeToByteArray().size 214 | } 215 | 216 | private fun mapResultToFormatResult( 217 | result: MessageChannel.Result?, 218 | filePath: String, 219 | ): FormatResult { 220 | if (result == null) { 221 | return FormatResult() 222 | } 223 | return when { 224 | (result.type == MessageType.FormatFileResponse && result.data is String?) -> { 225 | val successMessage = 226 | when (result.data) { 227 | null -> DprintBundle.message("editor.service.format.not.needed", filePath) 228 | else -> DprintBundle.message("editor.service.format.succeeded", filePath) 229 | } 230 | infoLogWithConsole(successMessage, project, LOGGER) 231 | FormatResult(formattedContent = result.data) 232 | } 233 | 234 | (result.type == MessageType.ErrorResponse && result.data is String) -> { 235 | warnLogWithConsole( 236 | DprintBundle.message("editor.service.format.failed", filePath, result.data), 237 | project, 238 | LOGGER, 239 | ) 240 | FormatResult(error = result.data) 241 | } 242 | 243 | (result.type != MessageType.Dropped) -> { 244 | val errorMessage = DprintBundle.message("editor.service.unsupported.message.type", result.type) 245 | warnLogWithConsole( 246 | DprintBundle.message("editor.service.format.failed", filePath, errorMessage), 247 | project, 248 | LOGGER, 249 | ) 250 | FormatResult(error = errorMessage) 251 | } 252 | 253 | else -> { 254 | FormatResult() 255 | } 256 | } 257 | } 258 | 259 | override fun canRangeFormat(): Boolean = false 260 | 261 | override fun canCancelFormat(): Boolean = true 262 | 263 | override fun maybeGetFormatId(): Int = getNextMessageId() 264 | 265 | override fun cancelFormat(formatId: Int) { 266 | val message = createNewMessage(MessageType.CancelFormat) 267 | infoLogWithConsole(DprintBundle.message("editor.service.cancel.format", formatId), project, LOGGER) 268 | message.addInt(formatId) 269 | runBlocking { 270 | project.service().writeBuffer(message.build()) 271 | } 272 | project.service().cancelRequest(formatId) 273 | } 274 | 275 | private fun dropMessages() { 276 | val cancelledIds = project.service().cancelAllRequests() 277 | for (messageId in cancelledIds) { 278 | infoLogWithConsole(DprintBundle.message("editor.service.clearing.message", messageId), project, LOGGER) 279 | } 280 | } 281 | 282 | private fun getName(): String = this::class.java.simpleName 283 | } 284 | --------------------------------------------------------------------------------