diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5ec2f0a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +end_of_line = unset +indent_style = space +indent_size = 2 +ij_continuation_indent_size = 2 +max_line_length = 140 +insert_final_newline = true + +ij_any_else_on_new_line = true +ij_any_catch_on_new_line = true +ij_any_finally_on_new_line = true +ij_any_block_comment_at_first_column = false +ij_any_line_comment_at_first_column = false +ij_javascript_force_semicolon_style = true +ij_javascript_use_semicolon_after_statement = false +ij_formatter_tags_enabled = true + +[*.py] +indent_size = 4 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a3a9379 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Alessandro Arcangeli + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/gradle.properties b/gradle.properties index 63347ff..1dc8179 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,7 +16,7 @@ platformVersion = 2021.3.3 # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html # Example: platformPlugins = com.intellij.java, com.jetbrains.php:203.4449.22 -platformPlugins = +platformPlugins = org.jetbrains.plugins.yaml # Gradle Releases -> https://github.com/gradle/gradle/releases gradleVersion = 7.5.1 diff --git a/src/main/java/com/github/aarcangeli/ideaclangformat/ClangFormatReplacement.java b/src/main/java/com/github/aarcangeli/ideaclangformat/ClangFormatReplacement.java new file mode 100644 index 0000000..9f34ba7 --- /dev/null +++ b/src/main/java/com/github/aarcangeli/ideaclangformat/ClangFormatReplacement.java @@ -0,0 +1,13 @@ +package com.github.aarcangeli.ideaclangformat; + +import javax.xml.bind.annotation.XmlAttribute; +import javax.xml.bind.annotation.XmlValue; + +public class ClangFormatReplacement { + @XmlAttribute + public int offset; + @XmlAttribute + public int length; + @XmlValue + public String value; +} diff --git a/src/main/java/com/github/aarcangeli/ideaclangformat/ClangFormatResponse.java b/src/main/java/com/github/aarcangeli/ideaclangformat/ClangFormatResponse.java new file mode 100644 index 0000000..561d5d3 --- /dev/null +++ b/src/main/java/com/github/aarcangeli/ideaclangformat/ClangFormatResponse.java @@ -0,0 +1,40 @@ +package com.github.aarcangeli.ideaclangformat; + +import com.github.aarcangeli.ideaclangformat.exceptions.ClangFormatError; +import com.intellij.openapi.util.NlsSafe; +import org.jetbrains.annotations.NotNull; + +import javax.xml.bind.JAXBContext; +import javax.xml.bind.JAXBException; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; +import java.io.StringReader; +import java.util.List; + +@XmlRootElement(name = "replacements") +public class ClangFormatResponse { + static final JAXBContext REPLACEMENTS_CTX; + + static { + try { + REPLACEMENTS_CTX = JAXBContext.newInstance(ClangFormatResponse.class); + } + catch (JAXBException e) { + throw new RuntimeException("Failed to load JAXB context", e); + } + } + + public static ClangFormatResponse unmarshal(@NotNull @NlsSafe String stdout) { + try { + // JAXB closes the InputStream. + return (ClangFormatResponse) REPLACEMENTS_CTX.createUnmarshaller().unmarshal(new StringReader(stdout)); + } + catch (JAXBException e) { + throw new ClangFormatError("Failed to parse clang-format XML replacements\n" + stdout, e); + } + } + + @XmlElement(name = "replacement") + public List replacements; +} + diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/ClangFormatExternalFormatProcessor.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/ClangFormatExternalFormatProcessor.kt new file mode 100644 index 0000000..13c221f --- /dev/null +++ b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/ClangFormatExternalFormatProcessor.kt @@ -0,0 +1,38 @@ +package com.github.aarcangeli.ideaclangformat + +import com.github.aarcangeli.ideaclangformat.services.ClangFormatService +import com.intellij.openapi.components.service +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiFile +import com.intellij.psi.codeStyle.ExternalFormatProcessor + +class ClangFormatExternalFormatProcessor : ExternalFormatProcessor { + override fun activeForFile(file: PsiFile): Boolean { + return service().mayBeFormatted(file) + } + + override fun format( + source: PsiFile, + range: TextRange, + canChangeWhiteSpacesOnly: Boolean, + keepLineBreaks: Boolean, + enableBulkUpdate: Boolean + ): TextRange? { + val virtualFile = source.originalFile.virtualFile + if (virtualFile != null) { + ProgressManager.checkCanceled() + service().reformatFileSync(source.project, virtualFile) + return range + } + return null + } + + override fun indent(source: PsiFile, lineStartOffset: Int): String? { + return null + } + + override fun getId(): String { + return "aarcangeli.clang-format" + } +} diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/ClangFormatSettings.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/ClangFormatSettings.kt new file mode 100644 index 0000000..5d97653 --- /dev/null +++ b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/ClangFormatSettings.kt @@ -0,0 +1,9 @@ +package com.github.aarcangeli.ideaclangformat + +import com.intellij.psi.codeStyle.CodeStyleSettings +import com.intellij.psi.codeStyle.CustomCodeStyleSettings + +class ClangFormatSettings(container: CodeStyleSettings?) : CustomCodeStyleSettings("clang-format", container) { + @JvmField + var ENABLED = true +} diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/ClangFormatStyleSettingsModifier.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/ClangFormatStyleSettingsModifier.kt new file mode 100644 index 0000000..6b5008c --- /dev/null +++ b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/ClangFormatStyleSettingsModifier.kt @@ -0,0 +1,246 @@ +package com.github.aarcangeli.ideaclangformat + +import com.github.aarcangeli.ideaclangformat.MyBundle.message +import com.github.aarcangeli.ideaclangformat.exceptions.ClangExitCodeError +import com.github.aarcangeli.ideaclangformat.exceptions.ClangFormatError +import com.github.aarcangeli.ideaclangformat.services.ClangFormatService +import com.github.aarcangeli.ideaclangformat.utils.ClangFormatCommons.isUnconditionallyNotSupported +import com.intellij.CodeStyleBundle +import com.intellij.application.options.CodeStyle +import com.intellij.icons.AllIcons +import com.intellij.ide.actions.ShowSettingsUtilImpl +import com.intellij.ide.util.PsiNavigationSupport +import com.intellij.notification.Notification +import com.intellij.notification.NotificationType +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.application.ApplicationBundle +import com.intellij.openapi.components.service +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.openapi.util.text.HtmlBuilder +import com.intellij.openapi.util.text.HtmlChunk +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiFile +import com.intellij.psi.codeStyle.CodeStyleSettingsManager +import com.intellij.psi.codeStyle.IndentStatusBarUIContributor +import com.intellij.psi.codeStyle.modifier.CodeStyleSettingsModifier +import com.intellij.psi.codeStyle.modifier.CodeStyleStatusBarUIContributor +import com.intellij.psi.codeStyle.modifier.TransientCodeStyleSettings +import com.intellij.ui.ColorUtil +import com.intellij.ui.JBColor +import org.jetbrains.annotations.Nls +import javax.swing.Icon + +class ClangFormatStyleSettingsModifier : CodeStyleSettingsModifier { + private val LAST_PROVIDED_SETTINGS = Key.create("WAS_FILE_SUPPORTED") + override fun modifySettings(settings: TransientCodeStyleSettings, file: PsiFile): Boolean { + if (!settings.getCustomSettings(ClangFormatSettings::class.java).ENABLED) { + return false + } + if (isUnconditionallyNotSupported(file)) { + return false + } + val formatService = service() + settings.addDependency(formatService.makeDependencyTracker(file)!!) + return if (!formatService.mayBeFormatted(file)) { + // clang format disabled for this file + false + } + else try { + val clangFormatStyle = ClangFormatStyle(formatService.getRawFormatStyle(file)) + file.putUserData(LAST_PROVIDED_SETTINGS, clangFormatStyle) + clangFormatStyle.apply(settings) + true + } + catch (e: ClangExitCodeError) { + // configuration broken, re-use last provided settings + val clangFormatStyle = file.getUserData(LAST_PROVIDED_SETTINGS) + if (clangFormatStyle != null) { + clangFormatStyle.apply(settings) + return true + } + false + } + catch (e: ClangFormatError) { + // other error + false + } + } + + override fun getName(): @Nls(capitalization = Nls.Capitalization.Title) String { + return message("error.clang-format.name") + } + + override fun getStatusBarUiContributor(settings: TransientCodeStyleSettings): CodeStyleStatusBarUIContributor { + return object : IndentStatusBarUIContributor(settings.indentOptions) { + override fun getTooltip(): String { + val builder = HtmlBuilder() + builder + .append(CodeStyleBundle.message("indent.status.bar.indent.tooltip")) + .append(" ") + .append( + HtmlChunk.tag("b").addText( + getIndentInfo( + indentOptions + ) + ) + ) + .br() + builder + .append("Tab Size: ") + .append(" ") + .append(HtmlChunk.tag("b").addText(indentOptions.TAB_SIZE.toString())) + .br() + builder + .append("Right Margin: ") + .append(" ") + .append(HtmlChunk.tag("b").addText(settings.defaultRightMargin.toString())) + .br() + builder.append( + HtmlChunk.span("color:" + ColorUtil.toHtmlColor(JBColor.GRAY)) + .addText(message("error.clang-format.status.hint")) + ) + return builder.wrapWith("html").toString() + } + + override fun getHint(): String { + return message("error.clang-format.status.hint") + } + + override fun areActionsAvailable(file: VirtualFile): Boolean { + return true + } + + override fun isShowFileIndentOptionsEnabled(): Boolean { + return false + } + + override fun getActions(file: PsiFile): Array { + return arrayOf(OpenClangConfigAction()) + } + + override fun createDisableAction(project: Project): AnAction { + return DumbAwareAction.create(message("clang-format.disable")) { e: AnActionEvent? -> + val currentSettings = CodeStyle.getSettings(project).getCustomSettings(ClangFormatSettings::class.java) + currentSettings.ENABLED = false + CodeStyleSettingsManager.getInstance(project).notifyCodeStyleSettingsChanged() + ClangFormatDisabledNotification(project).notify(project) + } + } + + override fun getIcon(): Icon { + // this is the same of ".editorconfig" + return AllIcons.Ide.ConfigFile + } + } + } + + private class ClangFormatDisabledNotification(project: Project) : Notification( + ClangFormatService.GROUP_ID, + message("clang-format.disabled.notification"), + "", + NotificationType.INFORMATION + ) { + init { + addAction(ReEnableAction(project, this)) + addAction(ShowEditorConfigOption(ApplicationBundle.message("code.style.indent.provider.notification.settings"))) + } + } + + private class ReEnableAction( + private val myProject: Project, + private val myNotification: Notification + ) : DumbAwareAction(ApplicationBundle.message("code.style.indent.provider.notification.re.enable")) { + override fun actionPerformed(e: AnActionEvent) { + val rootSettings = CodeStyle.getSettings(myProject) + val settings = rootSettings.getCustomSettings( + ClangFormatSettings::class.java + ) + settings.ENABLED = true + CodeStyleSettingsManager.getInstance(myProject).notifyCodeStyleSettingsChanged() + myNotification.expire() + } + } + + private class ShowEditorConfigOption(text: @Nls String?) : DumbAwareAction(text) { + override fun actionPerformed(e: AnActionEvent) { + ShowSettingsUtilImpl.showSettingsDialog(e.project, "preferences.sourceCode", null) + } + } + + private class OpenClangConfigAction : AnAction(), DumbAware { + override fun update(e: AnActionEvent) { + e.presentation.text = message("error.clang-format.status.open") + e.presentation.isEnabled = isEnabled(e) + } + + private fun isEnabled(e: AnActionEvent): Boolean { + val project = e.project + val virtualFile = e.dataContext.getData(CommonDataKeys.VIRTUAL_FILE) + if (project != null && virtualFile != null) { + val styleFile: VirtualFile? = service().getStyleFile(virtualFile) + return styleFile != null + } + return false + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project + val virtualFile = e.dataContext.getData(CommonDataKeys.VIRTUAL_FILE) + if (project != null && virtualFile != null) { + val styleFile: VirtualFile? = service().getStyleFile(virtualFile) + if (styleFile != null) { + PsiNavigationSupport.getInstance().createNavigatable(project, styleFile, -1).navigate(true) + } + } + } + } + + /** + * A small subset of clang-format style + */ + private class ClangFormatStyle(formatStyle: Map) { + private val columnLimit: Int + private val indentWidth: Int + private val tabWidth: Int + private val useTab: Boolean + + init { + columnLimit = getInt(formatStyle, "ColumnLimit") + indentWidth = getInt(formatStyle, "IndentWidth") + tabWidth = getInt(formatStyle, "TabWidth") + + // fixme: unsupported values "ForIndentation", "ForContinuationAndIndentation", etc + val useTabProp = formatStyle["UseTab"] + useTab = useTabProp != null && !useTabProp.toString() + .equals("false", ignoreCase = true) && !useTabProp.toString().equals("never", ignoreCase = true) + } + + fun apply(settings: TransientCodeStyleSettings) { + if (columnLimit > 0) { + settings.defaultRightMargin = columnLimit + } + if (indentWidth > 0) { + settings.indentOptions.INDENT_SIZE = indentWidth + } + if (tabWidth > 0) { + settings.indentOptions.TAB_SIZE = tabWidth + } + settings.indentOptions.USE_TAB_CHARACTER = useTab + } + + companion object { + private fun getInt(formatStyle: Map, tag: String): Int { + val value = formatStyle[tag] + return if (value is Int) { + value + } + else -1 + } + } + } +} diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/MyBundle.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/MyBundle.kt index ae2d045..05eb7cc 100644 --- a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/MyBundle.kt +++ b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/MyBundle.kt @@ -5,7 +5,7 @@ import org.jetbrains.annotations.NonNls import org.jetbrains.annotations.PropertyKey @NonNls -private const val BUNDLE = "messages.MyBundle" +private const val BUNDLE = "messages.ClangFormatBundle" object MyBundle : DynamicBundle(BUNDLE) { diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/OverrideActions.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/OverrideActions.kt new file mode 100644 index 0000000..2d87f0c --- /dev/null +++ b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/OverrideActions.kt @@ -0,0 +1,24 @@ +package com.github.aarcangeli.ideaclangformat + +import com.github.aarcangeli.ideaclangformat.actions.ReformatCodeWithClangAction +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.StartupActivity + +private val LOG = Logger.getInstance(OverrideActions::class.java) + +class OverrideActions : StartupActivity { + override fun runActivity(project: Project) { + val actionId = "ReformatCode" + val oldAction = ActionManager.getInstance().getAction(actionId) + if (oldAction == null) { + LOG.warn("Action not found $actionId") + return + } + val newAction: AnAction = ReformatCodeWithClangAction(oldAction) + LOG.info("Overriding built-in action $actionId (${oldAction.javaClass.name}) with ${newAction.javaClass.name}") + ActionManager.getInstance().replaceAction(actionId, newAction) + } +} diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/actions/ReformatCodeWithClangAction.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/actions/ReformatCodeWithClangAction.kt new file mode 100644 index 0000000..0fb3003 --- /dev/null +++ b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/actions/ReformatCodeWithClangAction.kt @@ -0,0 +1,56 @@ +package com.github.aarcangeli.ideaclangformat.actions + +import com.github.aarcangeli.ideaclangformat.services.ClangFormatService +import com.github.aarcangeli.ideaclangformat.utils.ClangFormatCommons +import com.intellij.codeInsight.actions.ReformatCodeAction +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.OverridingAction +import com.intellij.openapi.components.service +import com.intellij.openapi.project.DumbAware + +/** + * This action is faster than the original [ReformatCodeAction] when used with a single file. + */ +class ReformatCodeWithClangAction(private val baseAction: AnAction) : AnAction(), DumbAware, OverridingAction { + init { + templatePresentation.copyFrom(baseAction.templatePresentation) + } + + override fun update(e: AnActionEvent) { + if (!isManaged(e)) { + baseAction.update(e) + } + } + + override fun actionPerformed(e: AnActionEvent) { + if (!handleAction(e)) { + baseAction.actionPerformed(e) + } + } + + private fun handleAction(event: AnActionEvent): Boolean { + val dataContext = event.dataContext + val project = CommonDataKeys.PROJECT.getData(dataContext) ?: return false + val editor = CommonDataKeys.EDITOR.getData(dataContext) + if (editor != null) { + val virtualFile = ClangFormatCommons.getVirtualFileFor(project, editor.document) + if (virtualFile != null) { + service().reformatInBackground(project, virtualFile) + return true + } + } + return false + } + + private fun isManaged(event: AnActionEvent): Boolean { + val dataContext = event.dataContext + val project = CommonDataKeys.PROJECT.getData(dataContext) ?: return false + val editor = CommonDataKeys.EDITOR.getData(dataContext) + return if (editor != null) { + ClangFormatCommons.getVirtualFileFor(project, editor.document) != null + } + else false + } +} diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/exceptions/ClangExitCodeError.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/exceptions/ClangExitCodeError.kt new file mode 100644 index 0000000..d0ecb98 --- /dev/null +++ b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/exceptions/ClangExitCodeError.kt @@ -0,0 +1,13 @@ +package com.github.aarcangeli.ideaclangformat.exceptions + +import com.intellij.build.FileNavigatable +import com.intellij.pom.Navigatable +import org.jetbrains.annotations.Nls + +class ClangExitCodeError(@JvmField val description: @Nls String, private val fileNavigatable: FileNavigatable) : + ClangFormatError(description) { + + fun getFileNavigatable(): Navigatable { + return fileNavigatable + } +} diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/exceptions/ClangFormatError.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/exceptions/ClangFormatError.kt new file mode 100644 index 0000000..43ec85f --- /dev/null +++ b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/exceptions/ClangFormatError.kt @@ -0,0 +1,8 @@ +package com.github.aarcangeli.ideaclangformat.exceptions + +import org.jetbrains.annotations.Nls + +open class ClangFormatError : RuntimeException { + constructor(message: @Nls String) : super(message) + constructor(message: @Nls String, cause: Throwable?) : super(message, cause) +} diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/exceptions/ClangFormatNotFound.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/exceptions/ClangFormatNotFound.kt new file mode 100644 index 0000000..dd5cd32 --- /dev/null +++ b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/exceptions/ClangFormatNotFound.kt @@ -0,0 +1,5 @@ +package com.github.aarcangeli.ideaclangformat.exceptions + +import org.jetbrains.annotations.Nls + +class ClangFormatNotFound(message: @Nls String) : ClangFormatError(message) diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/exceptions/ClangMissingLanguageException.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/exceptions/ClangMissingLanguageException.kt new file mode 100644 index 0000000..39bd5c0 --- /dev/null +++ b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/exceptions/ClangMissingLanguageException.kt @@ -0,0 +1,9 @@ +package com.github.aarcangeli.ideaclangformat.exceptions + +import org.jetbrains.annotations.Nls + +/** + * Thrown when no ".clang-format" files support the file language. + * eg: "Configuration file(s) do(es) not support CSharp: /path/.clang-format" + */ +class ClangMissingLanguageException(message: @Nls String) : ClangFormatError(message) diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/lang/ClangFormatFile.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/lang/ClangFormatFile.kt new file mode 100644 index 0000000..b4b4d35 --- /dev/null +++ b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/lang/ClangFormatFile.kt @@ -0,0 +1,5 @@ +package com.github.aarcangeli.ideaclangformat.lang + +import com.intellij.psi.PsiFile + +interface ClangFormatFile : PsiFile diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/lang/ClangFormatFileImpl.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/lang/ClangFormatFileImpl.kt new file mode 100644 index 0000000..c91e519 --- /dev/null +++ b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/lang/ClangFormatFileImpl.kt @@ -0,0 +1,6 @@ +package com.github.aarcangeli.ideaclangformat.lang + +import com.intellij.psi.FileViewProvider +import org.jetbrains.yaml.psi.impl.YAMLFileImpl + +class ClangFormatFileImpl(viewProvider: FileViewProvider) : YAMLFileImpl(viewProvider), ClangFormatFile diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/lang/ClangFormatFileType.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/lang/ClangFormatFileType.kt new file mode 100644 index 0000000..de5bfe4 --- /dev/null +++ b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/lang/ClangFormatFileType.kt @@ -0,0 +1,29 @@ +package com.github.aarcangeli.ideaclangformat.lang + +import com.intellij.icons.AllIcons +import com.intellij.openapi.fileTypes.LanguageFileType +import org.jetbrains.annotations.NonNls +import javax.swing.Icon + +class ClangFormatFileType private constructor() : LanguageFileType(ClangFormatLanguage.INSTANCE) { + override fun getName(): @NonNls String { + return "ClangFormatStyle" + } + + override fun getDescription(): String { + return "Clang-format style options" + } + + override fun getDefaultExtension(): String { + return ".clang-format" + } + + override fun getIcon(): Icon { + // Reuse the same icon as the .editorconfig file + return AllIcons.Nodes.Editorconfig + } + + companion object { + val INSTANCE = ClangFormatFileType() + } +} diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/lang/ClangFormatLanguage.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/lang/ClangFormatLanguage.kt new file mode 100644 index 0000000..a95f817 --- /dev/null +++ b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/lang/ClangFormatLanguage.kt @@ -0,0 +1,15 @@ +package com.github.aarcangeli.ideaclangformat.lang + +import com.intellij.lang.Language +import org.jetbrains.yaml.YAMLLanguage + +class ClangFormatLanguage : Language(YAMLLanguage.INSTANCE, "aarcangeli.ClangFormat") { + override fun getDisplayName(): String { + return "Clang-Format Style Options" + } + + companion object { + @JvmField + val INSTANCE = ClangFormatLanguage() + } +} diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/lang/ClangFormatParserDefinition.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/lang/ClangFormatParserDefinition.kt new file mode 100644 index 0000000..5c8b6f8 --- /dev/null +++ b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/lang/ClangFormatParserDefinition.kt @@ -0,0 +1,26 @@ +package com.github.aarcangeli.ideaclangformat.lang + +import com.intellij.lang.PsiParser +import com.intellij.openapi.project.Project +import com.intellij.psi.FileViewProvider +import com.intellij.psi.PsiFile +import com.intellij.psi.tree.IFileElementType +import org.jetbrains.yaml.YAMLParserDefinition + +class ClangFormatParserDefinition : YAMLParserDefinition() { + override fun getFileNodeType(): IFileElementType { + return FILE + } + + override fun createFile(viewProvider: FileViewProvider): PsiFile { + return ClangFormatFileImpl(viewProvider) + } + + override fun createParser(project: Project): PsiParser { + return super.createParser(project) + } + + companion object { + private val FILE = IFileElementType(ClangFormatLanguage.INSTANCE) + } +} diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/lang/ClangFormatSchemaProviderFactory.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/lang/ClangFormatSchemaProviderFactory.kt new file mode 100644 index 0000000..75fde97 --- /dev/null +++ b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/lang/ClangFormatSchemaProviderFactory.kt @@ -0,0 +1,73 @@ +package com.github.aarcangeli.ideaclangformat.lang + +import com.intellij.openapi.fileTypes.LanguageFileType +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.io.StreamUtil +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.testFramework.LightVirtualFile +import com.jetbrains.jsonSchema.extension.JsonSchemaFileProvider +import com.jetbrains.jsonSchema.extension.JsonSchemaProviderFactory +import com.jetbrains.jsonSchema.extension.SchemaType +import org.jetbrains.annotations.Nls +import java.io.IOException +import java.io.InputStreamReader +import java.nio.charset.StandardCharsets + +private val schema = ClangFormatSchemaProviderFactory.readSchema() +private val clangFormatSchemaFile = LightVirtualFile("clangFormat-options.json", schema) + +class ClangFormatSchemaProviderFactory : JsonSchemaProviderFactory, DumbAware { + override fun getProviders(project: Project): List { + return listOf(ClangFormatSchemaProvider()) + } + + private class ClangFormatSchemaProvider : JsonSchemaFileProvider { + override fun isAvailable(file: VirtualFile): Boolean { + val fileType = file.fileType + return if (fileType is LanguageFileType) { + fileType.language.isKindOf(ClangFormatLanguage.INSTANCE) + } + else false + } + + override fun getName(): @Nls String { + return "Clang Format Schema Provider" + } + + override fun getSchemaFile(): VirtualFile? { + return clangFormatSchemaFile + } + + override fun getSchemaType(): SchemaType { + return SchemaType.embeddedSchema + } + + override fun isUserVisible(): Boolean { + return false + } + } + + companion object { + fun readSchema(): String { + val resourceAsStream = + ClangFormatSchemaProviderFactory::class.java.getResourceAsStream("/schemas/clangFormat-options.json") + if (resourceAsStream != null) { + try { + resourceAsStream.use { + return StreamUtil.readText( + InputStreamReader( + resourceAsStream, + StandardCharsets.UTF_8 + ) + ) + } + } + catch (e: IOException) { + throw RuntimeException("Cannot read schema", e) + } + } + return "" + } + } +} diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/listeners/MyProjectManagerListener.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/listeners/MyProjectManagerListener.kt deleted file mode 100644 index cca5f58..0000000 --- a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/listeners/MyProjectManagerListener.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.github.aarcangeli.ideaclangformat.listeners - -import com.github.aarcangeli.ideaclangformat.services.MyProjectService -import com.intellij.openapi.components.service -import com.intellij.openapi.project.Project -import com.intellij.openapi.project.ProjectManagerListener - -internal class MyProjectManagerListener : ProjectManagerListener { - - override fun projectOpened(project: Project) { - project.service() - } -} diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/services/ClangFormatReplacement.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/services/ClangFormatReplacement.kt new file mode 100644 index 0000000..354c008 --- /dev/null +++ b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/services/ClangFormatReplacement.kt @@ -0,0 +1,15 @@ +package com.github.aarcangeli.ideaclangformat.services + +import javax.xml.bind.annotation.XmlAttribute +import javax.xml.bind.annotation.XmlValue + +class ClangFormatReplacement { + @XmlAttribute + var offset = 0 + + @XmlAttribute + var length = 0 + + @XmlValue + var value: String? = null +} diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/services/ClangFormatService.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/services/ClangFormatService.kt new file mode 100644 index 0000000..f6b7e2e --- /dev/null +++ b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/services/ClangFormatService.kt @@ -0,0 +1,57 @@ +package com.github.aarcangeli.ideaclangformat.services + +import com.github.aarcangeli.ideaclangformat.exceptions.ClangFormatError +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.ModificationTracker +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiFile +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.concurrency.annotations.RequiresReadLock + +/** + * Provides functionalities to format a document using clang-format + */ +interface ClangFormatService { + /** + * Reformat the specified file + */ + @RequiresEdt + fun reformatFileSync(project: Project, virtualFile: VirtualFile) + + /** + * Reformat the specified file asynchronously, the operation is completed later. + */ + @RequiresEdt + fun reformatInBackground(project: Project, virtualFile: VirtualFile) + + @Throws(ClangFormatError::class) + fun getRawFormatStyle(psiFile: PsiFile): Map + + @get:Throws(ClangFormatError::class) + val clangFormatPath: String + + /** + * Returns a tracker that changes when configuration of a specific file changes + */ + @RequiresReadLock + fun makeDependencyTracker(file: PsiFile): ModificationTracker? + fun getStyleFile(virtualFile: VirtualFile): VirtualFile? + + /** + * Returns true if a ".clang-format" is available for the specified file. + * The file must also be with a matching language. + * The result of this value may be cached using the tracker returned by "makeDependencyTracker" + * + * @return + */ + fun mayBeFormatted(file: PsiFile): Boolean + + companion object { + val instance: ClangFormatService + get() = ApplicationManager.getApplication().getService( + ClangFormatService::class.java + ) + const val GROUP_ID = "aarcangeli.notification.ClangFormat" + } +} diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/services/ClangFormatServiceImpl.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/services/ClangFormatServiceImpl.kt new file mode 100644 index 0000000..5959a0f --- /dev/null +++ b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/services/ClangFormatServiceImpl.kt @@ -0,0 +1,720 @@ +package com.github.aarcangeli.ideaclangformat.services + +import com.github.aarcangeli.ideaclangformat.ClangFormatResponse +import com.github.aarcangeli.ideaclangformat.MyBundle.message +import com.github.aarcangeli.ideaclangformat.exceptions.ClangExitCodeError +import com.github.aarcangeli.ideaclangformat.exceptions.ClangFormatError +import com.github.aarcangeli.ideaclangformat.exceptions.ClangFormatNotFound +import com.github.aarcangeli.ideaclangformat.exceptions.ClangMissingLanguageException +import com.github.aarcangeli.ideaclangformat.utils.ClangFormatCommons +import com.github.aarcangeli.ideaclangformat.utils.OffsetConverter +import com.intellij.build.FileNavigatable +import com.intellij.build.FilePosition +import com.intellij.codeInsight.CodeInsightBundle +import com.intellij.core.CoreBundle +import com.intellij.execution.ExecutionException +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.configurations.PathEnvironmentVariableUtil +import com.intellij.execution.process.CapturingProcessHandler +import com.intellij.execution.process.ProcessOutput +import com.intellij.notification.Notification +import com.intellij.notification.NotificationListener +import com.intellij.notification.NotificationType +import com.intellij.notification.Notifications +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.* +import com.intellij.openapi.command.CommandProcessor +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.editor.Document +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.progress.ProcessCanceledException +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.progress.util.ProgressIndicatorUtils +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.util.* +import com.intellij.openapi.vfs.AsyncFileListener +import com.intellij.openapi.vfs.AsyncFileListener.ChangeApplier +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.openapi.vfs.newvfs.events.* +import com.intellij.psi.PsiFile +import com.intellij.psi.util.CachedValue +import com.intellij.psi.util.CachedValueProvider +import com.intellij.psi.util.CachedValuesManager +import com.intellij.testFramework.LightVirtualFile +import com.intellij.util.DocumentUtil +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.concurrency.annotations.RequiresReadLock +import com.intellij.util.containers.ContainerUtil +import com.intellij.util.containers.FixedHashMap +import org.jetbrains.annotations.NonNls +import org.yaml.snakeyaml.Yaml +import org.yaml.snakeyaml.error.YAMLException +import java.io.File +import java.io.IOException +import java.nio.charset.StandardCharsets +import java.util.* +import java.util.concurrent.Future +import java.util.concurrent.atomic.AtomicReference +import java.util.regex.Pattern +import javax.swing.event.HyperlinkEvent + +private val LOG = Logger.getInstance(ClangFormatServiceImpl::class.java) + +/** + * See class ApplyChangesState + */ +private val BULK_REPLACE_OPTIMIZATION_CRITERIA = 1000 + +class ClangFormatServiceImpl : ClangFormatService, Disposable { + private val errorNotification = AtomicReference() + private val WAS_CLANG_FORMAT_SUPPORTED = Key.create("WAS_CLANG_FORMAT_SUPPORTED") + private val afterWriteActionFinished = ContainerUtil.createLockFreeCopyOnWriteList() + private val cache: MutableMap> = FixedHashMap(100) + + init { + ApplicationManager.getApplication().addApplicationListener(MyApplicationListener(), this) + VirtualFileManager.getInstance().addAsyncFileListener(ClangFormatCacheManagment(), this) + } + + override fun reformatFileSync(project: Project, file: VirtualFile) { + val document = FileDocumentManager.getInstance().getDocument(file) ?: return + if (!ensureModifiable(project, file, document)) { + return + } + + // save all ".clang-format" + saveAllClangFormatFiles() + val stamp = document.modificationStamp + val content = ReadAction.compute { document.text } + .toByteArray(StandardCharsets.UTF_8) + val replacements = computeReplacementsWithProgress(project, file, content) + + // Apply replacements + if (replacements != null) { + if (replacements.replacements != null && stamp == document.modificationStamp) { + applyReplacementsWithCommand(project, content, document, replacements) + } + + // remove last error notification + clearLastNotification() + } + } + + override fun reformatInBackground(project: Project, virtualFile: VirtualFile) { + val document = FileDocumentManager.getInstance().getDocument(virtualFile) ?: return + if (!ensureModifiable(project, virtualFile, document)) { + return + } + + // save all ".clang-format" + saveAllClangFormatFiles() + + // Read the file content + val stamp = document.modificationStamp + val fileName = getFileName(virtualFile) + val content = ReadAction.compute { document.text } + + runTaskAsync(project) { indicator -> + // cancel the operation if the document is changed + val canceller = Runnable { + if (document.modificationStamp != stamp) { + // cancel operation when the document is modified + indicator.cancel() + } + } + + val contentAsByteArray = content.toByteArray(StandardCharsets.UTF_8) + + try { + afterWriteActionFinished.add(canceller) + val replacements = computeReplacementsWithError(project, contentAsByteArray, fileName, virtualFile.name) + if (replacements != null) { + invokeAndWaitIfNeeded { + runWriteAction { + if (stamp == document.modificationStamp) { + applyReplacementsWithCommand(project, contentAsByteArray, document, replacements) + } + } + } + } + } + catch (e: Throwable) { + LOG.error(e) + } + finally { + afterWriteActionFinished.remove(canceller) + } + } + } + + private fun computeReplacementsWithProgress( + project: Project, + file: VirtualFile, + content: ByteArray + ): ClangFormatResponse { + val replacementsRef = Ref() + val fileName = getFileName(file) + ProgressManager.getInstance().runProcessWithProgressSynchronously({ + replacementsRef.set( + computeReplacementsWithError( + project, + content, + fileName, + file.name + ) + ) + }, message("error.clang-format.formatting"), true, project) + return replacementsRef.get() + } + + private fun runTaskAsync(project: Project, fn: (indicator: ProgressIndicator) -> Unit) { + val task = object : Task.Backgroundable(project, message("error.clang-format.formatting"), true) { + override fun run(indicator: ProgressIndicator) { + fn(indicator); + } + } + ProgressManager.getInstance().run(task) + } + + private fun computeReplacementsWithError( + project: Project, + content: ByteArray, + fileName: String, + fileSmallName: String + ): ClangFormatResponse? { + return try { + executeClangFormat(project, content, fileName) + } + catch (e: ProcessCanceledException) { + null + } + catch (e: ClangExitCodeError) { + LOG.warn("Cannot format document", e) + showFormatError( + project, + e.description, + fileSmallName + ) { notification: Notification?, event: HyperlinkEvent? -> + e.getFileNavigatable().navigate(true) + } + null + } + catch (e: ClangFormatError) { + LOG.warn("Cannot format document", e) + showFormatError(project, e.message, fileSmallName, null) + null + } + catch (e: ExecutionException) { + LOG.warn("Cannot format document", e) + showFormatError(project, e.message, fileSmallName, null) + null + } + catch (e: Exception) { + LOG.warn("Cannot format document", e) + showFormatError(project, "Unknown error", fileSmallName, null) + null + } + } + + @RequiresEdt + private fun saveAllClangFormatFiles() { + for (document in getUnsavedClangFormats()) { + FileDocumentManager.getInstance().saveDocument(document) + } + } + + private fun getUnsavedClangFormats(): Array { + val documents = ArrayList() + for (document in FileDocumentManager.getInstance().unsavedDocuments) { + val file = FileDocumentManager.getInstance().getFile(document) ?: continue + if (ClangFormatCommons.isClangFormatFile(file.name) && document.isWritable) { + documents.add(document) + } + } + return documents.toTypedArray() + } + + private fun showFormatError( + project: Project?, + content: String?, + fileSmallName: String, + listener: NotificationListener? + ) { + val title = message("error.clang-format.failed", fileSmallName) + val notification = Notification(ClangFormatService.GROUP_ID, title, content!!, NotificationType.ERROR) + if (listener != null) { + notification.setListener(listener) + } + Notifications.Bus.notify(notification, project) + val oldNotification = errorNotification.getAndSet(notification) + oldNotification?.expire() + } + + private fun clearLastNotification() { + val oldNotification = errorNotification.getAndSet(null) + oldNotification?.expire() + } + + @get:Throws(ClangFormatError::class) + override val clangFormatPath: String + get() { + val path = findClangFormatPath() + if (path == null || !path.canExecute()) { + throw ClangFormatError("Cannot find clang-format") + } + return path.absolutePath + } + + private fun findClangFormatPath(): File? { + return PathEnvironmentVariableUtil.findExecutableInPathOnAnyOS("clang-format") + } + + private fun getClangFormatVirtualPath(): VirtualFile? { + val clangFormatPath = findClangFormatPath() + if (clangFormatPath != null) { + return VfsUtil.findFileByIoFile(clangFormatPath, true) + } + return null + } + + override fun getRawFormatStyle(psiFile: PsiFile): Map { + // save changed documents + saveUnchangedClangFormatFiles() + val result = CachedValuesManager.getCachedValue(psiFile, CLANG_STYLE, FormatStyleProvider(this, psiFile)) + if (result is ClangFormatError) { + throw result + } + return result as Map + } + + override fun makeDependencyTracker(file: PsiFile): ModificationTracker? { + val virtualFile = getVirtualFile(file) + ?: return ModificationTracker.NEVER_CHANGED + val oldFiles = getClangFormatFiles(virtualFile) + val oldStamps = oldFiles.stream() + .mapToLong { it: VirtualFile -> it.modificationStamp } + .toArray() + val documentStamp = oldFiles.stream() + .mapToLong { it: VirtualFile -> findDocumentStamp(it) } + .toArray() + return ModificationTracker { + val newFiles = getClangFormatFiles(virtualFile) + if (newFiles.size != oldFiles.size) { + // added or removed file + return@ModificationTracker -1 + } + for (i in newFiles.indices) { + if (oldFiles[i] != newFiles[i]) { + return@ModificationTracker -1 + } + if (oldStamps[i] != newFiles[i].modificationStamp) { + return@ModificationTracker -1 + } + if (documentStamp[i] != findDocumentStamp(newFiles[i])) { + return@ModificationTracker -1 + } + } + 1 + } + } + + private fun findDocumentStamp(file: VirtualFile): Long { + val cachedDocument = FileDocumentManager.getInstance().getCachedDocument(file) + return cachedDocument?.modificationStamp ?: -1 + } + + override fun getStyleFile(virtualFile: VirtualFile): VirtualFile? { + val formatFiles = getClangFormatFiles(virtualFile) + if (formatFiles.isNotEmpty()) { + return formatFiles[0] + } + return null + } + + override fun mayBeFormatted(file: PsiFile): Boolean { + val virtualFile = file.originalFile.virtualFile + if (ClangFormatCommons.isUnconditionallyNotSupported(virtualFile)) { + // invalid virtual file + return false + } + if (getStyleFile(virtualFile) == null) { + // no ".clang-format" file found. + // this is not a real issue for clang-format, but when no format is provided + // the editor shouldn't modify the appearance with llvm's default settings + file.putUserData(WAS_CLANG_FORMAT_SUPPORTED, null) + return false + } + val formatStyle = try { + getRawFormatStyle(file) + } + catch (e: ClangExitCodeError) { + // the configuration is (maybe temporary) broken. We reuse the answer of last invocation until the configuration is fixed + return java.lang.Boolean.TRUE == file.getUserData(WAS_CLANG_FORMAT_SUPPORTED) + } + catch (e: ClangFormatError) { + // the configuration is broken or the file language is not supported in ".clang-format" + file.putUserData(WAS_CLANG_FORMAT_SUPPORTED, null) + return false + } + val language = formatStyle["Language"] + val languageStr = language?.toString()?.trim { it <= ' ' } ?: "" + if (languageStr == "Cpp") { + // for clang, Cpp is a fallback for any file. + // we must ensure that the file is really c++ + if (!ClangFormatCommons.isCppFile(file)) { + file.putUserData(WAS_CLANG_FORMAT_SUPPORTED, null) + return false + } + } + file.putUserData(WAS_CLANG_FORMAT_SUPPORTED, true) + return true + } + + private fun saveUnchangedClangFormatFiles() { + // save changed documents + val unsavedClangFormats = getUnsavedClangFormats() + if (unsavedClangFormats.isNotEmpty()) { + ApplicationManager.getApplication().invokeLater { + WriteAction.run { + for (document in unsavedClangFormats) { + FileDocumentManager.getInstance().saveDocument(document) + } + } + } + } + } + + override fun dispose() {} + + @Throws(ExecutionException::class) + private fun executeClangFormat(project: Project, content: ByteArray, filename: String): ClangFormatResponse { + val commandLine = createCompileCommand(clangFormatPath) + commandLine.addParameter("-output-replacements-xml") + commandLine.addParameter("-assume-filename=$filename") + val output = executeProgram(content, commandLine) + if (output.exitCode != 0) { + LOG.warn(commandLine.exePath + " exited with code " + output.exitCode) + throw getException(project, commandLine, output) + } + if (output.stdout.isEmpty()) { + // no replacements + return ClangFormatResponse() + } + return ClangFormatResponse.unmarshal(output.stdout) + } + + @Throws(ClangExitCodeError::class) + private fun getException(project: Project, commandLine: GeneralCommandLine, output: ProcessOutput): ClangFormatError { + var stderr = output.stderr + if (stderr.startsWith("Configuration file(s) do(es) not support")) { + return ClangMissingLanguageException(stderr.trim { it <= ' ' }) + } + val matcher = CLANG_ERROR_PATTERN.matcher(stderr) + if (matcher.find()) { + try { + val fileName = File(matcher.group("FileName").replace('\\', '/')) + val lineNumber = matcher.group("LineNumber").toInt() + val column = matcher.group("Column").toInt() + val type = matcher.group("Type") + val message = matcher.group("Message") + if (type == "error") { + val description = """ + ${fileName.name}:$lineNumber:$column: $message + ${stderr.substring(matcher.group(0).length).trim { it <= ' ' }} + """.trimIndent() + return ClangExitCodeError( + description, + FileNavigatable(project, FilePosition(fileName, lineNumber - 1, column - 1)) + ) + } + } + catch (ignored: NumberFormatException) { + // in case of overflow + } + } + if (stderr.trim { it <= ' ' }.isEmpty()) { + // nothing on stderr, we use stdout instead + stderr = output.stdout + } + val description = """Exit code ${output.exitCode} from ${commandLine.commandLineString} +$stderr""" + return ClangFormatError(description) + } + + @RequiresEdt + private fun applyReplacementsWithCommand( + project: Project, + content: ByteArray, + document: Document, + replacements: ClangFormatResponse + ) { + assert(replacements.replacements != null) + CommandProcessor.getInstance().executeCommand(project, { + val executeInBulk = + document.isInBulkUpdate || replacements.replacements!!.size > BULK_REPLACE_OPTIMIZATION_CRITERIA + DocumentUtil.executeInBulk(document, executeInBulk) { + applyAllReplacements( + content, + document, + replacements + ) + } + }, message("error.clang-format.command.name"), null, document) + } + + /** + * This procedure is a little tricky as the offsets uses utf-8 encoding for offsets + */ + private fun applyAllReplacements(content: ByteArray, document: Document, replacements: ClangFormatResponse) { + ApplicationManager.getApplication().assertWriteAccessAllowed() + val converter = OffsetConverter(content) + var accumulator = 0 + for (replacement in replacements.replacements!!) { + val startOffset = converter.toUtf16(replacement.offset) + val endOffset = converter.toUtf16(replacement.offset + replacement.length) + val oldStringLengthUtf16 = endOffset - startOffset + document.replaceString(accumulator + startOffset, accumulator + endOffset, replacement.value) + accumulator += replacement.value.length - oldStringLengthUtf16 + } + } + + private fun ensureModifiable(project: Project, file: VirtualFile, document: Document): Boolean { + if (FileDocumentManager.getInstance().requestWriting(document, project)) { + return true + } + Messages.showMessageDialog( + project, CoreBundle.message("cannot.modify.a.read.only.file", file.name), + CodeInsightBundle.message("error.dialog.readonly.file.title"), + Messages.getErrorIcon() + ) + return false + } + + private fun dropCaches() { + synchronized(cache) { cache.clear() } + } + + @RequiresReadLock + private fun getClangFormatFiles(file: VirtualFile): List { + synchronized(cache) { + return cache.computeIfAbsent(file) { inFile: VirtualFile? -> + val files: MutableList = ArrayList() + var it = inFile + while (it != null) { + val child = it.findChild(".clang-format") + if (child != null) { + // read files in + files.add(child) + } + val childAlt = it.findChild("_clang-format") + if (childAlt != null) { + // read files in + files.add(childAlt) + } + it = it.parent + } + files + } + } + } + + private class FormatStyleProvider(private val service: ClangFormatServiceImpl, private val psiFile: PsiFile) : CachedValueProvider { + override fun compute(): CachedValueProvider.Result { + val dependencies: MutableList = ArrayList() + val result = computeFormat(dependencies) + return CachedValueProvider.Result.create(result, *dependencies.toTypedArray()) + } + + private fun computeFormat(dependencies: MutableList): Any { + dependencies.add(service.makeDependencyTracker(psiFile)) + val virtualFile = getVirtualFile(psiFile) + if (virtualFile == null) { + LOG.warn("Missing filename for $psiFile") + throw ClangFormatError("Cannot get clang-format configuration") + } + val clangFormat = service.getClangFormatVirtualPath() + ?: return ClangFormatNotFound(message("error.clang-format.error.not-found")) + dependencies.add(clangFormat) + return try { + val commandLine = createCompileCommand(clangFormat.path) + commandLine.addParameter("--dump-config") + commandLine.addParameter("-assume-filename=" + getFileName(virtualFile)) + val programOutput = executeProgram(null, commandLine) + if (programOutput.exitCode != 0) { + return service.getException(psiFile.project, commandLine, programOutput) + } + try { + val result = Yaml().load(programOutput.stdout) + if (result is Map<*, *>) { + return result + } + } + catch (ignored: YAMLException) { + } + ClangFormatError(message("error.clang-format.error.dump.not.yaml", programOutput.stdout)) + } + catch (e: ExecutionException) { + LOG.warn("Cannot dump clang-format configuration", e) + ClangFormatError("Cannot get clang-format configuration", e) + } + } + } + + private inner class MyApplicationListener : ApplicationListener { + override fun afterWriteActionFinished(action: Any) { + for (cancellation in afterWriteActionFinished) { + cancellation.run() + } + } + } + + // drop caches when a file is created or deleted + private inner class ClangFormatCacheManagment : AsyncFileListener { + override fun prepareChange(events: List): ChangeApplier? { + if (isThereAnyChangeInClangFormat(events)) { + return object : ChangeApplier { + override fun afterVfsChange() { + dropCaches() + } + } + } + return null + } + + private fun isThereAnyChangeInClangFormat(events: List): Boolean { + for (event in events) { + if (event is VFileCreateEvent) { + if (ClangFormatCommons.isClangFormatFile(event.childName)) { + return true + } + } + else if (event is VFileCopyEvent) { + val copyEvent = event + if (ClangFormatCommons.isClangFormatFile(copyEvent.file.name) || + ClangFormatCommons.isClangFormatFile(copyEvent.newChildName) + ) { + return true + } + } + else if (event is VFileDeleteEvent) { + if (ClangFormatCommons.isClangFormatFile(event.file.name)) { + return true + } + } + else if (event is VFileMoveEvent) { + if (ClangFormatCommons.isClangFormatFile(event.file.name)) { + return true + } + } + else if (event is VFilePropertyChangeEvent) { + val propertyChangeEvent = event + when (propertyChangeEvent.propertyName) { + VirtualFile.PROP_NAME -> if (ClangFormatCommons.isClangFormatFile(propertyChangeEvent.oldValue.toString()) || + ClangFormatCommons.isClangFormatFile(propertyChangeEvent.newValue.toString()) + ) { + return true + } + + VirtualFile.PROP_ENCODING, VirtualFile.PROP_SYMLINK_TARGET -> if (ClangFormatCommons.isClangFormatFile( + propertyChangeEvent.file.name + ) + ) { + return true + } + } + } + } + return false + } + } + + companion object { + private const val TIMEOUT = 10000 + private val CLANG_STYLE = Key.create>("CLANG_STYLE") + private val CLANG_ERROR_PATTERN = Pattern.compile( + "(?(?:[a-zA-Z]:|/)[^<>|?*:\\t]+):(?[\\d]+):(?[\\d]+)\\s*:\\s*(?\\w+):\\s*(?.*)" + ) + + private fun getFileName(virtualFile: VirtualFile): String { + var it = virtualFile + if (it is LightVirtualFile) { + it = it.originalFile + } + if (it.isInLocalFileSystem) { + return it.path + } + return virtualFile.name + } + + private fun getVirtualFile(file: PsiFile): VirtualFile? { + var virtualFile = file.originalFile.virtualFile + if (virtualFile is LightVirtualFile) { + virtualFile = virtualFile.originalFile + } + return if (virtualFile != null && virtualFile.isInLocalFileSystem) { + virtualFile + } + else null + } + + private fun createCompileCommand(clangFormatPath: String): GeneralCommandLine { + val commandLine = GeneralCommandLine() + if (SystemInfo.isWindows && isWinShellScript(clangFormatPath)) { + commandLine.exePath = "cmd.exe" + commandLine.addParameter("/c") + commandLine.addParameter(clangFormatPath) + } + else { + commandLine.exePath = clangFormatPath + } + commandLine.addParameter("--fno-color-diagnostics") + return commandLine + } + + @Throws(ExecutionException::class) + private fun executeProgram(content: ByteArray?, commandLine: GeneralCommandLine): ProcessOutput { + val handler = CapturingProcessHandler(commandLine) + + // write and close output stream on pooled thread + var writerFuture: Future<*>? = null + if (content != null) { + writerFuture = ApplicationManager.getApplication().executeOnPooledThread { + try { + handler.processInput.use { out -> out.write(content) } + } + catch (ignored: IOException) { + } + } + } + val output: ProcessOutput + try { + output = ProgressIndicatorUtils.awaitWithCheckCanceled( + ApplicationManager.getApplication().executeOnPooledThread { + handler.runProcess( + TIMEOUT, true + ) + }) + if (writerFuture != null) { + ProgressIndicatorUtils.awaitWithCheckCanceled(writerFuture) + } + } + finally { + handler.destroyProcess() + } + return output + } + + private fun isWinShellScript(command: @NonNls String?): Boolean { + return endsWithIgnoreCase(command, ".cmd") || endsWithIgnoreCase(command, ".bat") + } + + private fun endsWithIgnoreCase(str: String?, suffix: String): Boolean { + return str!!.regionMatches(str.length - suffix.length, suffix, 0, suffix.length, ignoreCase = true) + } + } +} diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/services/MyApplicationService.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/services/MyApplicationService.kt deleted file mode 100644 index e4fa42c..0000000 --- a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/services/MyApplicationService.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.github.aarcangeli.ideaclangformat.services - -import com.github.aarcangeli.ideaclangformat.MyBundle - -class MyApplicationService { - - init { - println(MyBundle.message("applicationService")) - } -} diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/services/MyProjectService.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/services/MyProjectService.kt deleted file mode 100644 index 81926e3..0000000 --- a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/services/MyProjectService.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.github.aarcangeli.ideaclangformat.services - -import com.github.aarcangeli.ideaclangformat.MyBundle -import com.intellij.openapi.project.Project - -class MyProjectService(project: Project) { - - init { - println(MyBundle.message("projectService", project.name)) - } - - /** - * Chosen by fair dice roll, guaranteed to be random. - */ - fun getRandomNumber() = 4 -} diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/ui/ClangFormatConfigurable.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/ui/ClangFormatConfigurable.kt new file mode 100644 index 0000000..b079d7e --- /dev/null +++ b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/ui/ClangFormatConfigurable.kt @@ -0,0 +1,50 @@ +package com.github.aarcangeli.ideaclangformat.ui + +import com.github.aarcangeli.ideaclangformat.MyBundle +import com.github.aarcangeli.ideaclangformat.ClangFormatSettings +import com.intellij.application.options.GeneralCodeStyleOptionsProvider +import com.intellij.psi.codeStyle.CodeStyleSettings +import com.intellij.psi.codeStyle.CodeStyleSettingsProvider +import com.intellij.psi.codeStyle.CustomCodeStyleSettings +import com.intellij.ui.dsl.builder.panel +import javax.swing.JCheckBox +import javax.swing.JComponent + +class ClangFormatConfigurable : CodeStyleSettingsProvider(), GeneralCodeStyleOptionsProvider { + private lateinit var myEnabled: JCheckBox + + override fun createComponent(): JComponent { + return panel { + group(MyBundle.message("clang-format.title")) { + row { + myEnabled = checkBox(MyBundle.message("clang-format.enable")) + .comment(MyBundle.message("clang-format.comment")) + .component + } + } + } + } + + override fun isModified(settings: CodeStyleSettings): Boolean { + return myEnabled.isSelected != settings.getCustomSettings(ClangFormatSettings::class.java).ENABLED + } + + override fun apply(settings: CodeStyleSettings) { + settings.getCustomSettings(ClangFormatSettings::class.java).ENABLED = myEnabled.isSelected + } + + override fun reset(settings: CodeStyleSettings) { + myEnabled.isSelected = settings.getCustomSettings(ClangFormatSettings::class.java).ENABLED + } + + override fun isModified(): Boolean = false + + override fun apply() { + } + + override fun hasSettingsPage(): Boolean = false + + override fun createCustomSettings(settings: CodeStyleSettings): CustomCodeStyleSettings { + return ClangFormatSettings(settings) + } +} diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/utils/ClangFormatCommons.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/utils/ClangFormatCommons.kt new file mode 100644 index 0000000..21b53f5 --- /dev/null +++ b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/utils/ClangFormatCommons.kt @@ -0,0 +1,44 @@ +package com.github.aarcangeli.ideaclangformat.utils + +import com.github.aarcangeli.ideaclangformat.services.ClangFormatService +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.Document +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiFile + +object ClangFormatCommons { + fun isCppFile(file: PsiFile): Boolean { + // TODO: add more extensions + val filename = file.name.lowercase() + return filename.endsWith(".cpp") || filename.endsWith(".h") || filename.endsWith(".h") + } + + @JvmStatic + fun isUnconditionallyNotSupported(file: PsiFile): Boolean { + val virtualFile = file.originalFile.virtualFile + return isUnconditionallyNotSupported(virtualFile) + } + + fun isUnconditionallyNotSupported(virtualFile: VirtualFile?): Boolean { + // invalid virtual file + return virtualFile == null || + !virtualFile.isValid || + !virtualFile.isInLocalFileSystem || + isClangFormatFile(virtualFile.name) + } + + fun getVirtualFileFor(project: Project, document: Document): VirtualFile? { + val psiFile = PsiDocumentManager.getInstance(project).getPsiFile(document) + if (psiFile != null && service().mayBeFormatted(psiFile)) { + return psiFile.originalFile.virtualFile + } + return null + } + + fun isClangFormatFile(filename: String): Boolean { + return filename.equals(".clang-format", ignoreCase = true) || + filename.equals("_clang-format", ignoreCase = true) + } +} diff --git a/src/main/kotlin/com/github/aarcangeli/ideaclangformat/utils/OffsetConverter.kt b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/utils/OffsetConverter.kt new file mode 100644 index 0000000..022acbd --- /dev/null +++ b/src/main/kotlin/com/github/aarcangeli/ideaclangformat/utils/OffsetConverter.kt @@ -0,0 +1,45 @@ +package com.github.aarcangeli.ideaclangformat.utils + +/** + * Converts offsets from utf-8 to utf16 + */ +class OffsetConverter(private val content: ByteArray) { + private var cursorUtf8 = 0 + private var cursorUtf16 = 0 + + fun toUtf16(offsetUtf8: Int): Int { + assert(offsetUtf8 >= cursorUtf8) + var cursorUtf8 = cursorUtf8 + var cursorUtf16 = cursorUtf16 + while (offsetUtf8 > cursorUtf8) { + var sizeUtf8: Int + var sizeUtf16: Int + val ch = content[cursorUtf8] + if (ch >= 0) { + // 1 byte code point + sizeUtf8 = 1 + sizeUtf16 = 1 + } + else if (ch.toInt() and 224 == 192) { + // 2 bytes code point + sizeUtf8 = 2 + sizeUtf16 = 1 + } + else if (ch.toInt() and 240 == 224) { + // 3 bytes code point + sizeUtf8 = 3 + sizeUtf16 = 1 + } + else { + // 4 bytes code point + sizeUtf8 = 4 + sizeUtf16 = 2 + } + cursorUtf16 += sizeUtf16 + cursorUtf8 += sizeUtf8 + } + this.cursorUtf8 = cursorUtf8 + this.cursorUtf16 = cursorUtf16 + return cursorUtf16 + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 95cbf2d..64019c3 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -1,19 +1,44 @@ - com.github.aarcangeli.ideaclangformat - Clang-Format & Language - aarcangeli - - com.intellij.modules.platform - - - - - - - - - + com.github.aarcangeli.ideaclangformat + Clang-Format & Language + Alessandro Arcangeli + + com.intellij.modules.platform + org.jetbrains.plugins.yaml + + messages.ClangFormatBundle + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/messages/ClangFormatBundle.properties b/src/main/resources/messages/ClangFormatBundle.properties new file mode 100644 index 0000000..33f07d9 --- /dev/null +++ b/src/main/resources/messages/ClangFormatBundle.properties @@ -0,0 +1,14 @@ +clang-format.title=Clang-Format +clang-format.enable=Enable Clang-Format support +clang-format.comment=Clang-Format may override the IDE code style settings +clang-format.disable=Disable for Project +clang-format.disabled.notification=ClangFormat disabled + +error.clang-format.name=Clang-Format +error.clang-format.formatting=Formatting with clang-format +error.clang-format.command.name=Reformat Code +error.clang-format.failed=Error while formatting {0} +error.clang-format.error.not-found=Cannot find clang-format executable +error.clang-format.error.dump.not.yaml=Invalid yaml returned by clang-format\n{0} +error.clang-format.status.hint=From clang-format +error.clang-format.status.open=Open Clang-Format Configuration diff --git a/src/main/resources/messages/MyBundle.properties b/src/main/resources/messages/MyBundle.properties deleted file mode 100644 index 78dbb24..0000000 --- a/src/main/resources/messages/MyBundle.properties +++ /dev/null @@ -1,3 +0,0 @@ -name=My Plugin -applicationService=Application service -projectService=Project service: {0} diff --git a/src/test/kotlin/com/github/aarcangeli/ideaclangformat/MyPluginTest.kt b/src/test/kotlin/com/github/aarcangeli/ideaclangformat/MyPluginTest.kt index b013343..117c928 100644 --- a/src/test/kotlin/com/github/aarcangeli/ideaclangformat/MyPluginTest.kt +++ b/src/test/kotlin/com/github/aarcangeli/ideaclangformat/MyPluginTest.kt @@ -1,39 +1,31 @@ package com.github.aarcangeli.ideaclangformat import com.intellij.ide.highlighter.XmlFileType -import com.intellij.openapi.components.service import com.intellij.psi.xml.XmlFile import com.intellij.testFramework.TestDataPath import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.PsiErrorElementUtil -import com.github.aarcangeli.ideaclangformat.services.MyProjectService @TestDataPath("\$CONTENT_ROOT/src/test/testData") class MyPluginTest : BasePlatformTestCase() { - fun testXMLFile() { - val psiFile = myFixture.configureByText(XmlFileType.INSTANCE, "bar") - val xmlFile = assertInstanceOf(psiFile, XmlFile::class.java) + fun testXMLFile() { + val psiFile = myFixture.configureByText(XmlFileType.INSTANCE, "bar") + val xmlFile = assertInstanceOf(psiFile, XmlFile::class.java) - assertFalse(PsiErrorElementUtil.hasErrors(project, xmlFile.virtualFile)) + assertFalse(PsiErrorElementUtil.hasErrors(project, xmlFile.virtualFile)) - assertNotNull(xmlFile.rootTag) + assertNotNull(xmlFile.rootTag) - xmlFile.rootTag?.let { - assertEquals("foo", it.name) - assertEquals("bar", it.value.text) - } + xmlFile.rootTag?.let { + assertEquals("foo", it.name) + assertEquals("bar", it.value.text) } + } - fun testRename() { - myFixture.testRename("foo.xml", "foo_after.xml", "a2") - } - - fun testProjectService() { - val projectService = project.service() - - assertEquals(4, projectService.getRandomNumber()) - } + fun testRename() { + myFixture.testRename("foo.xml", "foo_after.xml", "a2") + } - override fun getTestDataPath() = "src/test/testData/rename" + override fun getTestDataPath() = "src/test/testData/rename" }