diff --git a/python/pluginResources/messages/PyBundle.properties b/python/pluginResources/messages/PyBundle.properties index 02e13015f1b0..11171288d8ea 100644 --- a/python/pluginResources/messages/PyBundle.properties +++ b/python/pluginResources/messages/PyBundle.properties @@ -1373,4 +1373,50 @@ python.sdk.configurable.name=Python SDK inlay.hints.usages.text={0,choice, 0#no usages|1#1 usage|2#{0,number}'{1,choice, |1#+}' usages} inlay.hints.usages.with.dynamic.text={0,choice, 0#no usages|1#1 usage|2#{0,number} usages} ({1,choice, 1#1 dynamic|2#{1,number} dynamic}) - +# Black formatter +black.configurable.name=Black +black.formatting.service.name=Black formatter +black.not.installed.error=Black formatter package is not installed on the current interpreter +black.no.project.interpreter.error=No Python interpreter configured for the project +black.remote.sdk.error=Package mode is not available for remote SDKs. Please use Binary mode. +black.install.button.label=Install Black +black.use.section.label=Use Black formatter: +black.enable.black.checkbox.label=On code reformat +black.enable.action.on.save.label=On save +black.advanced.settings.panel.title=Advanced settings +black.empty.output.error=Black formatter returned empty output +black.empty.path.to.executable.exception.text=Black executable not found or empty +black.action.on.save.name=Run Black +black.action.on.save.package.info=Using Black package v{0} +black.action.on.save.executable.info=Using Black executable v{0} +black.action.on.save.executable.path.not.specified=Black executable path not specified +black.formatting.with.black=Formatting with Black +black.no.lines.changed=Black: No lines changed. Content is already properly formatted. +black.formatted.n.lines=Black: Formatted {0} {1, choice, 0#lines|1#line} +black.installing.modal.title=Installing Black formatter +black.installation.error.title=Failed to install Black formatter +black.failed.to.format.on.save.error.label=Black: Failed to format file {0} +black.processing.file.name=Black: Processing file {0} +black.exception.error.message=Black formatter error +black.file.ignored.notification.label=Black: File ignored +black.file.ignored.notification.message=File {0} ignored according to --exclude or --force-exclude rules +black.advertising.service.notification.title=Black formatter integration +black.advertising.service.found.in.packages=Black formatter package is detected on the project interpreter. Try Black formatter integration! +black.advertising.service.found.in.PATH=Black formatter executable is detected in {0, choice, 0#%PATH%|1#$PATH}. Try Black formatter integration! +black.advertising.service.configure.button.label=Configure +black.advertising.service.dont.show.again.label=Don't show again +black.select.path.to.executable=Select path to Black formatter executable +black.execution.mode.label=Execution mode: +black.executable.label=Black executable: +black.executable.auto.detected.path=Auto-detected: {0} +black.executable.not.found=Unable to auto-detect Black executable in {0, choice, 0#%PATH%|1#$PATH} +black.execution.mode.package=Package +black.execution.mode.binary=Binary +black.execution.mode.tooltip.text=Use either executable or package installed on the project SDK +black.cli.args.text.field.label=Settings: +black.cli.args.comment=List command line arguments separated by whitespace. Learn more +black.cli.args.validation.notification=No value passed for parameter {0} +black.sdk.not.configured.error=No project SDK configured for the project {0} +black.sdk.not.configured.error.title=SDK not configured +black.remote.sdk.exception.text=Black formatter invocation in Package mode is not allowed on remote SDKs +black.sdk.selection.combobox.label=Select Python SDK: diff --git a/python/setup-test-environment/build.gradle b/python/setup-test-environment/build.gradle index 314c5ab4feb2..725f793c13b9 100644 --- a/python/setup-test-environment/build.gradle +++ b/python/setup-test-environment/build.gradle @@ -154,8 +154,8 @@ envs { createPython("python3.11", python311version, - [], - "python3.11", + ["black == 23.1.0"], + "python3.11\nblack", true) createPython("python3.12", diff --git a/python/src/META-INF/python-core-common.xml b/python/src/META-INF/python-core-common.xml index f87112beea82..4ee097090e31 100644 --- a/python/src/META-INF/python-core-common.xml +++ b/python/src/META-INF/python-core-common.xml @@ -512,6 +512,29 @@ + + + + + + + + + + + + + diff --git a/python/src/com/jetbrains/python/black/BlackFormatterActionOnSave.kt b/python/src/com/jetbrains/python/black/BlackFormatterActionOnSave.kt new file mode 100644 index 000000000000..12c1b37c5438 --- /dev/null +++ b/python/src/com/jetbrains/python/black/BlackFormatterActionOnSave.kt @@ -0,0 +1,127 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.black + +import com.intellij.ide.actionsOnSave.impl.ActionsOnSaveFileDocumentManagerListener.ActionOnSave +import com.intellij.notification.Notification +import com.intellij.notification.NotificationType +import com.intellij.notification.Notifications +import com.intellij.openapi.application.EDT +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.editor.Document +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.progress.progressStep +import com.intellij.openapi.progress.runBlockingModal +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.util.registry.Registry +import com.intellij.openapi.vfs.VirtualFile +import com.jetbrains.python.PyBundle +import com.jetbrains.python.black.configuration.BlackFormatterConfiguration +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.annotations.Nls +import kotlin.coroutines.cancellation.CancellationException + +class BlackFormatterActionOnSave : ActionOnSave() { + + companion object { + val LOG = thisLogger() + } + + override fun isEnabledForProject(project: Project): Boolean = Registry.`is`("black.formatter.support.enabled") + + override fun processDocuments(project: Project, documents: Array) { + val blackConfig = BlackFormatterConfiguration.getBlackConfiguration(project) + if (!blackConfig.enabledOnSave) return + + val sdk = blackConfig.getSdk(project) + if (sdk == null) { + LOG.warn(PyBundle.message("black.sdk.not.configured.error", project.name)) + return + } + + formatMultipleDocuments(project, sdk, blackConfig, documents.filterNotNull().toList()) + } + + private fun formatMultipleDocuments(project: Project, + sdk: Sdk, + blackConfig: BlackFormatterConfiguration, + documents: List) { + val manager = FileDocumentManager.getInstance() + + val executor = try { + BlackFormatterExecutor(project, sdk, blackConfig) + } + catch (e: Exception) { + reportFailure(PyBundle.message("black.exception.error.message"), e.localizedMessage, project) + return + } + + val descriptors = documents + .mapNotNull { document -> manager.getFile(document)?.let { document to it } } + .filter { BlackFormatterUtil.isFileApplicable(it.second) } + .map { Descriptor(it.first, it.second) } + + runCatching { + runBlockingModal(project, PyBundle.message("black.formatting.with.black")) { + var processedFiles = 0L + + descriptors.forEach { descriptor -> + processedFiles++ + progressStep(processedFiles / descriptors.size.toDouble(), + PyBundle.message("black.processing.file.name", descriptor.virtualFile.name)) { + val request = BlackFormattingRequest.File(descriptor.document.text, descriptor.virtualFile) + val response = executor.getBlackFormattingResponse(request, BlackFormatterExecutor.BLACK_DEFAULT_TIMEOUT) + applyChanges(project, descriptor, response) + } + } + } + }.onFailure { exception -> + when (exception) { + is CancellationException -> { /* ignore */ } + else -> { + LOG.warn(exception) + reportFailure(PyBundle.message("black.exception.error.message"), exception.localizedMessage, project) + } + } + } + } + + private suspend fun applyChanges(project: Project, descriptor: Descriptor, response: BlackFormattingResponse) { + when (response) { + is BlackFormattingResponse.Success -> { + withContext(Dispatchers.EDT) { + WriteCommandAction + .runWriteCommandAction(project, + null, + null, + { descriptor.document.setText(response.formattedText) }) + } + } + is BlackFormattingResponse.Failure -> { + reportFailure(response.title, response.description, project) + } + is BlackFormattingResponse.Ignored -> { + reportIgnored(response.title, response.description, project) + } + } + } + + private fun reportFailure(@Nls title: String, @Nls message: String, project: Project) { + Notifications.Bus.notify( + Notification(BlackFormattingService.NOTIFICATION_GROUP_ID, + title, + message, NotificationType.ERROR), project) + } + + // [TODO] add `do not show again` option + private fun reportIgnored(@Nls title: String, @Nls message: String, project: Project) { + Notifications.Bus.notify( + Notification(BlackFormattingService.NOTIFICATION_GROUP_ID, + title, + message, NotificationType.INFORMATION), project) + } + + private data class Descriptor(val document: Document, val virtualFile: VirtualFile) +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/black/BlackFormatterAdvertiserService.kt b/python/src/com/jetbrains/python/black/BlackFormatterAdvertiserService.kt new file mode 100644 index 000000000000..53f90399f2b4 --- /dev/null +++ b/python/src/com/jetbrains/python/black/BlackFormatterAdvertiserService.kt @@ -0,0 +1,73 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.black + +import com.intellij.ide.util.PropertiesComponent +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.SystemInfo +import com.jetbrains.python.PyBundle +import com.jetbrains.python.black.configuration.BlackFormatterConfigurable +import com.jetbrains.python.black.configuration.BlackFormatterConfiguration +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.annotations.Nls +import org.jetbrains.annotations.NonNls + +@ApiStatus.Internal +@Service(Service.Level.APP) +class BlackFormatterAdvertiserService private constructor() { + + companion object { + @NonNls + const val SHOW_BLACK_FORMATTER_SUPPORT_NOTIFICATION = "black.formatter.show.support.notification" + + fun getInstance(): BlackFormatterAdvertiserService = + ApplicationManager.getApplication().getService(BlackFormatterAdvertiserService::class.java) + } + + private var alreadyShown: Boolean = false + + fun suggestBlack(project: Project, blackFormatterConfiguration: BlackFormatterConfiguration) { + if (blackFormatterConfiguration == BlackFormatterConfiguration() && !alreadyShown) { + if (BlackFormatterUtil.isBlackFormatterInstalledOnProjectSdk(blackFormatterConfiguration.getSdk(project))) { + showBlackFormatterSupportNotification(project, + PyBundle.message("black.advertising.service.found.in.packages")) + } + else if (BlackFormatterUtil.isBlackExecutableDetected()) { + showBlackFormatterSupportNotification(project, + PyBundle.message("black.advertising.service.found.in.PATH", + if (SystemInfo.isWindows) 0 else 1)) + } + } + } + + @Synchronized + private fun showBlackFormatterSupportNotification(project: Project, @Nls message: String) { + val propertiesComponent = PropertiesComponent.getInstance() + if (!propertiesComponent.getBoolean(SHOW_BLACK_FORMATTER_SUPPORT_NOTIFICATION, true)) { + return + } + + val notification = NotificationGroupManager.getInstance().getNotificationGroup(BlackFormattingService.NOTIFICATION_GROUP_ID) + .createNotification(PyBundle.message("black.advertising.service.notification.title"), message, NotificationType.INFORMATION) + .setDisplayId("black.formatter") + .setSuggestionType(true) + .setImportantSuggestion(true) + .addAction(NotificationAction + .createSimpleExpiring(PyBundle.message("black.advertising.service.configure.button.label")) { + ShowSettingsUtil.getInstance().showSettingsDialog(project, BlackFormatterConfigurable::class.java) + }) + .addAction(NotificationAction + .createSimpleExpiring(PyBundle.message("black.advertising.service.dont.show.again.label")) { + propertiesComponent.setValue( + SHOW_BLACK_FORMATTER_SUPPORT_NOTIFICATION, + "false", "true") + }) + notification.notify(project) + alreadyShown = true + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/black/BlackFormatterExecutor.kt b/python/src/com/jetbrains/python/black/BlackFormatterExecutor.kt new file mode 100644 index 000000000000..5ba1f63f09cf --- /dev/null +++ b/python/src/com/jetbrains/python/black/BlackFormatterExecutor.kt @@ -0,0 +1,174 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.black + +import com.intellij.execution.process.* +import com.intellij.execution.target.* +import com.intellij.execution.target.local.LocalTargetEnvironment +import com.intellij.execution.target.local.LocalTargetEnvironmentRequest +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.fileTypes.FileTypeRegistry +import com.intellij.openapi.progress.util.ProgressIndicatorUtils +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.util.Version +import com.intellij.openapi.vfs.VirtualFile +import com.jetbrains.python.PyBundle +import com.jetbrains.python.black.configuration.BlackFormatterConfiguration +import com.jetbrains.python.pyi.PyiFileType +import com.jetbrains.python.run.PythonInterpreterTargetEnvironmentFactory +import com.jetbrains.python.sdk.InvalidSdkException +import com.jetbrains.python.sdk.configureBuilderToRunPythonOnTarget +import java.io.FileNotFoundException +import java.io.IOException +import java.nio.charset.Charset +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +class BlackFormatterExecutor(private val project: Project, + private val sdk: Sdk, + private val blackConfig: BlackFormatterConfiguration) { + + private var targetEnvironment: TargetEnvironment + private var targetEnvironmentRequest: TargetEnvironmentRequest + + companion object { + val BLACK_DEFAULT_TIMEOUT = 30000.milliseconds + private val LOG = thisLogger() + private val minimalStdinFilenameCompatibleVersion = Version(21, 4, 0) + } + + init { + when (blackConfig.executionMode) { + BlackFormatterConfiguration.ExecutionMode.BINARY -> { + targetEnvironmentRequest = LocalTargetEnvironmentRequest() + targetEnvironment = LocalTargetEnvironment(LocalTargetEnvironmentRequest()) + } + BlackFormatterConfiguration.ExecutionMode.PACKAGE -> { + if (!sdk.sdkType.isLocalSdk(sdk)) { + throw InvalidSdkException(PyBundle.message("black.remote.sdk.exception.text")) + } + val interpreter = PythonInterpreterTargetEnvironmentFactory.findPythonTargetInterpreter(sdk, project) + targetEnvironmentRequest = interpreter.targetEnvironmentRequest + targetEnvironment = targetEnvironmentRequest.prepareEnvironment(TargetProgressIndicator.EMPTY) + } + } + } + + + fun getBlackFormattingResponse(blackFormattingRequest: BlackFormattingRequest, + timeout: Duration): BlackFormattingResponse { + val targetCMD = buildTargetCommandLine(blackFormattingRequest, targetEnvironmentRequest) + val future = getFuture(targetCMD, targetEnvironment, blackFormattingRequest, timeout) + return ProgressIndicatorUtils.awaitWithCheckCanceled(future) + } + + + private fun buildTargetCommandLine(blackFormattingRequest: BlackFormattingRequest, + targetEnvRequest: TargetEnvironmentRequest): TargetedCommandLine { + val cmd = TargetedCommandLineBuilder(targetEnvRequest) + val cmdArgs = configToCmdArguments(blackFormattingRequest.virtualFile) + val cwd = blackFormattingRequest.virtualFile.parent.takeIf { it.isDirectory } + if (cwd != null) { + cmd.setWorkingDirectory(cwd.path) + } + + when (blackConfig.executionMode) { + BlackFormatterConfiguration.ExecutionMode.BINARY -> { + val blackExecutable = blackConfig.pathToExecutable + if (blackExecutable == null) { + throw FileNotFoundException(PyBundle.message("black.empty.path.to.executable.exception.text")) + } + cmd.setExePath(blackExecutable) + } + BlackFormatterConfiguration.ExecutionMode.PACKAGE -> { + sdk.configureBuilderToRunPythonOnTarget(cmd) + cmd.addParameters("-m", BlackFormatterUtil.PACKAGE_NAME) + } + } + + cmd.addParameters(cmdArgs) + cmd.addParameter("-") + return cmd.build() + } + + private fun configToCmdArguments(vFile: VirtualFile): List { + val cmd = mutableListOf() + val blackVersion = BlackFormatterVersionService.getVersion(project) + + if (FileTypeRegistry.getInstance().isFileOfType(vFile, PyiFileType.INSTANCE)) { + cmd.add("--pyi") + } + + if (blackConfig.cmdArguments.isNotEmpty()) { + cmd.addAll(blackConfig.cmdArguments.split(" ")) + } + + if (blackVersion >= minimalStdinFilenameCompatibleVersion) { + cmd.add("--stdin-filename") + cmd.add(vFile.path) + } + + return cmd + } + + private fun getFuture(targetCMD: TargetedCommandLine, + targetEnvironment: TargetEnvironment, + formattingRequest: BlackFormattingRequest, + timeout: Duration): CompletableFuture { + val vFile = formattingRequest.virtualFile + + val future = CompletableFuture() + .completeOnTimeout(BlackFormattingResponse.Failure(PyBundle.message("black.failed.to.format.on.save.error.label", + vFile.name), "Timeout exceeded", null), + timeout.inWholeMilliseconds, TimeUnit.MILLISECONDS) + + val process = targetEnvironment.createProcess(targetCMD) + + val processHandler = CapturingProcessHandler(process, BlackFormattingService.DEFAULT_CHARSET, + targetCMD.getCommandPresentation(targetEnvironment)) + + processHandler.addProcessListener(writeToStdinListener(formattingRequest.fragmentToFormat, vFile.charset)) + + processHandler.addProcessListener(object : CapturingProcessAdapter() { + override fun processTerminated(event: ProcessEvent) { + val exitCode = event.exitCode + if (exitCode == 0) { + if (output.stdout.isEmpty()) { + future.complete(BlackFormattingResponse.Ignored(PyBundle.message("black.file.ignored.notification.label"), + PyBundle.message("black.file.ignored.notification.message", vFile.name))) + } + else { + future.complete(BlackFormattingResponse.Success(output.stdout)) + } + } + else { + future.complete(BlackFormattingResponse.Failure( + PyBundle.message("black.failed.to.format.on.save.error.label", vFile.name), + output.stderr, exitCode)) + } + } + }) + processHandler.startNotify() + return future + } + + private fun writeToStdinListener(text: String, charset: Charset): ProcessListener { + return object : ProcessAdapter() { + override fun startNotified(event: ProcessEvent) { + try { + val processInput = event.processHandler.processInput + if (processInput == null) { + return + } + processInput.write(text.toByteArray(charset)) + processInput.close() + } + catch (e: IOException) { + LOG.error(e) + } + } + } + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/black/BlackFormatterUtil.kt b/python/src/com/jetbrains/python/black/BlackFormatterUtil.kt new file mode 100644 index 000000000000..5671422be7a0 --- /dev/null +++ b/python/src/com/jetbrains/python/black/BlackFormatterUtil.kt @@ -0,0 +1,65 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.black + +import com.intellij.execution.configurations.PathEnvironmentVariableUtil +import com.intellij.execution.target.readableFs.PathInfo +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.fileTypes.FileTypeRegistry +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.vfs.VirtualFile +import com.jetbrains.python.PyBundle +import com.jetbrains.python.PythonFileType +import com.jetbrains.python.packaging.PyPackage +import com.jetbrains.python.packaging.PyPackageManager +import com.jetbrains.python.pyi.PyiFileType +import com.jetbrains.python.sdk.add.target.ValidationRequest +import com.jetbrains.python.sdk.add.target.validateExecutableFile +import org.jetbrains.annotations.SystemDependent +import java.io.File + +class BlackFormatterUtil { + companion object { + val LOG = thisLogger() + + const val PACKAGE_NAME: String = "black" + + fun isFileApplicable(vFile: VirtualFile): Boolean { + return FileTypeRegistry.getInstance().isFileOfType(vFile, PythonFileType.INSTANCE) + || FileTypeRegistry.getInstance().isFileOfType(vFile, PyiFileType.INSTANCE) + } + + fun isBlackFormatterInstalledOnProjectSdk(sdk: Sdk?): Boolean { + val packageManager = sdk?.let { PyPackageManager.getInstance(it) } + return packageManager?.let { + it.packages?.any { pyPackage -> pyPackage.name == PACKAGE_NAME } + } ?: false + } + + fun getBlackFormatterPackageInfo(sdk: Sdk?): PyPackage? { + val packageManager = sdk?.let { PyPackageManager.getInstance(it) } + return packageManager?.let { + it.refreshAndGetPackages(false).firstOrNull { pyPackage -> pyPackage.name == PACKAGE_NAME } + } + } + + fun detectBlackExecutable(): File? { + val name = when { + SystemInfo.isWindows -> "black.exe" + else -> "black" + } + return PathEnvironmentVariableUtil.findInPath(name) + } + + fun isBlackExecutableDetected(): Boolean = detectBlackExecutable() != null + + fun validateBlackExecutable(path: @SystemDependent String?): ValidationInfo? { + return validateExecutableFile(ValidationRequest( + path = path, + fieldIsEmpty = PyBundle.message("black.executable.not.found", if (SystemInfo.isWindows) 0 else 1), + pathInfoProvider = PathInfo.localPathInfoProvider + )) + } + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/black/BlackFormatterVersionService.kt b/python/src/com/jetbrains/python/black/BlackFormatterVersionService.kt new file mode 100644 index 000000000000..570ef267d949 --- /dev/null +++ b/python/src/com/jetbrains/python/black/BlackFormatterVersionService.kt @@ -0,0 +1,105 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.black + +import com.intellij.execution.process.CapturingProcessHandler +import com.intellij.execution.target.TargetedCommandLineBuilder +import com.intellij.execution.target.local.LocalTargetEnvironment +import com.intellij.execution.target.local.LocalTargetEnvironmentRequest +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.progress.runBlockingMaybeCancellable +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Version +import com.intellij.util.VersionUtil +import com.jetbrains.python.black.configuration.BlackFormatterConfiguration +import com.jetbrains.python.packaging.PyPackage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.regex.Pattern + +@Service(Service.Level.PROJECT) +class BlackFormatterVersionService(private val project: Project) { + companion object { + + private val PATTERN = Pattern.compile("(?<=black, )(([\\d]+\\.[\\d]+\\.?[\\d]*).*)(?= \\(compiled: )") + + val UNKNOWN_VERSION = Version(0, 0, 0) + + private fun getInstance(project: Project): BlackFormatterVersionService = project.service() + + fun getVersion(project: Project): Version = getInstance(project).getVersion() + } + + private var version: Version + + private var configuration: BlackFormatterConfiguration + + init { + configuration = BlackFormatterConfiguration.getBlackConfiguration(project).copy() + version = getVersionFromSdkOrBinary() + } + + @Synchronized + private fun getVersion(): Version { + if (BlackFormatterConfiguration.getBlackConfiguration(project) != configuration) { + configuration = BlackFormatterConfiguration.getBlackConfiguration(project).copy() + version = getVersionFromSdkOrBinary() + } + return version + } + + private fun getVersionFromSdkOrBinary(): Version { + return when (configuration.executionMode) { + BlackFormatterConfiguration.ExecutionMode.BINARY -> { + configuration.pathToExecutable?.let { + getVersionForExecutable(it) + } ?: UNKNOWN_VERSION + } + BlackFormatterConfiguration.ExecutionMode.PACKAGE -> { + BlackFormatterUtil.getBlackFormatterPackageInfo(configuration.getSdk(project))?.let { + getVersionForPackage(it) + } ?: UNKNOWN_VERSION + } + } + } + + private fun getVersionForExecutable(pathToExecutable: String): Version { + val targetEnvRequest = LocalTargetEnvironmentRequest() + val targetEnvironment = LocalTargetEnvironment(LocalTargetEnvironmentRequest()) + + val commandLineBuilder = TargetedCommandLineBuilder(targetEnvRequest) + commandLineBuilder.setExePath(pathToExecutable) + commandLineBuilder.addParameters("--version") + + val targetCMD = commandLineBuilder.build() + + val process = targetEnvironment.createProcess(targetCMD) + + return runBlockingMaybeCancellable { + runCatching { + withContext(Dispatchers.IO) { + val processHandler = CapturingProcessHandler(process, targetCMD.charset, targetCMD.getCommandPresentation(targetEnvironment)) + val processOutput = processHandler.runProcess(5000, true).stdout + return@withContext VersionUtil.parseVersion(processOutput, PATTERN) ?: UNKNOWN_VERSION + } + }.getOrDefault(UNKNOWN_VERSION) + } + } + + private fun getVersionForPackage(pythonPackage: PyPackage): Version = + parseVersionString(pythonPackage.version) + + private fun parseVersionString(versionString: String): Version { + + val parts = versionString + .split(".", "b") + .mapNotNull(String::toIntOrNull) + .filter { it >= 0 } + + if (parts.size < 3) { + return UNKNOWN_VERSION + } + + return Version(parts[0], parts[1], parts[2]) + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/black/BlackFormattingRequest.kt b/python/src/com/jetbrains/python/black/BlackFormattingRequest.kt new file mode 100644 index 000000000000..22a440746c49 --- /dev/null +++ b/python/src/com/jetbrains/python/black/BlackFormattingRequest.kt @@ -0,0 +1,55 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.black + +import com.intellij.openapi.vfs.VirtualFile +import com.jetbrains.python.psi.PyIndentUtil + +sealed class BlackFormattingRequest { + abstract val fragmentToFormat: String + abstract val virtualFile: VirtualFile + + class Fragment(val fragment: String, override val virtualFile: VirtualFile) : BlackFormattingRequest() { + private val extractedIndent: String + private val whitespaceBefore: String + private val whitespaceAfter: String + private val endsWithNewLine: Boolean + + override val fragmentToFormat: String + + init { + val firstNotEmptyLine = fragment.lines().first { it.isNotBlank() } + + extractedIndent = PyIndentUtil.getLineIndent(firstNotEmptyLine) + whitespaceBefore = fragment.takeWhile { it.isWhitespace() } + whitespaceAfter = fragment.takeLastWhile { it.isWhitespace() } + endsWithNewLine = fragment.endsWith("\n") + + fragmentToFormat = PyIndentUtil.removeCommonIndent(fragment, false) + } + + fun postProcessResponse(response: String): String { + return buildString { + val lines = response.trimEnd().lines() + + if (!response.contains('\n')) { + append(extractedIndent) + append(response) + if (endsWithNewLine) { + append('\n') + } + return@buildString + } + + append(whitespaceBefore) + append(lines.first()) + for (line in lines.listIterator(1)) { + appendLine() + append(line.prependIndent(extractedIndent)) + } + append(whitespaceAfter) + } + } + } + + class File(override val fragmentToFormat: String, override val virtualFile: VirtualFile) : BlackFormattingRequest() +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/black/BlackFormattingResponse.kt b/python/src/com/jetbrains/python/black/BlackFormattingResponse.kt new file mode 100644 index 000000000000..7a4afc7312d3 --- /dev/null +++ b/python/src/com/jetbrains/python/black/BlackFormattingResponse.kt @@ -0,0 +1,29 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.black + +import com.intellij.openapi.util.NlsSafe +import org.jetbrains.annotations.Nls + +sealed class BlackFormattingResponse { + class Success(val formattedText: String) : BlackFormattingResponse() + + class Ignored(@Nls val title: String, + @NlsSafe val description: String) : BlackFormattingResponse() + + class Failure(@Nls val title: String, + @NlsSafe val description: String, + val exitCode: Int?) : BlackFormattingResponse() { + + fun getLoggingMessage(): String { + val stringBuilder = StringBuilder() + stringBuilder.append("${title}\n") + if (description.isNotEmpty()) { + stringBuilder.append("stderr: ${description}\n") + } + if (exitCode != null) { + stringBuilder.append("exit code: ${exitCode}\n") + } + return stringBuilder.toString() + } + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/black/BlackFormattingService.kt b/python/src/com/jetbrains/python/black/BlackFormattingService.kt new file mode 100644 index 000000000000..be4ae7d4eb4b --- /dev/null +++ b/python/src/com/jetbrains/python/black/BlackFormattingService.kt @@ -0,0 +1,169 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.black + +import com.intellij.codeInsight.actions.VcsFacade +import com.intellij.codeInsight.hint.HintManager +import com.intellij.codeInsight.hint.HintManagerImpl +import com.intellij.codeInsight.hint.HintUtil +import com.intellij.formatting.service.AsyncDocumentFormattingService +import com.intellij.formatting.service.AsyncFormattingRequest +import com.intellij.formatting.service.FormattingService +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.editor.Document +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.progress.ProcessCanceledException +import com.intellij.openapi.util.TextRange +import com.intellij.openapi.util.registry.Registry +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiFile +import com.intellij.psi.util.PsiEditorUtil +import com.intellij.ui.LightweightHint +import com.jetbrains.python.PyBundle +import com.jetbrains.python.black.configuration.BlackFormatterConfiguration +import org.jetbrains.annotations.Nls +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import kotlin.time.toKotlinDuration + + +class BlackFormattingService : AsyncDocumentFormattingService() { + companion object { + private val LOG = thisLogger() + val NAME: String = PyBundle.message("black.formatting.service.name") + val DEFAULT_CHARSET: Charset = StandardCharsets.UTF_8 + const val NOTIFICATION_GROUP_ID = "Black Formatter Integration" + val FEATURES: Set = setOf(FormattingService.Feature.FORMAT_FRAGMENTS) + } + + override fun getFeatures(): Set = FEATURES + + override fun canFormat(source: PsiFile): Boolean { + if (!Registry.`is`("black.formatter.support.enabled")) return false + val project = source.project + val blackConfiguration = BlackFormatterConfiguration.getBlackConfiguration(project) + + BlackFormatterAdvertiserService.getInstance().suggestBlack(project, blackConfiguration) + + if (!blackConfiguration.enabledOnReformat) return false + val vFile = source.virtualFile ?: return false + return BlackFormatterUtil.isFileApplicable(vFile) + } + + override fun createFormattingTask(formattingRequest: AsyncFormattingRequest): FormattingTask? { + val formattingContext = formattingRequest.context + val file = formattingContext.containingFile + val vFile = formattingContext.virtualFile ?: return null + val formattingRange = formattingRequest.formattingRanges[0] + val project = formattingContext.project + val blackConfig = BlackFormatterConfiguration.getBlackConfiguration(project) + val sdk = blackConfig.getSdk(project) + + if (sdk == null) { + val message = PyBundle.message("black.sdk.not.configured.error", project.name) + LOG.warn(message) + formattingRequest.onError(PyBundle.message("black.sdk.not.configured.error.title"), message) + return null + } + + val document = ReadAction.compute { + PsiDocumentManager.getInstance(project).getDocument(file) + } + if (document == null) { + LOG.warn("Document for file ${file.name} is null") + return null + } + + val text = document.text + val fragment: String = document.getText(formattingRange) + if (fragment.isEmpty()) return null + + val editor = PsiEditorUtil.findEditor(file) + + val blackFormattingRequest = if (isFormatFragmentAction(document, formattingRange)) + BlackFormattingRequest.Fragment(fragment, vFile) + else + BlackFormattingRequest.File(fragment, vFile) + + return object : FormattingTask { + override fun run() { + runCatching { + val executor = BlackFormatterExecutor(project, sdk, blackConfig) + + when (val response = executor.getBlackFormattingResponse(blackFormattingRequest, timeout.toKotlinDuration())) { + is BlackFormattingResponse.Success -> { + val formattedFragment = response.formattedText + val formattedDocumentText = when (blackFormattingRequest) { + is BlackFormattingRequest.Fragment -> { + val postProcessedFragment = blackFormattingRequest.postProcessResponse(formattedFragment) + text.replaceRange(IntRange(formattingRange.startOffset, formattingRange.endOffset - 1), postProcessedFragment) + } + is BlackFormattingRequest.File -> { + formattedFragment + } + } + formattingRequest.onTextReady(formattedDocumentText) + val message = buildNotificationMessage(document, formattedDocumentText) + showFormattedLinesInfo(editor, message) + } + is BlackFormattingResponse.Failure -> { + LOG.warn(response.getLoggingMessage()) + formattingRequest.onError(response.title, response.description) + } + is BlackFormattingResponse.Ignored -> { + showFormattedLinesInfo(editor, PyBundle.message("black.file.ignored.notification.message", vFile.name)) + } + } + }.onFailure { exception -> + when (exception) { + is ProcessCanceledException -> { /* ignore */ } + else -> { + LOG.warn(exception) + formattingRequest.onError(PyBundle.message("black.exception.error.message"), exception.localizedMessage) + } + } + } + } + + override fun cancel(): Boolean { + return true + } + + override fun isRunUnderProgress(): Boolean { + return true + } + } + } + + private fun buildNotificationMessage(document: Document, textBefore: CharSequence): @Nls String { + val diff = VcsFacade.getInstance().calculateChangedLinesNumber(document, textBefore) + return if (diff == 0) + PyBundle.message("black.no.lines.changed") + else + PyBundle.message("black.formatted.n.lines", diff, if (diff == 1) 1 else 0) + } + + private fun showFormattedLinesInfo(editor: Editor?, text: @Nls String) { + if (editor != null) { + ApplicationManager.getApplication() + .invokeLater({ + val component = HintUtil.createInformationLabel(text, null, null, null) + val hint = LightweightHint(component) + HintManagerImpl.getInstanceImpl() + .showEditorHint(hint, editor, HintManager.ABOVE, + HintManager.HIDE_BY_ANY_KEY or HintManager.HIDE_BY_SCROLLING, 0, + false) + }, + ModalityState.defaultModalityState()) { editor.isDisposed || !editor.component.isShowing } + } + } + + private fun isFormatFragmentAction(document: Document, range: TextRange): Boolean = + range.length != document.textLength + + override fun getNotificationGroupId(): String = NOTIFICATION_GROUP_ID + + override fun getName(): String = NAME +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/black/configuration/BlackFormatterConfigurable.kt b/python/src/com/jetbrains/python/black/configuration/BlackFormatterConfigurable.kt new file mode 100644 index 000000000000..0bc43c5ad701 --- /dev/null +++ b/python/src/com/jetbrains/python/black/configuration/BlackFormatterConfigurable.kt @@ -0,0 +1,425 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.black.configuration + +import com.intellij.codeInsight.AutoPopupController +import com.intellij.icons.AllIcons +import com.intellij.ide.actionsOnSave.* +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.IdeActions +import com.intellij.openapi.application.EDT +import com.intellij.openapi.editor.SpellCheckingEditorCustomizationProvider +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.keymap.KeymapUtil +import com.intellij.openapi.options.BoundConfigurable +import com.intellij.openapi.progress.runBlockingModal +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.modules +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.ui.* +import com.intellij.openapi.util.SystemInfo +import com.intellij.ui.EnumComboBoxModel +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.TextFieldWithAutoCompletionListProvider +import com.intellij.ui.dsl.builder.* +import com.intellij.util.io.await +import com.intellij.util.text.nullize +import com.intellij.util.textCompletion.TextCompletionUtil +import com.intellij.util.textCompletion.TextFieldWithCompletion +import com.intellij.util.ui.UIUtil +import com.intellij.webcore.packaging.PackageManagementService +import com.jetbrains.python.PyBundle +import com.jetbrains.python.black.BlackFormatterUtil +import com.jetbrains.python.black.BlackFormatterVersionService +import com.jetbrains.python.black.configuration.BlackFormatterConfiguration.BlackFormatterOption.Companion.toCliOptionFlags +import com.jetbrains.python.newProject.steps.createPythonSdkComboBox +import com.jetbrains.python.packaging.PyPackageManagers +import com.jetbrains.python.packaging.PyPackagesNotificationPanel +import com.jetbrains.python.sdk.PythonSdkAdditionalData +import com.jetbrains.python.sdk.pythonSdk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.annotations.Nls +import java.io.File +import java.util.concurrent.CompletableFuture +import javax.swing.JButton +import javax.swing.JCheckBox +import javax.swing.JLabel + +const val CONFIGURABLE_ID = "com.jetbrains.python.black.configuration.BlackFormatterConfigurable" + +class BlackFormatterConfigurable(val project: Project) : BoundConfigurable(PyBundle.message("black.configurable.name")) { + + private var storedState: BlackFormatterConfiguration + + private var isBlackFormatterPackageInstalled: Boolean = false + private var detectedBlackExecutable: File? = null + private var selectedSdk: Sdk? = null + private var isLocalSdk = false + + private lateinit var enableOnReformatCheckBox: JCheckBox + private lateinit var enableOnSaveCheckBox: JCheckBox + private lateinit var packageNotInstalledErrorLabel: JLabel + private lateinit var remoteSdkErrorLabel: JLabel + private lateinit var installButton: JButton + private lateinit var installPanel: Panel + private lateinit var settingsPanel: Panel + private lateinit var pathToBinaryRow: Row + private lateinit var sdkSelectionRow: Row + private lateinit var cliArgumentsRow: Row + private lateinit var executionModeComboBox: ComboBox + + private val blackExecutablePathField = TextFieldWithBrowseButton().apply { + addBrowseFolderListener( + @Suppress("DialogTitleCapitalization") + PyBundle.message("black.select.path.to.executable"), + null, + project, + FileChooserDescriptorFactory.createSingleFileOrExecutableAppDescriptor() + ) + } + + private val sdkSelectionComboBox = createPythonSdkComboBox(project.modules.mapNotNull { it.pythonSdk }, null) + + private val cliArgumentsTextField = BlackTextFieldWithAutoCompletion(project, object : + TextFieldWithAutoCompletionListProvider( + BlackFormatterConfiguration.options.toCliOptionFlags()) { + + override fun getLookupString(item: BlackFormatterConfiguration.CliOptionFlag): String = item.flag + " " + + override fun getTailText(item: BlackFormatterConfiguration.CliOptionFlag): String? = item.option.param + + override fun getTypeText(item: BlackFormatterConfiguration.CliOptionFlag): String = item.description() + }) + + var mainPanel: DialogPanel = panel { + row { + label(PyBundle.message("black.execution.mode.label")) + .applyToComponent { toolTipText = PyBundle.message("black.execution.mode.tooltip.text") } + .applyToComponent { icon = AllIcons.General.ContextHelp } + executionModeComboBox = comboBox(EnumComboBoxModel( + BlackFormatterConfiguration.ExecutionMode::class.java)) + .applyToComponent { renderer = executionModeComboBoxRenderer } + .component + layout(RowLayout.LABEL_ALIGNED) + } + row { + remoteSdkErrorLabel = label(PyBundle.message("black.remote.sdk.error")) + .applyToComponent { icon = AllIcons.General.Warning } + .visible(false) + .component + } + pathToBinaryRow = row(PyBundle.message("black.executable.label")) { + layout(RowLayout.LABEL_ALIGNED) + cell(blackExecutablePathField) + .validationInfo { blackExecutableValidationInfo() } + .onChanged { updateUiState() } + .align(AlignX.FILL) + bottomGap(BottomGap.SMALL) + } + sdkSelectionRow = row(PyBundle.message("black.sdk.selection.combobox.label")) { + layout(RowLayout.LABEL_ALIGNED) + cell(sdkSelectionComboBox) + .resizableColumn() + .align(AlignX.FILL) + .columns(COLUMNS_SHORT) + } + installPanel = panel { + row { + packageNotInstalledErrorLabel = label(PyBundle.message("black.not.installed.error")) + .applyToComponent { icon = AllIcons.General.Warning } + .component + installButton = button(PyBundle.message("black.install.button.label")) { + runBlockingModal(project, PyBundle.message("black.installing.modal.title")) { + withContext(Dispatchers.EDT) { + if (selectedSdk != null) { + val errorDescription = installBlackFormatter(selectedSdk!!) + if (errorDescription == null) { + isBlackFormatterPackageInstalled = true + enableOnReformatCheckBox.isSelected = true + updateUiState() + } + else { + PyPackagesNotificationPanel + .showPackageInstallationError(@Suppress("DialogTitleCapitalization") + PyBundle.message("black.installation.error.title"), + errorDescription) + } + } + } + } + }.component + } + } + settingsPanel = panel { + row(PyBundle.message("black.use.section.label")) { + layout(RowLayout.LABEL_ALIGNED) + enableOnReformatCheckBox = checkBox(PyBundle.message("black.enable.black.checkbox.label")).component + val shortcut = ActionManager.getInstance().getKeyboardShortcut(IdeActions.ACTION_EDITOR_REFORMAT) + shortcut?.let { comment(KeymapUtil.getShortcutText(it)) } + } + row { + label("") + layout(RowLayout.LABEL_ALIGNED) + bottomGap(BottomGap.SMALL) + enableOnSaveCheckBox = checkBox(PyBundle.message("black.enable.action.on.save.label")).component + val link = ActionsOnSaveConfigurable.createGoToActionsOnSavePageLink() + cell(link) + } + cliArgumentsRow = row(PyBundle.message("black.cli.args.text.field.label")) { + cell(cliArgumentsTextField) + .resizableColumn() + .align(AlignX.FILL) + .applyToComponent { + background = UIUtil.getTextFieldBackground() + } + .comment(PyBundle.message("black.cli.args.comment"), MAX_LINE_LENGTH_WORD_WRAP) + } + } + } + + init { + storedState = BlackFormatterConfiguration.getBlackConfiguration(project) + + selectedSdk = storedState.getSdk(project) + ?: if (project.modules.size == 1) project.pythonSdk + else null + + updateSdkInfo() + + detectedBlackExecutable = BlackFormatterUtil.detectBlackExecutable() + + executionModeComboBox.addActionListener { updateUiState() } + + sdkSelectionComboBox.addActionListener { + selectedSdk = sdkSelectionComboBox.item + updateSdkInfo() + updateUiState() + } + } + + private fun initForm() { + enableOnReformatCheckBox.isSelected = storedState.enabledOnReformat + enableOnSaveCheckBox.isSelected = storedState.enabledOnSave + executionModeComboBox.item = storedState.executionMode + cliArgumentsTextField.text = storedState.cmdArguments ?: "" + sdkSelectionComboBox.item = selectedSdk + + blackExecutablePathField.emptyText.text = getBlackExecPathPlaceholderMessage() + storedState.pathToExecutable?.let { + blackExecutablePathField.text = it + } + + if (storedState == BlackFormatterConfiguration()) { + if (isBlackFormatterPackageInstalled) { + executionModeComboBox.selectedItem = BlackFormatterConfiguration.ExecutionMode.PACKAGE + } + else if (detectedBlackExecutable != null) { + executionModeComboBox.selectedItem = BlackFormatterConfiguration.ExecutionMode.BINARY + } + } + + updateUiState() + } + + private fun updateUiState() { + val isBinaryMode = executionModeComboBox.selectedItem == BlackFormatterConfiguration.ExecutionMode.BINARY + + installPanel.visible(!isBlackFormatterPackageInstalled && !isBinaryMode && isLocalSdk) + pathToBinaryRow.visible(isBinaryMode) + sdkSelectionRow.visible(!isBinaryMode) + + if (selectedSdk == null) { + packageNotInstalledErrorLabel.text = PyBundle.message("black.no.project.interpreter.error") + installButton.isVisible = false + } + else if (!isLocalSdk) { + remoteSdkErrorLabel.isVisible = executionModeComboBox.selectedItem == BlackFormatterConfiguration.ExecutionMode.PACKAGE + } + + val canBeEnabled = canBeEnabled() + settingsPanel.enabled(canBeEnabled) + enableOnReformatCheckBox.isSelected = storedState.enabledOnReformat && canBeEnabled + enableOnSaveCheckBox.isSelected = storedState.enabledOnSave && canBeEnabled + } + + private fun updateSdkInfo() { + isLocalSdk = selectedSdk?.let { it.sdkType.isLocalSdk(it) } ?: false + isBlackFormatterPackageInstalled = BlackFormatterUtil.isBlackFormatterInstalledOnProjectSdk(selectedSdk) + } + + private suspend fun installBlackFormatter(sdk: Sdk): PackageManagementService.ErrorDescription? { + val manager = PyPackageManagers.getInstance().getManagementService(project, sdk) + val blackPackage = manager.allPackagesCached.firstOrNull { pyPackage -> pyPackage.name == BlackFormatterUtil.PACKAGE_NAME } + val result = CompletableFuture() + val listener = object : PackageManagementService.Listener { + override fun operationStarted(packageName: String?) {} + + override fun operationFinished(packageName: String?, errorDescription: PackageManagementService.ErrorDescription?) { + if (errorDescription == null) { + result.complete(null) + } + else { + result.complete(errorDescription) + } + } + } + manager.installPackage(blackPackage, null, false, null, listener, false) + return result.await() + } + + private fun canBeEnabled(): Boolean { + return when (executionModeComboBox.selectedItem) { + BlackFormatterConfiguration.ExecutionMode.BINARY -> + detectedBlackExecutable != null || storedState.pathToExecutable != null || blackExecutableValidationInfo() == null + BlackFormatterConfiguration.ExecutionMode.PACKAGE -> + selectedSdk != null && isLocalSdk && isBlackFormatterPackageInstalled + else -> false + } + } + + private fun applyToConfig(configuration: BlackFormatterConfiguration): BlackFormatterConfiguration = configuration.apply { + executionMode = executionModeComboBox.item + enabledOnReformat = enableOnReformatCheckBox.isSelected + enabledOnSave = enableOnSaveCheckBox.isSelected + cmdArguments = cliArgumentsTextField.text + sdkUUID = (sdkSelectionComboBox.item?.sdkAdditionalData as? PythonSdkAdditionalData)?.uuid.toString() + + pathToExecutable = if (blackExecutableValidationInfo() == null) { + blackExecutablePathField.text.nullize() ?: BlackFormatterUtil.detectBlackExecutable()?.absolutePath + } + else null + } + + private fun blackExecutableValidationInfo(): ValidationInfo? = + BlackFormatterUtil.validateBlackExecutable( + blackExecutablePathField.text.nullize() ?: BlackFormatterUtil.detectBlackExecutable()?.absolutePath) + + private fun getBlackExecPathPlaceholderMessage(): String { + return BlackFormatterUtil.detectBlackExecutable()?.let { + PyBundle.message("black.executable.auto.detected.path", it.absolutePath) + } ?: PyBundle.message("black.executable.not.found", if (SystemInfo.isWindows) 0 else 1) + } + + override fun isModified(): Boolean = storedState != applyToConfig(storedState.copy()) + + override fun reset() { + initForm() + updateUiState() + } + + override fun apply() { + applyToConfig(storedState) + } + override fun createPanel(): DialogPanel { + mainPanel.registerValidators(disposable!!) + return mainPanel + } + + companion object { + private val executionModeComboBoxRenderer = + SimpleListCellRenderer.create( + SimpleListCellRenderer.Customizer { label, value, _ -> + val text: @Nls String = when (value) { + BlackFormatterConfiguration.ExecutionMode.PACKAGE -> PyBundle.message("black.execution.mode.package") + BlackFormatterConfiguration.ExecutionMode.BINARY -> PyBundle.message("black.execution.mode.binary") + null -> "" + } + label.text = text + }) + } + + + class BlackTextFieldWithAutoCompletion(project: Project, + provider: TextFieldWithAutoCompletionListProvider) + : TextFieldWithCompletion(project, provider, "", true, true, true) { + override fun createEditor(): EditorEx { + val editor = super.createEditor() + val disableSpellChecking = SpellCheckingEditorCustomizationProvider.getInstance().disabledCustomization + disableSpellChecking?.customize(editor) + editor.putUserData(AutoPopupController.ALWAYS_AUTO_POPUP, true) + val completionShortcut = KeymapUtil.getFirstKeyboardShortcutText( + ActionManager.getInstance().getAction(IdeActions.ACTION_CODE_COMPLETION)) + if (completionShortcut.isNotEmpty()) { + TextCompletionUtil.installCompletionHint(editor) + } + return editor + } + + override fun getText(): String = super.getText().trimEnd() + } + + class BlackFormatterActionOnSaveInfoProvider : ActionOnSaveInfoProvider() { + override fun getActionOnSaveInfos(context: ActionOnSaveContext): + List = listOf(BlackFormatterActionOnSaveInfo(context)) + + override fun getSearchableOptions(): Collection { + return listOf(PyBundle.message("black.action.on.save.name"), PyBundle.message("black.configurable.name")) + } + } + + internal class BlackFormatterActionOnSaveInfo(actionOnSaveContext: ActionOnSaveContext) + : ActionOnSaveBackedByOwnConfigurable(actionOnSaveContext, CONFIGURABLE_ID, + BlackFormatterConfigurable::class.java) { + + override fun setActionOnSaveEnabled(configurable: BlackFormatterConfigurable, enabled: Boolean) { + configurable.enableOnSaveCheckBox.isSelected = enabled + } + + override fun getCommentAccordingToStoredState() = + getCommentForBlack(BlackFormatterConfiguration.getBlackConfiguration(project)) + + override fun getCommentAccordingToUiState(configurable: BlackFormatterConfigurable) = + getCommentForBlack(configurable.storedState) + + private fun getCommentForBlack(configuration: BlackFormatterConfiguration): ActionOnSaveComment { + val version = runBlockingModal(project, "") { + BlackFormatterVersionService.getVersion(project) + } + return when (configuration.executionMode) { + BlackFormatterConfiguration.ExecutionMode.BINARY -> { + configuration.pathToExecutable?.let { + ActionOnSaveComment.info(PyBundle.message("black.action.on.save.executable.info", version, it)) + } ?: ActionOnSaveComment.warning(PyBundle.message("black.action.on.save.executable.path.not.specified")) + } + BlackFormatterConfiguration.ExecutionMode.PACKAGE -> { + if (version != BlackFormatterVersionService.UNKNOWN_VERSION) { + ActionOnSaveComment.info(PyBundle.message("black.action.on.save.package.info", version)) + } + else { + ActionOnSaveComment.warning(PyBundle.message("black.not.installed.error")) + } + } + } + } + + override fun isActionOnSaveEnabledAccordingToUiState(configurable: BlackFormatterConfigurable): Boolean { + return configurable.enableOnSaveCheckBox.isSelected + } + + override fun isActionOnSaveEnabledAccordingToStoredState(): Boolean = + BlackFormatterConfiguration.getBlackConfiguration(project).enabledOnSave + + override fun getActionOnSaveName(): String = PyBundle.message("black.action.on.save.name") + + override fun isApplicableAccordingToUiState(configurable: BlackFormatterConfigurable) = + when (configurable.storedState.executionMode) { + BlackFormatterConfiguration.ExecutionMode.PACKAGE -> + BlackFormatterUtil.isBlackFormatterInstalledOnProjectSdk(configurable.selectedSdk) + BlackFormatterConfiguration.ExecutionMode.BINARY -> + BlackFormatterUtil.isBlackExecutableDetected() + } + + override fun isApplicableAccordingToStoredState(): Boolean { + val configuration = BlackFormatterConfiguration.getBlackConfiguration(project) + return when (configuration.executionMode) { + BlackFormatterConfiguration.ExecutionMode.PACKAGE -> + BlackFormatterUtil.isBlackFormatterInstalledOnProjectSdk(configuration.getSdk(project)) + BlackFormatterConfiguration.ExecutionMode.BINARY -> + BlackFormatterUtil.isBlackExecutableDetected() + } + } + + override fun getActionLinks() = listOf(createGoToPageInSettingsLink(CONFIGURABLE_ID)) + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/black/configuration/BlackFormatterConfigurableProvider.kt b/python/src/com/jetbrains/python/black/configuration/BlackFormatterConfigurableProvider.kt new file mode 100644 index 000000000000..30e0937b7792 --- /dev/null +++ b/python/src/com/jetbrains/python/black/configuration/BlackFormatterConfigurableProvider.kt @@ -0,0 +1,17 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.black.configuration + +import com.intellij.openapi.options.ConfigurableProvider +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.registry.Registry + +class BlackFormatterConfigurableProvider(val project: Project) : ConfigurableProvider() { + + override fun canCreateConfigurable(): Boolean { + return Registry.`is`("black.formatter.support.enabled") + } + + override fun createConfigurable(): BlackFormatterConfigurable { + return BlackFormatterConfigurable(project) + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/black/configuration/BlackFormatterConfiguration.kt b/python/src/com/jetbrains/python/black/configuration/BlackFormatterConfiguration.kt new file mode 100644 index 000000000000..21c3d27e2577 --- /dev/null +++ b/python/src/com/jetbrains/python/black/configuration/BlackFormatterConfiguration.kt @@ -0,0 +1,92 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.black.configuration + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.modules +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.util.xmlb.XmlSerializerUtil +import com.jetbrains.python.sdk.PythonSdkAdditionalData +import com.jetbrains.python.sdk.pythonSdk +import java.util.* + +const val BLACK_ID: String = "Black" + +@State(name = BLACK_ID) +data class BlackFormatterConfiguration(var enabledOnReformat: Boolean, + var enabledOnSave: Boolean, + var executionMode: ExecutionMode, + var pathToExecutable: String?, + var cmdArguments: String, + var sdkUUID: String?) + : PersistentStateComponent { + + @Suppress("unused") // Empty constructor required for state components + constructor() : this(false, + false, + ExecutionMode.PACKAGE, + null, + "", + null) + + enum class ExecutionMode { + BINARY, + PACKAGE, + } + + fun getSdk(project: Project): Sdk? = sdkUUID?.let { uuidString -> + val uuid = UUID.fromString(uuidString) + project.modules + .mapNotNull { it.pythonSdk } + .firstOrNull { sdk -> (sdk.sdkAdditionalData as PythonSdkAdditionalData).uuid == uuid } + } + + companion object { + + val options = listOf( + BlackFormatterOption(listOf("-l", "--line-length"), "", "How many characters per line to allow. [default: 88]"), + BlackFormatterOption(listOf("-x", "--skip-source-first-line"), null, "Skip the first line of the source code"), + BlackFormatterOption(listOf("-S", "--skip-string-normalization"), null, "Don't normalize string quotes or prefixes"), + BlackFormatterOption(listOf("-C", "--skip-magic-trailing-comma"), null, "Don't use trailing commas as a reason to split lines"), + BlackFormatterOption(listOf("--fast", "--safe"), null, "Skip temporary sanity checks [default: --safe]"), + BlackFormatterOption(listOf("--config"), "FILE", "Read configuration from FILE path."), + BlackFormatterOption(listOf("--preview"), null, "Enable potentially disruptive style changes\n" + + "that may be added to Black's main\n" + + "functionality in the next major release."), + BlackFormatterOption(listOf("-t", "--target-version"), "[ver1, ver2..]", "Python versions that should be supported by\n" + + "Black's output. By default, Black will try\n" + + "to infer this from the project metadata in\n" + + "pyproject.toml. If this does not yield\n" + + "conclusive results, Black will use per-file\n" + + "auto-detection."), + ) + + fun getBlackConfiguration(project: Project): BlackFormatterConfiguration = project.getService(BlackFormatterConfiguration::class.java) + } + + class CliOptionFlag(val flag: String, val option: BlackFormatterOption) { + internal fun description(): String { + if (isPrimaryFlag(flag)) { + return option.description + } + val primaryFlag = option.flags.find(::isPrimaryFlag) + return if (primaryFlag != null) "See $primaryFlag" else option.description + } + + private fun isPrimaryFlag(flag: String): Boolean = flag.startsWith("--") + } + + data class BlackFormatterOption(val flags: List, val param: String?, val description: String) { + + companion object { + fun List.toCliOptionFlags() = this.flatMap { option -> + option.flags.map { CliOptionFlag(it, option) } + } + } + } + + override fun getState(): BlackFormatterConfiguration = this + + override fun loadState(state: BlackFormatterConfiguration) = XmlSerializerUtil.copyBean(state, this) +} \ No newline at end of file diff --git a/python/testSrc/com/jetbrains/env/PyEnvTestCase.java b/python/testSrc/com/jetbrains/env/PyEnvTestCase.java index cd8638819769..916f77aee7a6 100644 --- a/python/testSrc/com/jetbrains/env/PyEnvTestCase.java +++ b/python/testSrc/com/jetbrains/env/PyEnvTestCase.java @@ -188,7 +188,11 @@ public abstract class PyEnvTestCase { final EnvTestTagsRequired classAnnotation = getClass().getAnnotation(EnvTestTagsRequired.class); EnvTestTagsRequired methodAnnotation = null; try { - final Method method = getClass().getMethod(myTestName.getMethodName()); + String methodName = myTestName.getMethodName(); + if (methodName.contains("[")) { + methodName = methodName.substring(0, methodName.indexOf('[')); + } + final Method method = getClass().getMethod(methodName); methodAnnotation = method.getAnnotation(EnvTestTagsRequired.class); } catch (final NoSuchMethodException e) {