diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5de52b4d9..051f9802c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,9 @@ jobs: jre: 11 os: ubuntu-latest shfmt-version: v3.8.0 + - kind: idea + jre: 11 + os: ubuntu-latest runs-on: ${{ matrix.os }} steps: - name: Checkout @@ -91,6 +94,16 @@ jobs: - name: Test shfmt if: matrix.kind == 'shfmt' run: ./gradlew testShfmt + - name: Test idea + if: matrix.kind == 'idea' + run: | + download_link=$(curl https://data.services.jetbrains.com/products/releases\?code\=IIC\&latest\=true\&type\=release | jq -r '.IIC[0].downloads.linux.link') + curl --location "$download_link" -o idea.tar.gz + tar -xf idea.tar.gz + cd idea-IC* + export PATH=${PATH}:$(pwd)/bin + cd .. + ./gradlew testIdea - name: junit result uses: mikepenz/action-junit-report@v5 if: always() # always run even if the previous step fails diff --git a/.gitignore b/.gitignore index 2f8c81aca6..28ce16fa21 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ # Project-specific stuff userHome/ workspace/ +testenv.properties ### Gradle ### .gradle diff --git a/CHANGES.md b/CHANGES.md index ba1e66f1f7..938e3c385c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -11,6 +11,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format ( ## [Unreleased] ### Added +* Support for `idea` ([#2020](https://github.com/diffplug/spotless/pull/2020), [#2535](https://github.com/diffplug/spotless/pull/2535)) * Add support for removing wildcard imports via `removeWildcardImports` step. ([#2517](https://github.com/diffplug/spotless/pull/2517)) ## [3.1.2] - 2025-05-27 diff --git a/INTELLIJ_IDEA_SCREENSHOTS.md b/INTELLIJ_IDEA_SCREENSHOTS.md new file mode 100644 index 0000000000..31e264b5ea --- /dev/null +++ b/INTELLIJ_IDEA_SCREENSHOTS.md @@ -0,0 +1,16 @@ +# Extracting Code Style from IntelliJ IDEA + +## 1. Exporting Code Style Settings to a file +To export code style settings from IntelliJ IDEA to a file, go to +`Settings | Editor | Code Style` and click on the gear icon next to the scheme name. + +![Exporting code style settings](_images/intellij_export_codestyle.png) + +## 2. Using IntelliJ's active code style directly +If you have your code style settings checked into version control (in your `.idea` directory), +you can use the active code style directly in Spotless without exporting it to a file. +The file can be found at `.idea/codeStyles/Project.xml`. + +## Upstream documentation +More details can be found in the [IntelliJ IDEA documentation](https://www.jetbrains.com/help/idea/command-line-formatter.html#options) +for the command line formatter, which is what Spotless uses under the hood. diff --git a/_images/intellij_export_codestyle.png b/_images/intellij_export_codestyle.png new file mode 100644 index 0000000000..e0c98d2cdb Binary files /dev/null and b/_images/intellij_export_codestyle.png differ diff --git a/gradle/special-tests.gradle b/gradle/special-tests.gradle index 650c04b84b..1931043e42 100644 --- a/gradle/special-tests.gradle +++ b/gradle/special-tests.gradle @@ -6,6 +6,7 @@ def special = [ 'buf', 'clang', 'gofmt', + 'idea', 'npm', 'shfmt' ] @@ -37,5 +38,9 @@ tasks.named('test').configure { special.forEach { tag -> tasks.register("test${tag.capitalize()}", Test) { useJUnitPlatform { includeTags tag } + if (rootProject.file('testenv.properties').exists()) { + systemProperty 'testenv.properties.path', rootProject.file('testenv.properties').canonicalPath + } } } + diff --git a/lib/src/main/java/com/diffplug/spotless/generic/IdeaStep.java b/lib/src/main/java/com/diffplug/spotless/generic/IdeaStep.java new file mode 100644 index 0000000000..209a0d7560 --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/generic/IdeaStep.java @@ -0,0 +1,326 @@ +/* + * Copyright 2024-2025 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.generic; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Properties; +import java.util.TreeMap; +import java.util.UUID; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.diffplug.spotless.ForeignExe; +import com.diffplug.spotless.FormatterFunc; +import com.diffplug.spotless.FormatterStep; +import com.diffplug.spotless.ProcessRunner; +import com.diffplug.spotless.ThrowingEx; + +public final class IdeaStep { + + private static final Logger LOGGER = LoggerFactory.getLogger(IdeaStep.class); + + public static final String NAME = "IDEA"; + + public static final String IDEA_EXECUTABLE_DEFAULT = "idea"; + + public static final String IDEA_CONFIG_PATH_PROPERTY = "idea.config.path"; + public static final String IDEA_SYSTEM_PATH_PROPERTY = "idea.system.path"; + @Nonnull + private final IdeaStepBuilder builder; + + private IdeaStep(@Nonnull IdeaStepBuilder builder) { + this.builder = builder; + } + + public static IdeaStepBuilder newBuilder(@Nonnull File buildDir) { + return new IdeaStepBuilder(Objects.requireNonNull(buildDir)); + } + + private static FormatterStep create(IdeaStepBuilder builder) { + return new IdeaStep(builder).createFormatterStep(); + } + + private FormatterStep createFormatterStep() { + return FormatterStep.createLazy(NAME, this::createState, State::toFunc); + } + + private State createState() { + return new State(Objects.requireNonNull(builder)); + } + + public static final class IdeaStepBuilder { + + private boolean useDefaults = true; + @Nonnull + private String binaryPath = IDEA_EXECUTABLE_DEFAULT; + @Nullable private String codeStyleSettingsPath; + private final Map ideaProperties = new HashMap<>(); + + @Nonnull + private final File buildDir; + + private IdeaStepBuilder(@Nonnull File buildDir) { + this.buildDir = Objects.requireNonNull(buildDir); + } + + public IdeaStepBuilder setUseDefaults(boolean useDefaults) { + this.useDefaults = useDefaults; + return this; + } + + public IdeaStepBuilder setBinaryPath(@Nonnull String binaryPath) { + this.binaryPath = Objects.requireNonNull(binaryPath); + return this; + } + + public IdeaStepBuilder setCodeStyleSettingsPath(@Nullable String codeStyleSettingsPath) { + this.codeStyleSettingsPath = codeStyleSettingsPath; + return this; + } + + public IdeaStepBuilder setIdeaProperties(@Nonnull Map ideaProperties) { + if (ideaProperties.containsKey(IDEA_CONFIG_PATH_PROPERTY) || ideaProperties.containsKey(IDEA_SYSTEM_PATH_PROPERTY)) { + throw new IllegalArgumentException("Cannot override IDEA config or system path"); + } + this.ideaProperties.putAll(ideaProperties); + return this; + } + + public FormatterStep build() { + return create(this); + } + + @Override + public String toString() { + return String.format( + "IdeaStepBuilder[useDefaults=%s, binaryPath=%s, codeStyleSettingsPath=%s, ideaProperties=%s, buildDir=%s]", + this.useDefaults, + this.binaryPath, + this.codeStyleSettingsPath, + this.ideaProperties, + this.buildDir); + } + } + + private static class State implements Serializable { + + private static final long serialVersionUID = -1426311255869303398L; + + private final File uniqueBuildFolder; + private final String binaryPath; + @Nullable private final String codeStyleSettingsPath; + private final boolean withDefaults; + private final TreeMap ideaProperties; + + private State(@Nonnull IdeaStepBuilder builder) { + LOGGER.debug("Creating {} state with configuration {}", NAME, builder); + this.uniqueBuildFolder = new File(builder.buildDir, UUID.randomUUID().toString()); + this.withDefaults = builder.useDefaults; + this.codeStyleSettingsPath = builder.codeStyleSettingsPath; + this.ideaProperties = new TreeMap<>(builder.ideaProperties); + this.binaryPath = resolveFullBinaryPathAndCheckVersion(builder.binaryPath); + } + + private static String resolveFullBinaryPathAndCheckVersion(String binaryPath) { + var exe = ForeignExe + .nameAndVersion(binaryPath, "IntelliJ IDEA") + .pathToExe(pathToExe(binaryPath)) + .versionRegex(Pattern.compile("(IntelliJ IDEA) .*")) + .fixCantFind( + "IDEA executable cannot be found on your machine, " + + "please install it and put idea binary to PATH, provide a valid path to the executable or report the problem") + .fixWrongVersion("Provided binary is not IDEA, " + + "please check it and fix the problem; or report the problem"); + try { + return exe.confirmVersionAndGetAbsolutePath(); + } catch (IOException e) { + throw new IllegalArgumentException("binary cannot be found", e); + } catch (InterruptedException e) { + throw new IllegalArgumentException( + "binary cannot be found, process was interrupted", e); + } + } + + @CheckForNull + private static String pathToExe(String binaryPath) { + String testEnvBinaryPath = TestEnvVars.read().get(String.format("%s.%s", IdeaStep.class.getName(), "binaryPath")); + if (testEnvBinaryPath != null) { + return testEnvBinaryPath; + } + if (isMacOs()) { + return macOsFix(binaryPath); + } + if (new File(binaryPath).exists()) { + return binaryPath; + } + return null; // search in PATH + } + + private static String macOsFix(String binaryPath) { + // on macOS, the binary is located in the .app bundle which might be invisible to the user + // we try need to append the path to the binary + File binary = new File(binaryPath); + if (!binary.exists()) { + // maybe it is bundle path without .app? (might be hidden by os) + binary = new File(binaryPath + ".app"); + if (!binary.exists()) { + return binaryPath; // fallback: do nothing + } + } + if (binaryPath.endsWith(".app") || binary.isDirectory()) { + binary = new File(binary, "Contents/MacOS/idea"); + } + if (binary.isFile() && binary.canExecute()) { + return binary.getPath(); + } + return binaryPath; // fallback: do nothing + } + + private static boolean isMacOs() { + return System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("mac"); + } + + private String format(IdeaStepFormatterCleanupResources ideaStepFormatterCleanupResources, String unix, File file) throws Exception { + // since we cannot directly work with the file, we need to write the unix string to a temporary file + File tempFile = File.createTempFile("spotless", file.getName()); + try { + Files.write(tempFile.toPath(), unix.getBytes(StandardCharsets.UTF_8)); + List params = getParams(tempFile); + + Map env = createEnv(); + LOGGER.info("Launching IDEA formatter for orig file {} with params: {} and env: {}", file, params, env); + var result = ideaStepFormatterCleanupResources.runner.exec(null, env, null, params); + LOGGER.debug("command finished with exit code: {}", result.exitCode()); + LOGGER.debug("command finished with stdout: {}", + result.assertExitZero(StandardCharsets.UTF_8)); + return Files.readString(tempFile.toPath(), StandardCharsets.UTF_8); + } finally { + Files.delete(tempFile.toPath()); + } + } + + private Map createEnv() { + File ideaProps = createIdeaPropertiesFile(); + Map env = Map.ofEntries( + Map.entry("IDEA_PROPERTIES", ThrowingEx.get(ideaProps::getCanonicalPath))); + return env; + } + + private File createIdeaPropertiesFile() { + Path ideaProps = this.uniqueBuildFolder.toPath().resolve("idea.properties"); + + if (Files.exists(ideaProps)) { + return ideaProps.toFile(); // only create if it does not exist + } + + Path parent = ideaProps.getParent(); + if (parent == null) { + throw new IllegalStateException(String.format("Parent directory for IDEA properties file %s cannot be null", ideaProps)); + } + ThrowingEx.run(() -> Files.createDirectories(parent)); + + Path configPath = parent.resolve("config"); + Path systemPath = parent.resolve("system"); + + Properties properties = new Properties(); + properties.putAll(ideaProperties); + properties.put(IDEA_CONFIG_PATH_PROPERTY, ThrowingEx.get(configPath.toFile()::getCanonicalPath)); + properties.put(IDEA_SYSTEM_PATH_PROPERTY, ThrowingEx.get(systemPath.toFile()::getCanonicalPath)); + + LOGGER.debug("Creating IDEA properties file at {} with content: {}", ideaProps, properties); + try (FileOutputStream out = new FileOutputStream(ideaProps.toFile())) { + properties.store(out, "Generated by spotless"); + } catch (IOException e) { + throw new IllegalStateException("Failed to create IDEA properties file", e); + } + return ideaProps.toFile(); + } + + private List getParams(File file) { + /* https://www.jetbrains.com/help/idea/command-line-formatter.html */ + var builder = Stream. builder(); + builder.add(binaryPath); + builder.add("format"); + if (withDefaults) { + builder.add("-allowDefaults"); + } + if (codeStyleSettingsPath != null) { + builder.add("-s"); + builder.add(codeStyleSettingsPath); + } + builder.add("-charset").add("UTF-8"); + builder.add(ThrowingEx.get(file::getCanonicalPath)); + return builder.build().collect(Collectors.toList()); + } + + private FormatterFunc.Closeable toFunc() { + IdeaStepFormatterCleanupResources ideaStepFormatterCleanupResources = new IdeaStepFormatterCleanupResources(uniqueBuildFolder, new ProcessRunner()); + return FormatterFunc.Closeable.of(ideaStepFormatterCleanupResources, this::format); + } + } + + private static class IdeaStepFormatterCleanupResources implements AutoCloseable { + @Nonnull + private final File uniqueBuildFolder; + @Nonnull + private final ProcessRunner runner; + + public IdeaStepFormatterCleanupResources(@Nonnull File uniqueBuildFolder, @Nonnull ProcessRunner runner) { + this.uniqueBuildFolder = uniqueBuildFolder; + this.runner = runner; + } + + @Override + public void close() throws Exception { + // close the runner + runner.close(); + // delete the unique build folder + if (uniqueBuildFolder.exists()) { + // delete the unique build folder recursively + try (Stream paths = Files.walk(uniqueBuildFolder.toPath())) { + paths.sorted((o1, o2) -> o2.compareTo(o1)) // delete files first + .forEach(path -> { + try { + Files.delete(path); + } catch (IOException e) { + LOGGER.warn("Failed to delete file: {}", path, e); + } + }); + } + } + } + + } +} diff --git a/lib/src/main/java/com/diffplug/spotless/generic/TestEnvVars.java b/lib/src/main/java/com/diffplug/spotless/generic/TestEnvVars.java new file mode 100644 index 0000000000..8845fe9c0d --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/generic/TestEnvVars.java @@ -0,0 +1,97 @@ +/* + * Copyright 2025 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.generic; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; + +import javax.annotation.Nullable; + +class TestEnvVars { + + private final Map envVars; + + private static TestEnvVars INSTANCE; + + private TestEnvVars(Map envVars) { + this.envVars = Map.copyOf(envVars); + } + + public static synchronized TestEnvVars read() { + if (INSTANCE == null) { + INSTANCE = new TestEnvVars(readTestEnvVars()); + } + return INSTANCE; + } + + private static Map readTestEnvVars() { + Map envVars = new HashMap<>(); + Optional resolvedTestenvProps = candidateTestEnvLocations().filter(Files::exists).findFirst(); + resolvedTestenvProps.ifPresent(testenvProps -> { + try (var reader = Files.newBufferedReader(testenvProps)) { + java.util.Properties properties = new java.util.Properties(); + properties.load(reader); + for (String name : properties.stringPropertyNames()) { + envVars.put(name, properties.getProperty(name)); + } + } catch (IOException e) { + throw new RuntimeException("Failed to read test environment variables", e); + } + }); + return envVars; + } + + private static Stream candidateTestEnvLocations() { + Stream.Builder builder = Stream.builder(); + if (System.getProperty("testenv.properties.path") != null) { + builder.add(Path.of(System.getProperty("testenv.properties.path"))); + } + if (System.getProperty("spotlessProjectDir") != null) { + builder.add(Path.of(System.getProperty("spotlessProjectDir"), "testenv.properties")); + } + builder.add( + Path.of(System.getProperty("user.dir"), "testenv.properties")); + builder.add( + Objects.requireNonNull(Path.of(System.getProperty("user.dir")).getParent()).resolve("testenv.properties")); + return builder.build(); + } + + public @Nullable String get(String key) { + return envVars.get(key); + } + + public String getOrDefault(String key, String defaultValue) { + return envVars.getOrDefault(key, defaultValue); + } + + public String getOrThrow(String key) { + String value = envVars.get(key); + if (value == null) { + throw new IllegalArgumentException("Environment variable " + key + " not found"); + } + return value; + } + + public boolean hasKey(String key) { + return envVars.containsKey(key); + } +} diff --git a/plugin-gradle/CHANGES.md b/plugin-gradle/CHANGES.md index c7fc99944b..121d9d922c 100644 --- a/plugin-gradle/CHANGES.md +++ b/plugin-gradle/CHANGES.md @@ -4,6 +4,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format ( ## [Unreleased] ### Added +* Support for `idea` ([#2020](https://github.com/diffplug/spotless/pull/2020), [#2535](https://github.com/diffplug/spotless/pull/2535)) * Add support for removing wildcard imports via `removeWildcardImports` step. ([#2517](https://github.com/diffplug/spotless/pull/2517)) ## [7.0.4] - 2025-05-27 diff --git a/plugin-gradle/README.md b/plugin-gradle/README.md index 7a2c6b42b7..f775bf58a8 100644 --- a/plugin-gradle/README.md +++ b/plugin-gradle/README.md @@ -55,7 +55,7 @@ Spotless supports all of Gradle's built-in performance features (incremental bui - [Requirements](#requirements) - [Linting](#linting) - **Languages** - - [Java](#java) ([google-java-format](#google-java-format), [eclipse jdt](#eclipse-jdt), [clang-format](#clang-format), [prettier](#prettier), [palantir-java-format](#palantir-java-format), [formatAnnotations](#formatAnnotations), [cleanthat](#cleanthat)) + - [Java](#java) ([google-java-format](#google-java-format), [eclipse jdt](#eclipse-jdt), [clang-format](#clang-format), [prettier](#prettier), [palantir-java-format](#palantir-java-format), [formatAnnotations](#formatAnnotations), [cleanthat](#cleanthat), [IntelliJ IDEA](#intellij-idea)) - [Groovy](#groovy) ([eclipse groovy](#eclipse-groovy)) - [Kotlin](#kotlin) ([ktfmt](#ktfmt), [ktlint](#ktlint), [diktat](#diktat), [prettier](#prettier)) - [Scala](#scala) ([scalafmt](#scalafmt)) @@ -65,7 +65,7 @@ Spotless supports all of Gradle's built-in performance features (incremental bui - [FreshMark](#freshmark) aka markdown - [Flexmark](#flexmark) aka markdown - [Antlr4](#antlr4) ([antlr4formatter](#antlr4formatter)) - - [SQL](#sql) ([dbeaver](#dbeaver), [prettier](#prettier)) + - [SQL](#sql) ([dbeaver](#dbeaver), [prettier](#prettier), [IntelliJ IDEA](#intellij-idea)) - [Maven POM](#maven-pom) ([sortPom](#sortpom)) - [Typescript](#typescript) ([tsfmt](#tsfmt), [prettier](#prettier), [ESLint](#eslint-typescript), [Biome](#biome)) - [Javascript](#javascript) ([prettier](#prettier), [ESLint](#eslint-javascript), [Biome](#biome)) @@ -78,6 +78,7 @@ Spotless supports all of Gradle's built-in performance features (incremental bui - [clang-format](#clang-format) - [eclipse web tools platform](#eclipse-web-tools-platform) - [Biome](#biome) ([binary detection](#biome-binary), [config file](#biome-configuration-file), [input language](#biome-input-language)) + - [IntelliJ IDEA](#intellij-idea) - **Language independent** - [Generic steps](#generic-steps) - [License header](#license-header) ([slurp year from git](#retroactively-slurp-years-from-git-history)) @@ -198,6 +199,7 @@ spotless { eclipse() // has its own section below prettier() // has its own section below clangFormat() // has its own section below + idea() // has its own section below formatAnnotations() // fixes formatting of type annotations, see below @@ -754,6 +756,7 @@ spotless { dbeaver() // has its own section below prettier() // has its own section below + idea() // has its own section below } } ``` @@ -1581,9 +1584,41 @@ The following languages are currently recognized: * `ts?` -- TypeScript, with or without JSX, depending on the file extension * `json` -- JSON +## IntelliJ IDEA + +[homepage](https://www.jetbrains.com/idea/). [changelog](https://www.jetbrains.com/idea/whatsnew/). + +`IntelliJ IDEA` is a powerful IDE for java, kotlin and many more languages. There are [specific variants](https://www.jetbrains.com/products/) for almost any modern language +and a plethora of [plugins](https://plugins.jetbrains.com/). + +Spotless provides access to IntelliJ IDEA's command line formatter. + +```gradle +spotless { + format 'myFormatter', { + // you have to set the target manually + target 'src/main/**/*.java','jbang/*.java' + + idea() + .codeStyleSettingsPath('/path/to/config') // if you have custom formatting rules, see below for how to extract/reference them + .withDefaults(false) // Disable using default code style settings when no custom code style is defined for a file type (default: true) + + // if idea is not on your path, you must specify the path to the executable + idea().binaryPath('/path/to/idea') + } +} +``` + +### How to generate code style settings files +See [here](../INTELLIJ_IDEA_SCREENSHOTS.md) for an explanation on how to extract or reference existing code style files. + +### Limitations +- Currently, only IntelliJ IDEA is supported - none of the other jetbrains IDE. Consider opening a PR if you want to change this. +- Launching IntelliJ IDEA from the command line is pretty expensive and as of now, we do this for each file. If you want to change this, consider opening a PR. + ## Generic steps -[Prettier](#prettier), [eclipse wtp](#eclipse-web-tools-platform), and [license header](#license-header) are available in every format, and they each have their own section. As mentioned in the [quickstart](#quickstart), there are a variety of simple generic steps which are also available in every format, here are examples of these: +[Prettier](#prettier), [eclipse wtp](#eclipse-web-tools-platform), [IntelliJ IDEA](#intellij-idea) and [license header](#license-header) are available in every format, and they each have their own section. As mentioned in the [quickstart](#quickstart), there are a variety of simple generic steps which are also available in every format, here are examples of these: ```gradle spotless { diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java index 40069850c5..1216b15864 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java @@ -65,6 +65,7 @@ import com.diffplug.spotless.extra.wtp.EclipseWtpFormatterStep; import com.diffplug.spotless.generic.EndWithNewlineStep; import com.diffplug.spotless.generic.FenceStep; +import com.diffplug.spotless.generic.IdeaStep; import com.diffplug.spotless.generic.IndentStep; import com.diffplug.spotless.generic.LicenseHeaderStep; import com.diffplug.spotless.generic.LicenseHeaderStep.YearMode; @@ -217,8 +218,7 @@ public void encoding(String charset) { protected FileCollection target, targetExclude; /** The value from which files will be excluded if their content contain it. */ - @Nullable - protected String targetExcludeContentPattern = null; + @Nullable protected String targetExcludeContentPattern = null; protected boolean isLicenseHeaderStep(FormatterStep formatterStep) { String formatterStepName = formatterStep.getName(); @@ -686,17 +686,13 @@ public abstract static class NpmStepConfig> { public static final String SPOTLESS_NPM_INSTALL_CACHE_DEFAULT_NAME = "spotless-npm-install-cache"; - @Nullable - protected Object npmFile; + @Nullable protected Object npmFile; - @Nullable - protected Object nodeFile; + @Nullable protected Object nodeFile; - @Nullable - protected Object npmInstallCache; + @Nullable protected Object npmInstallCache; - @Nullable - protected Object npmrcFile; + @Nullable protected Object npmrcFile; protected Project project; @@ -770,11 +766,9 @@ protected void replaceStep() { public class PrettierConfig extends NpmStepConfig { - @Nullable - Object prettierConfigFile; + @Nullable Object prettierConfigFile; - @Nullable - Map prettierConfig; + @Nullable Map prettierConfig; final Map devDependencies; @@ -814,8 +808,7 @@ protected FormatterStep createStep() { * format{ ... }. */ public class BiomeGeneric extends BiomeStepConfig { - @Nullable - String language; + @Nullable String language; /** * Creates a new Biome config that downloads the Biome executable for the given @@ -960,6 +953,44 @@ public EclipseWtpConfig eclipseWtp(EclipseWtpFormatterStep type, String version) return new EclipseWtpConfig(type, version); } + public IdeaConfig idea() { + return new IdeaConfig(); + } + + public class IdeaConfig { + private final IdeaStep.IdeaStepBuilder builder; + + IdeaConfig() { + this.builder = IdeaStep.newBuilder(getProject().getLayout().getBuildDirectory().getAsFile().get()); + addStep(createStep()); + } + + private FormatterStep createStep() { + return builder.build(); + } + + public IdeaConfig binaryPath(String binaryPath) { + requireNonNull(binaryPath); + builder.setBinaryPath(binaryPath); + replaceStep(createStep()); + return this; + } + + public IdeaConfig codeStyleSettingsPath(String configPath) { + requireNonNull(configPath); + builder.setCodeStyleSettingsPath(configPath); + replaceStep(createStep()); + return this; + } + + public IdeaConfig withDefaults(Boolean withDefaults) { + requireNonNull(withDefaults); + builder.setUseDefaults(withDefaults); + replaceStep(createStep()); + return this; + } + } + /** *
 	 * spotless {
diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/JavaExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/JavaExtension.java
index aeed7b64b5..f0530fb968 100644
--- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/JavaExtension.java
+++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/JavaExtension.java
@@ -444,7 +444,7 @@ public CleanthatJavaConfig clearMutators() {
 		}
 
 		// An id of a mutator (see IMutator.getIds()) or
-		// tThe fully qualified name of a class implementing eu.solven.cleanthat.engine.java.refactorer.meta.IMutator
+		// The fully qualified name of a class implementing eu.solven.cleanthat.engine.java.refactorer.meta.IMutator
 		public CleanthatJavaConfig addMutator(String mutator) {
 			this.mutators.add(mutator);
 			replaceStep(createStep());
diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/JavaIdeaTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/JavaIdeaTest.java
new file mode 100644
index 0000000000..bea9272236
--- /dev/null
+++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/JavaIdeaTest.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2020-2025 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.gradle.spotless;
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.Test;
+
+import com.diffplug.spotless.tag.IdeaTest;
+
+@IdeaTest
+class JavaIdeaTest extends GradleIntegrationHarness {
+	@Test
+	void idea() throws IOException {
+		setFile("build.gradle").toLines(
+				"plugins {",
+				"  id 'com.diffplug.spotless'",
+				"}",
+				"spotless {",
+				"  java {",
+				"    target file('test.java')",
+				"    idea().binaryPath('idea').withDefaults(true)",
+				"  }",
+				"}");
+
+		setFile("test.java").toResource("java/idea/full.dirty.java");
+		gradleRunner().withArguments("spotlessApply").build();
+		assertFile("test.java").notSameAsResource("java/idea/full.dirty.java");
+	}
+}
diff --git a/plugin-maven/CHANGES.md b/plugin-maven/CHANGES.md
index 53c6f4b093..cab00b07af 100644
--- a/plugin-maven/CHANGES.md
+++ b/plugin-maven/CHANGES.md
@@ -4,6 +4,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (
 
 ## [Unreleased]
 ### Added
+* Support for `idea` ([#2020](https://github.com/diffplug/spotless/pull/2020), [#2535](https://github.com/diffplug/spotless/pull/2535))
 * Add support for removing wildcard imports via `removeWildcardImports` step. ([#2517](https://github.com/diffplug/spotless/pull/2517))
 
 ## [2.44.5] - 2025-05-27
diff --git a/plugin-maven/README.md b/plugin-maven/README.md
index b27ddc4eab..2e5839e819 100644
--- a/plugin-maven/README.md
+++ b/plugin-maven/README.md
@@ -39,14 +39,14 @@ user@machine repo % mvn spotless:check
   - [Requirements](#requirements)
   - [Binding to maven phase](#binding-to-maven-phase)
 - **Languages**
-  - [Java](#java) ([google-java-format](#google-java-format), [eclipse jdt](#eclipse-jdt), [prettier](#prettier), [palantir-java-format](#palantir-java-format), [formatAnnotations](#formatAnnotations), [cleanthat](#cleanthat))
+  - [Java](#java) ([google-java-format](#google-java-format), [eclipse jdt](#eclipse-jdt), [prettier](#prettier), [palantir-java-format](#palantir-java-format), [formatAnnotations](#formatAnnotations), [cleanthat](#cleanthat), [IntelliJ IDEA](#intellij-idea))
   - [Groovy](#groovy) ([eclipse groovy](#eclipse-groovy))
   - [Kotlin](#kotlin) ([ktfmt](#ktfmt), [ktlint](#ktlint), [diktat](#diktat), [prettier](#prettier))
   - [Scala](#scala) ([scalafmt](#scalafmt))
   - [C/C++](#cc) ([eclipse cdt](#eclipse-cdt), [clang-format](#clang-format))
   - [Python](#python) ([black](#black))
   - [Antlr4](#antlr4) ([antlr4formatter](#antlr4formatter))
-  - [Sql](#sql) ([dbeaver](#dbeaver))
+  - [Sql](#sql) ([dbeaver](#dbeaver), [prettier](#prettier), [IntelliJ IDEA](#intellij-idea))
   - [Maven Pom](#maven-pom) ([sortPom](#sortpom))
   - [Markdown](#markdown) ([flexmark](#flexmark))
   - [Typescript](#typescript) ([tsfmt](#tsfmt), [prettier](#prettier), [ESLint](#eslint-typescript), [Biome](#biome))
@@ -61,6 +61,7 @@ user@machine repo % mvn spotless:check
     - [Prettier](#prettier) ([plugins](#prettier-plugins), [npm detection](#npm-detection), [`.npmrc` detection](#npmrc-detection), [caching `npm install` results](#caching-results-of-npm-install))
     - [eclipse web tools platform](#eclipse-web-tools-platform)
     - [Biome](#biome) ([binary detection](#biome-binary), [config file](#biome-configuration-file), [input language](#biome-input-language))
+    - [IntelliJ IDEA](#intellij-idea)
 - **Language independent**
   - [Generic steps](#generic-steps)
   - [License header](#license-header) ([slurp year from git](#retroactively-slurp-years-from-git-history))
@@ -194,6 +195,7 @@ any other maven phase (i.e. compile) then it can be configured as below;
      
               
              
+                 
 
      
       
@@ -665,6 +667,7 @@ Additionally, `editorConfigOverride` options will override what's supplied in `.
 
       
      
+         
   
 
 ```
@@ -1666,9 +1669,47 @@ The following languages are currently recognized:
 * `ts?` -- TypeScript, with or without JSX, depending on the file extension
 * `json` -- JSON
 
+## IntelliJ IDEA
+
+[homepage](https://www.jetbrains.com/idea/). [changelog](https://www.jetbrains.com/idea/whatsnew/).
+
+`IntelliJ IDEA` is a powerful IDE for java, kotlin and many more languages. There are [specific variants](https://www.jetbrains.com/products/) for almost any modern language
+and a plethora of [plugins](https://plugins.jetbrains.com/).
+
+Spotless provides access to IntelliJ IDEA's command line formatter.
+
+```xml
+
+  
+    
+      
+        src/main/**/*.java
+        jbang/*.java
+      
+
+      
+        
+        /path/to/config
+        
+        false
+        
+        /path/to/idea
+      
+    
+  
+
+```
+
+### How to generate code style settings files
+See [here](../INTELLIJ_IDEA_SCREENSHOTS.md) for an explanation on how to extract or reference existing code style files.
+
+### Limitations
+- Currently, only IntelliJ IDEA is supported - none of the other jetbrains IDE. Consider opening a PR if you want to change this.
+- Launching IntelliJ IDEA from the command line is pretty expensive and as of now, we do this for each file. If you want to change this, consider opening a PR.
+
 ## Generic steps
 
-[Prettier](#prettier), [eclipse wtp](#eclipse-web-tools-platform), and [license header](#license-header) are available in every format, and they each have their own section. As mentioned in the [quickstart](#quickstart), there are a variety of simple generic steps which are also available in every format, here are examples of these:
+[Prettier](#prettier), [eclipse wtp](#eclipse-web-tools-platform), [IntelliJ IDEA](#intellij-idea) and [license header](#license-header) are available in every format, and they each have their own section. As mentioned in the [quickstart](#quickstart), there are a variety of simple generic steps which are also available in every format, here are examples of these:
 
 ```xml
  
diff --git a/plugin-maven/build.gradle b/plugin-maven/build.gradle
index 7932d4a007..d8e5f90770 100644
--- a/plugin-maven/build.gradle
+++ b/plugin-maven/build.gradle
@@ -55,6 +55,7 @@ dependencies {
 apply from: rootProject.file('gradle/special-tests.gradle')
 tasks.withType(Test).configureEach {
 	systemProperty 'spotlessMavenPluginVersion', project.version
+	systemProperty 'spotlessProjectDir', "${project.rootProject.projectDir}".toString()
 	dependsOn 'publishToMavenLocal'
 	dependsOn ':lib:publishToMavenLocal'
 	dependsOn ':lib-extra:publishToMavenLocal'
diff --git a/plugin-maven/src/main/java/com/diffplug/spotless/maven/FormatterFactory.java b/plugin-maven/src/main/java/com/diffplug/spotless/maven/FormatterFactory.java
index 456c31b9a3..143bd56e69 100644
--- a/plugin-maven/src/main/java/com/diffplug/spotless/maven/FormatterFactory.java
+++ b/plugin-maven/src/main/java/com/diffplug/spotless/maven/FormatterFactory.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2016-2024 DiffPlug
+ * Copyright 2016-2025 DiffPlug
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -38,6 +38,7 @@
 import com.diffplug.spotless.LineEnding;
 import com.diffplug.spotless.maven.generic.EclipseWtp;
 import com.diffplug.spotless.maven.generic.EndWithNewline;
+import com.diffplug.spotless.maven.generic.Idea;
 import com.diffplug.spotless.maven.generic.Indent;
 import com.diffplug.spotless.maven.generic.Jsr223;
 import com.diffplug.spotless.maven.generic.LicenseHeader;
@@ -147,6 +148,10 @@ public final void addPrettier(Prettier prettier) {
 		addStepFactory(prettier);
 	}
 
+	public final void addIdea(Idea idea) {
+		addStepFactory(idea);
+	}
+
 	public final void addToggleOffOn(ToggleOffOn toggle) {
 		this.toggle = toggle;
 	}
diff --git a/plugin-maven/src/main/java/com/diffplug/spotless/maven/generic/Idea.java b/plugin-maven/src/main/java/com/diffplug/spotless/maven/generic/Idea.java
new file mode 100644
index 0000000000..2c3b5587a3
--- /dev/null
+++ b/plugin-maven/src/main/java/com/diffplug/spotless/maven/generic/Idea.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2016-2025 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.maven.generic;
+
+import org.apache.maven.plugins.annotations.Parameter;
+
+import com.diffplug.spotless.FormatterStep;
+import com.diffplug.spotless.generic.IdeaStep;
+import com.diffplug.spotless.maven.FormatterStepConfig;
+import com.diffplug.spotless.maven.FormatterStepFactory;
+
+public class Idea implements FormatterStepFactory {
+
+	@Parameter
+	private String binaryPath;
+
+	@Parameter
+	private String codeStyleSettingsPath;
+
+	@Parameter
+	private Boolean withDefaults = true;
+
+	@Override
+	public FormatterStep newFormatterStep(FormatterStepConfig config) {
+		return IdeaStep.newBuilder(config.getFileLocator().getBuildDir())
+				.setUseDefaults(withDefaults)
+				.setCodeStyleSettingsPath(codeStyleSettingsPath)
+				.setBinaryPath(binaryPath)
+				.build();
+	}
+}
diff --git a/plugin-maven/src/test/java/com/diffplug/spotless/maven/MavenIntegrationHarness.java b/plugin-maven/src/test/java/com/diffplug/spotless/maven/MavenIntegrationHarness.java
index 0d0582e180..f3e1f30355 100644
--- a/plugin-maven/src/test/java/com/diffplug/spotless/maven/MavenIntegrationHarness.java
+++ b/plugin-maven/src/test/java/com/diffplug/spotless/maven/MavenIntegrationHarness.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2016-2024 DiffPlug
+ * Copyright 2016-2025 DiffPlug
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -213,9 +213,15 @@ protected void writePom(String[] executions, String[] configuration, String[] de
 	}
 
 	protected MavenRunner mavenRunner() throws IOException {
-		return MavenRunner.create()
+		MavenRunner mavenRunner = MavenRunner.create()
 				.withProjectDir(rootFolder())
 				.withRunner(runner);
+		System.getProperties().forEach((key, value) -> {
+			if (key instanceof String && ((String) key).startsWith("spotless") && value instanceof String) {
+				mavenRunner.withSystemProperty((String) key, (String) value);
+			}
+		});
+		return mavenRunner;
 	}
 
 	private static ProcessRunner runner;
diff --git a/plugin-maven/src/test/java/com/diffplug/spotless/maven/MavenRunner.java b/plugin-maven/src/test/java/com/diffplug/spotless/maven/MavenRunner.java
index c8d845f8a2..6e22fe5e31 100644
--- a/plugin-maven/src/test/java/com/diffplug/spotless/maven/MavenRunner.java
+++ b/plugin-maven/src/test/java/com/diffplug/spotless/maven/MavenRunner.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2016-2023 DiffPlug
+ * Copyright 2016-2025 DiffPlug
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -23,6 +23,8 @@
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Objects;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 import com.diffplug.spotless.Jvm;
 import com.diffplug.spotless.ProcessRunner;
@@ -41,6 +43,7 @@ private MavenRunner() {}
 	private File projectDir;
 	private String[] args;
 	private Map environment = new HashMap<>();
+	private Map systemProperties = new HashMap<>();
 	private ProcessRunner runner;
 
 	public MavenRunner withProjectDir(File projectDir) {
@@ -64,12 +67,31 @@ public MavenRunner withRemoteDebug(int port) {
 		return this;
 	}
 
+	public MavenRunner withSystemProperty(String key, String value) {
+		systemProperties.put(key, value);
+		return this;
+	}
+
+	private Map calculateEnvironment() {
+		Map env = new HashMap<>(environment);
+		if (!systemProperties.isEmpty()) {
+			// add system properties as environment variables as MAVEN_OPTS or append if already there
+			String sysProps = systemProperties.entrySet().stream()
+					.map(entry -> String.format("-D%s=%s", entry.getKey(), entry.getValue()))
+					.collect(Collectors.joining(" "));
+			String mavenOpts = Stream.of(env.getOrDefault("MAVEN_OPTS", ""), sysProps)
+					.collect(Collectors.joining(" "));
+			env.put("MAVEN_OPTS", mavenOpts.trim());
+		}
+		return env;
+	}
+
 	private ProcessRunner.Result run() throws IOException, InterruptedException {
 		Objects.requireNonNull(projectDir, "Need to call withProjectDir() first");
 		Objects.requireNonNull(args, "Need to call withArguments() first");
 		// run Maven with the given args in the given directory
 		String argsString = "-e " + String.join(" ", Arrays.asList(args));
-		return runner.shellWinUnix(projectDir, environment, "mvnw " + argsString, "./mvnw " + argsString);
+		return runner.shellWinUnix(projectDir, calculateEnvironment(), "mvnw " + argsString, "./mvnw " + argsString);
 	}
 
 	/** Runs the command and asserts that exit code is 0. */
diff --git a/plugin-maven/src/test/java/com/diffplug/spotless/maven/generic/IdeaTest.java b/plugin-maven/src/test/java/com/diffplug/spotless/maven/generic/IdeaTest.java
new file mode 100644
index 0000000000..c368213437
--- /dev/null
+++ b/plugin-maven/src/test/java/com/diffplug/spotless/maven/generic/IdeaTest.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2016-2025 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.maven.generic;
+
+import org.junit.jupiter.api.Test;
+
+import com.diffplug.spotless.maven.MavenIntegrationHarness;
+
+@com.diffplug.spotless.tag.IdeaTest
+class IdeaTest extends MavenIntegrationHarness {
+	@Test
+	void idea() throws Exception {
+		setFile("test.java").toResource("java/cleanthat/MultipleMutators.dirty.test");
+		writePomWithJavaSteps(
+				"",
+				"  idea",
+				"  true",
+				"");
+
+		mavenRunner().withArguments("spotless:apply").runNoError();
+
+		assertFile("test.java").notSameAsResource("java/cleanthat/MultipleMutators.dirty.test");
+	}
+}
diff --git a/testlib/src/main/java/com/diffplug/spotless/ResourceHarness.java b/testlib/src/main/java/com/diffplug/spotless/ResourceHarness.java
index 8d5cdff79f..caf668ae8d 100644
--- a/testlib/src/main/java/com/diffplug/spotless/ResourceHarness.java
+++ b/testlib/src/main/java/com/diffplug/spotless/ResourceHarness.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2016-2024 DiffPlug
+ * Copyright 2016-2025 DiffPlug
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -189,10 +189,18 @@ public void hasContent(String expected) {
 			hasContent(expected, StandardCharsets.UTF_8);
 		}
 
+		public void hasDifferentContent(String expected) {
+			hasDifferentContent(expected, StandardCharsets.UTF_8);
+		}
+
 		public void hasContent(String expected, Charset charset) {
 			assertThat(file).usingCharset(charset).hasContent(expected);
 		}
 
+		public void hasDifferentContent(String expected, Charset charset) {
+			assertThat(file).usingCharset(charset).isNotEqualTo(expected);
+		}
+
 		public void hasLines(String... lines) {
 			hasContent(String.join("\n", Arrays.asList(lines)));
 		}
@@ -201,6 +209,10 @@ public void sameAsResource(String resource) throws IOException {
 			hasContent(getTestResource(resource));
 		}
 
+		public void notSameAsResource(String resource) throws IOException {
+			hasDifferentContent(getTestResource(resource));
+		}
+
 		public void matches(Consumer> conditions) throws IOException {
 			String content = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
 			conditions.accept(assertThat(content));
diff --git a/testlib/src/main/java/com/diffplug/spotless/tag/IdeaTest.java b/testlib/src/main/java/com/diffplug/spotless/tag/IdeaTest.java
new file mode 100644
index 0000000000..c9f351cdcb
--- /dev/null
+++ b/testlib/src/main/java/com/diffplug/spotless/tag/IdeaTest.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2021-2025 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.tag;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import org.junit.jupiter.api.Tag;
+
+@Target({TYPE, METHOD})
+@Retention(RUNTIME)
+@Tag("idea")
+public @interface IdeaTest {}
diff --git a/testlib/src/main/resources/java/idea/full.clean.java b/testlib/src/main/resources/java/idea/full.clean.java
new file mode 100644
index 0000000000..84d76d4650
--- /dev/null
+++ b/testlib/src/main/resources/java/idea/full.clean.java
@@ -0,0 +1,14 @@
+package com.example;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class Application {
+
+    public static void main(String[] args) {
+        SpringApplication.run(Application.class, args);
+    }
+
+}
+
diff --git a/testlib/src/main/resources/java/idea/full.dirty.java b/testlib/src/main/resources/java/idea/full.dirty.java
new file mode 100644
index 0000000000..83923130e6
--- /dev/null
+++ b/testlib/src/main/resources/java/idea/full.dirty.java
@@ -0,0 +1,22 @@
+package com.example;
+
+import org.springframework.boot.SpringApplication;
+
+
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@
+
+SpringBootApplication
+public 
+
+
+class     Application{
+
+    public static void      main(       String[] args) {
+        SpringApplication.     run(Application.class, args);
+    }
+
+}
+
diff --git a/testlib/src/test/java/com/diffplug/spotless/generic/IdeaStepTest.java b/testlib/src/test/java/com/diffplug/spotless/generic/IdeaStepTest.java
new file mode 100644
index 0000000000..299ea4e45f
--- /dev/null
+++ b/testlib/src/test/java/com/diffplug/spotless/generic/IdeaStepTest.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2024-2025 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.generic;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import com.diffplug.common.io.Files;
+import com.diffplug.spotless.FormatterStep;
+import com.diffplug.spotless.ResourceHarness;
+import com.diffplug.spotless.ThrowingEx;
+import com.diffplug.spotless.tag.IdeaTest;
+
+@IdeaTest
+class IdeaStepTest extends ResourceHarness {
+
+	@Test
+	void name() throws Exception {
+		FormatterStep step = IdeaStep.newBuilder(buildDir()).setUseDefaults(true).build();
+
+		String name = step.getName();
+
+		Assertions.assertEquals("IDEA", name);
+	}
+
+	@Test
+	void notFormattings() throws Exception {
+		File cleanFile = newFile("clean.java");
+		String cleanJava = ResourceHarness.getTestResource("java/idea/full.clean.java");
+		Files.write(cleanJava, cleanFile, StandardCharsets.UTF_8);
+		FormatterStep step = IdeaStep.newBuilder(buildDir()).setUseDefaults(true).build();
+
+		var result = step.format(cleanJava, cleanFile);
+
+		Assertions.assertEquals(cleanJava, result,
+				"formatting was applied to clean file");
+	}
+
+	@Test
+	void formattings() throws Exception {
+		File dirtyFile = newFile("dirty.java");
+		String dirtyJava = ResourceHarness.getTestResource("java/idea/full.dirty.java");
+		Files.write(dirtyJava, dirtyFile, StandardCharsets.UTF_8);
+		FormatterStep step = IdeaStep.newBuilder(buildDir()).setUseDefaults(true).build();
+
+		var result = step.format(dirtyJava, dirtyFile);
+
+		Assertions.assertNotEquals(dirtyJava, result,
+				"files were not changed after reformat");
+	}
+
+	@Test
+	void formattingsWorkWithDefaultParameters() throws Exception {
+		File dirtyFile = newFile("dirty.java");
+		String dirtyJava = ResourceHarness.getTestResource("java/idea/full.dirty.java");
+		Files.write(dirtyJava, dirtyFile, StandardCharsets.UTF_8);
+		FormatterStep step = IdeaStep.newBuilder(buildDir()).build();
+
+		var result = step.format(dirtyJava, dirtyFile);
+
+		Assertions.assertNotEquals(dirtyJava, result,
+				"files were not changed after reformat");
+	}
+
+	@Test
+	void formattingsWithoutDefaultDoesNothing() throws Exception {
+		File dirtyFile = newFile("dirty.java");
+		String dirtyJava = ResourceHarness.getTestResource("java/idea/full.dirty.java");
+		Files.write(dirtyJava, dirtyFile, StandardCharsets.UTF_8);
+		FormatterStep step = IdeaStep.newBuilder(buildDir()).setUseDefaults(false).build();
+
+		var result = step.format(dirtyJava, dirtyFile);
+
+		Assertions.assertEquals(dirtyJava, result,
+				"files were changed after reformat");
+	}
+
+	@Test
+	void configureFile() throws Exception {
+		File cleanFile = newFile("clean.java");
+		String cleanJava = ResourceHarness.getTestResource("java/idea/full.clean.java");
+		Files.write(cleanJava, cleanFile, StandardCharsets.UTF_8);
+		FormatterStep step = IdeaStep.newBuilder(buildDir()).setUseDefaults(true).build();
+
+		var result = step.format(cleanJava, cleanFile);
+
+		Assertions.assertEquals(cleanJava, result,
+				"formatting was applied to clean file");
+	}
+
+	private File buildDir = null;
+
+	protected File buildDir() {
+		if (this.buildDir == null) {
+			this.buildDir = ThrowingEx.get(() -> newFolder("build-dir"));
+		}
+		return this.buildDir;
+	}
+}