├── .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 |
--------------------------------------------------------------------------------
/src/main/resources/META-INF/toolWindowIcon_dark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/main/resources/META-INF/toolWindowIcon@20x20.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/main/resources/META-INF/toolWindowIcon@20x20_dark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/main/resources/META-INF/toolWindowIcon_selected_dark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/main/resources/META-INF/toolWindowIcon@20x20_selected.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/main/resources/META-INF/toolWindowIcon@20x20_selected_dark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
6 |
7 |
8 |
9 |
12 |
17 |
18 |
19 | true
20 | true
21 | false
22 |
23 |
24 |
--------------------------------------------------------------------------------
/.run/Run IDE with Plugin.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
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 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | true
20 | true
21 | false
22 |
23 |
24 |
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 |
--------------------------------------------------------------------------------