diff --git a/CHANGES.md b/CHANGES.md index fad1120a61..0567f21a6a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,12 @@ This document is intended for Spotless developers. We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `1.27.0`). ## [Unreleased] +### Added +* The ability to shell out to formatters with their own executables. ([#672](https://github.com/diffplug/spotless/pull/672)) + * `ProcessRunner` makes it easy to efficiently and debuggably call foreign executables, and pipe their stdout and stderr to strings. + * `ForeignExe` finds executables on the path (or other strategies), and confirms that they have the correct version (to facilitate Spotless' caching). If the executable is not present or the wrong version, it points the user towards how to fix the problem. + * These classes were used to add support for [python black](https://github.com/psf/black) and [clang-format](https://clang.llvm.org/docs/ClangFormat.html). + * Incidental to this effort, `FormatterFunc.Closeable` now has new methods which make resource-handling safer. The old method is still available as `ofDangerous`, but it should not be used outside of a testing situation. There are some legacy usages of `ofDangerous` in the codebase, and it would be nice to fix them, but the existing usages are using it safely. ## [2.2.2] - 2020-08-21 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b3ef78542..7d2546cee1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,6 +104,15 @@ Here's a checklist for creating a new step for Spotless: In order for Spotless' model to work, each step needs to look only at the `String` input, otherwise they cannot compose. However, there are some cases where the source `File` is useful, such as to look at the file extension. In this case, you can pass a `FormatterFunc.NeedsFile` instead of a `FormatterFunc`. This should only be used in [rare circumstances](https://github.com/diffplug/spotless/pull/637), be careful that you don't accidentally depend on the bytes inside of the `File`! +### Integrating outside the JVM + +There are many great formatters (prettier, clang-format, black, etc.) which live entirely outside the JVM. We have two main strategies for these: + +- shell out to an external command for every file (used by clang-format and black) // TODO: link +- open a headless server and make http calls to it from Spotless (used by prettier) // TODO: link + +Because of Spotless' up-to-date checking and [git ratcheting](https://github.com/diffplug/spotless/tree/main/plugin-gradle#ratchet), Spotless actually doesn't have to call formatters very often, so even an expensive shell call for every single invocation isn't that bad. Anything that works is better than nothing, and we can always speed things up later if it feels too slow (but it probably won't). + ## How to enable the _ext projects The `_ext` projects are disabled per default, since: diff --git a/README.md b/README.md index c4c4966e1e..3f4340c370 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ output = [ [![License Apache](https://img.shields.io/badge/license-apache-brightgreen.svg)](https://tldrlegal.com/license/apache-license-2.0-(apache-2.0)) -Spotless can format <java | kotlin | scala | sql | groovy | javascript | flow | typeScript | css | scss | less | jsx | vue | graphql | json | yaml | markdown | license headers | anything> using <gradle | maven | anything>. +Spotless can format <antlr | c | c# | c++ | css | flow | graphql | groovy | html | java | javascript | json | jsx | kotlin | less | license headers | markdown | objective-c | protobuf | python | scala | scss | sql | typeScript | vue | yaml | anything> using <gradle | maven | anything>. - [Spotless for Gradle](plugin-gradle) - [VS Code extension](https://marketplace.visualstudio.com/items?itemName=richardwillis.vscode-spotless-gradle) @@ -46,6 +46,7 @@ lib('generic.ReplaceRegexStep') +'{{yes}} | {{yes}} lib('generic.ReplaceStep') +'{{yes}} | {{yes}} | {{no}} |', lib('generic.TrimTrailingWhitespaceStep') +'{{yes}} | {{yes}} | {{no}} |', lib('antlr4.Antlr4FormatterStep') +'{{yes}} | {{yes}} | {{no}} |', +lib('cpp.ClangFormatStep') +'{{yes}} | {{no}} | {{no}} |', extra('cpp.EclipseFormatterStep') +'{{yes}} | {{yes}} | {{no}} |', extra('groovy.GrEclipseFormatterStep') +'{{yes}} | {{no}} | {{no}} |', lib('java.GoogleJavaFormatStep') +'{{yes}} | {{yes}} | {{no}} |', @@ -57,6 +58,7 @@ lib('kotlin.KtfmtStep') +'{{yes}} | {{yes}} lib('markdown.FreshMarkStep') +'{{yes}} | {{no}} | {{no}} |', lib('npm.PrettierFormatterStep') +'{{yes}} | {{yes}} | {{no}} |', lib('npm.TsFmtFormatterStep') +'{{yes}} | {{yes}} | {{no}} |', +lib('python.BlackStep') +'{{yes}} | {{no}} | {{no}} |', lib('scala.ScalaFmtStep') +'{{yes}} | {{yes}} | {{no}} |', lib('sql.DBeaverSQLFormatterStep') +'{{yes}} | {{no}} | {{no}} |', extra('wtp.EclipseWtpFormatterStep') +'{{yes}} | {{yes}} | {{no}} |', @@ -78,6 +80,7 @@ extra('wtp.EclipseWtpFormatterStep') +'{{yes}} | {{yes}} | [`generic.ReplaceStep`](lib/src/main/java/com/diffplug/spotless/generic/ReplaceStep.java) | :+1: | :+1: | :white_large_square: | | [`generic.TrimTrailingWhitespaceStep`](lib/src/main/java/com/diffplug/spotless/generic/TrimTrailingWhitespaceStep.java) | :+1: | :+1: | :white_large_square: | | [`antlr4.Antlr4FormatterStep`](lib/src/main/java/com/diffplug/spotless/antlr4/Antlr4FormatterStep.java) | :+1: | :+1: | :white_large_square: | +| [`cpp.ClangFormatStep`](lib/src/main/java/com/diffplug/spotless/cpp/ClangFormatStep.java) | :+1: | :white_large_square: | :white_large_square: | | [`cpp.EclipseFormatterStep`](lib-extra/src/main/java/com/diffplug/spotless/extra/cpp/EclipseFormatterStep.java) | :+1: | :+1: | :white_large_square: | | [`groovy.GrEclipseFormatterStep`](lib-extra/src/main/java/com/diffplug/spotless/extra/groovy/GrEclipseFormatterStep.java) | :+1: | :white_large_square: | :white_large_square: | | [`java.GoogleJavaFormatStep`](lib/src/main/java/com/diffplug/spotless/java/GoogleJavaFormatStep.java) | :+1: | :+1: | :white_large_square: | @@ -89,6 +92,7 @@ extra('wtp.EclipseWtpFormatterStep') +'{{yes}} | {{yes}} | [`markdown.FreshMarkStep`](lib/src/main/java/com/diffplug/spotless/markdown/FreshMarkStep.java) | :+1: | :white_large_square: | :white_large_square: | | [`npm.PrettierFormatterStep`](lib/src/main/java/com/diffplug/spotless/npm/PrettierFormatterStep.java) | :+1: | :+1: | :white_large_square: | | [`npm.TsFmtFormatterStep`](lib/src/main/java/com/diffplug/spotless/npm/TsFmtFormatterStep.java) | :+1: | :+1: | :white_large_square: | +| [`python.BlackStep`](lib/src/main/java/com/diffplug/spotless/python/BlackStep.java) | :+1: | :white_large_square: | :white_large_square: | | [`scala.ScalaFmtStep`](lib/src/main/java/com/diffplug/spotless/scala/ScalaFmtStep.java) | :+1: | :+1: | :white_large_square: | | [`sql.DBeaverSQLFormatterStep`](lib/src/main/java/com/diffplug/spotless/sql/DBeaverSQLFormatterStep.java) | :+1: | :white_large_square: | :white_large_square: | | [`wtp.EclipseWtpFormatterStep`](lib-extra/src/main/java/com/diffplug/spotless/extra/wtp/EclipseWtpFormatterStep.java) | :+1: | :+1: | :white_large_square: | diff --git a/gradle/special-tests.gradle b/gradle/special-tests.gradle new file mode 100644 index 0000000000..91aab76df5 --- /dev/null +++ b/gradle/special-tests.gradle @@ -0,0 +1,16 @@ +def special = [ + 'Npm', + 'Black', + 'Clang' +] + +tasks.named('test') { + useJUnit { excludeCategories special.collect { "com.diffplug.spotless.category.${it}Test" } as String[] } +} + +special.forEach { + def category = "com.diffplug.spotless.category.${it}Test" + tasks.register("${it}Test", Test) { + useJUnit { includeCategories category } + } +} diff --git a/lib/src/main/java/com/diffplug/spotless/ForeignExe.java b/lib/src/main/java/com/diffplug/spotless/ForeignExe.java new file mode 100644 index 0000000000..1f707fcbb8 --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/ForeignExe.java @@ -0,0 +1,135 @@ +/* + * Copyright 2020 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; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.annotation.Nullable; + +/** + * Finds a foreign executable and checks its version. + * If either part of that fails, it shows you why + * and helps you fix it. + * + * Usage: `ForeignExe.nameAndVersion("grep", "2.5.7").confirmVersionAndGetAbsolutePath()` + * will find grep, confirm that it is version 2.5.7, and then return. + */ +public class ForeignExe { + private @Nullable String pathToExe; + private String versionFlag = "--version"; + private Pattern versionRegex = Pattern.compile("version (\\S*)"); + private @Nullable String fixCantFind, fixWrongVersion; + + // MANDATORY + private String name; + private String version; + + /** The name of the executable, used by "where" (win) and "which" (unix). */ + public static ForeignExe nameAndVersion(String exeName, String version) { + ForeignExe foreign = new ForeignExe(); + foreign.name = Objects.requireNonNull(exeName); + foreign.version = Objects.requireNonNull(version); + return foreign; + } + + /** The flag which causes the exe to print its version (defaults to --version). */ + public ForeignExe versionFlag(String versionFlag) { + this.versionFlag = Objects.requireNonNull(versionFlag); + return this; + } + + /** A regex which can parse the version out of the output of the {@link #versionFlag(String)} command (defaults to `version (\\S*)`) */ + public ForeignExe versionRegex(Pattern versionRegex) { + this.versionRegex = Objects.requireNonNull(versionRegex); + return this; + } + + /** Use {version} anywhere you would like to inject the actual version string. */ + public ForeignExe fixCantFind(String msg) { + this.fixCantFind = msg; + return this; + } + + /** Use {version} or {versionFound} anywhere you would like to inject the actual version strings. */ + public ForeignExe fixWrongVersion(String msg) { + this.fixWrongVersion = msg; + return this; + } + + /** Path to the executable. If null, will search for the executable on the system path. */ + public ForeignExe pathToExe(@Nullable String pathToExe) { + this.pathToExe = pathToExe; + return this; + } + + /** + * Searches for the executable and confirms that it has the expected version. + * If it can't find the executable, or if it doesn't have the correct version, + * throws an exception with a message describing how to fix. + */ + public String confirmVersionAndGetAbsolutePath() throws IOException, InterruptedException { + try (ProcessRunner runner = new ProcessRunner()) { + String exeAbsPath; + if (pathToExe != null) { + exeAbsPath = pathToExe; + } else { + ProcessRunner.Result cmdWhich = runner.shellWinUnix("where " + name, "which " + name); + if (cmdWhich.exitNotZero()) { + throw cantFind("Unable to find " + name + " on path", cmdWhich); + } else { + exeAbsPath = cmdWhich.assertExitZero(Charset.defaultCharset()).trim(); + } + } + ProcessRunner.Result cmdVersion = runner.exec(exeAbsPath, versionFlag); + if (cmdVersion.exitNotZero()) { + throw cantFind("Unable to run " + exeAbsPath, cmdVersion); + } + Matcher versionMatcher = versionRegex.matcher(cmdVersion.assertExitZero(Charset.defaultCharset())); + if (!versionMatcher.find()) { + throw cantFind("Unable to parse version with /" + versionRegex + "/", cmdVersion); + } + String versionFound = versionMatcher.group(1); + if (!versionFound.equals(version)) { + throw wrongVersion("You specified version " + version + ", but Spotless found " + versionFound, cmdVersion, versionFound); + } + return exeAbsPath; + } + } + + private RuntimeException cantFind(String message, ProcessRunner.Result cmd) { + return exceptionFmt(message, cmd, fixCantFind == null ? null : fixCantFind.replace("{version}", version)); + } + + private RuntimeException wrongVersion(String message, ProcessRunner.Result cmd, String versionFound) { + return exceptionFmt(message, cmd, fixWrongVersion == null ? null : fixWrongVersion.replace("{version}", version).replace("{versionFound}", versionFound)); + } + + private RuntimeException exceptionFmt(String msgPrimary, ProcessRunner.Result cmd, @Nullable String msgFix) { + StringBuilder errorMsg = new StringBuilder(); + errorMsg.append(msgPrimary); + errorMsg.append('\n'); + if (msgFix != null) { + errorMsg.append(msgFix); + errorMsg.append('\n'); + } + errorMsg.append(cmd.toString()); + return new RuntimeException(errorMsg.toString()); + } +} diff --git a/lib/src/main/java/com/diffplug/spotless/FormatterFunc.java b/lib/src/main/java/com/diffplug/spotless/FormatterFunc.java index 6e87346bbb..bd17e7fc8f 100644 --- a/lib/src/main/java/com/diffplug/spotless/FormatterFunc.java +++ b/lib/src/main/java/com/diffplug/spotless/FormatterFunc.java @@ -40,8 +40,17 @@ interface Closeable extends FormatterFunc, AutoCloseable { @Override void close(); - /** Creates a {@link Closeable} from an AutoCloseable and a function. */ - public static Closeable of(AutoCloseable closeable, FormatterFunc function) { + /** + * Dangerous way to create a {@link Closeable} from an AutoCloseable and a function. + * + * It's important for FormatterStep's to allocate their resources as lazily as possible. + * It's easy to create a resource inside the state, and not realize that it may not be + * released. It's far better to use one of the non-deprecated `of()` methods below. + * + * The bug (and its fix) which is easy to write using this method: https://github.com/diffplug/spotless/commit/7f16ecca031810b5e6e6f647e1f10a6d2152d9f4 + * How the `of()` methods below make the correct thing easier to write and safer: https://github.com/diffplug/spotless/commit/18c10f9c93d6f18f753233d0b5f028d5f0961916 + */ + public static Closeable ofDangerous(AutoCloseable closeable, FormatterFunc function) { Objects.requireNonNull(closeable, "closeable"); Objects.requireNonNull(function, "function"); return new Closeable() { @@ -52,12 +61,73 @@ public void close() { @Override public String apply(String unix, File file) throws Exception { - return function.apply(Objects.requireNonNull(unix), Objects.requireNonNull(file)); + return function.apply(unix, file); + } + + @Override + public String apply(String unix) throws Exception { + return function.apply(unix); + } + }; + } + + /** @deprecated synonym for {@link #ofDangerous(AutoCloseable, FormatterFunc)} */ + @Deprecated + public static Closeable of(AutoCloseable closeable, FormatterFunc function) { + return ofDangerous(closeable, function); + } + + @FunctionalInterface + interface ResourceFunc { + String apply(T resource, String unix) throws Exception; + } + + /** Creates a {@link FormatterFunc.Closeable} which uses the given resource to execute the format function. */ + public static Closeable of(T resource, ResourceFunc function) { + Objects.requireNonNull(resource, "resource"); + Objects.requireNonNull(function, "function"); + return new Closeable() { + @Override + public void close() { + ThrowingEx.run(resource::close); + } + + @Override + public String apply(String unix, File file) throws Exception { + return function.apply(resource, unix); + } + + @Override + public String apply(String unix) throws Exception { + return function.apply(resource, unix); + } + }; + } + + @FunctionalInterface + interface ResourceFuncNeedsFile { + String apply(T resource, String unix, File file) throws Exception; + } + + /** Creates a {@link FormatterFunc.Closeable} which uses the given resource to execute the file-dependent format function. */ + public static Closeable of(T resource, ResourceFuncNeedsFile function) { + Objects.requireNonNull(resource, "resource"); + Objects.requireNonNull(function, "function"); + return new Closeable() { + @Override + public void close() { + ThrowingEx.run(resource::close); + } + + @Override + public String apply(String unix, File file) throws Exception { + FormatterStepImpl.checkNotSentinel(file); + return function.apply(resource, unix, file); } @Override public String apply(String unix) throws Exception { - return function.apply(Objects.requireNonNull(unix)); + return apply(unix, FormatterStepImpl.SENTINEL); } }; } @@ -80,9 +150,7 @@ interface NeedsFile extends FormatterFunc { @Override default String apply(String unix, File file) throws Exception { - if (file == FormatterStepImpl.SENTINEL) { - throw new IllegalArgumentException("This step requires the underlying file. If this is a test, use StepHarnessWithFile"); - } + FormatterStepImpl.checkNotSentinel(file); return applyWithFile(unix, file); } diff --git a/lib/src/main/java/com/diffplug/spotless/FormatterStepImpl.java b/lib/src/main/java/com/diffplug/spotless/FormatterStepImpl.java index 71e00faf2f..061aff6af9 100644 --- a/lib/src/main/java/com/diffplug/spotless/FormatterStepImpl.java +++ b/lib/src/main/java/com/diffplug/spotless/FormatterStepImpl.java @@ -114,4 +114,10 @@ protected String format(Integer state, String rawUnix, File file) throws Excepti /** A dummy SENTINEL file. */ static final File SENTINEL = new File(""); + + static void checkNotSentinel(File file) { + if (file == SENTINEL) { + throw new IllegalArgumentException("This step requires the underlying file. If this is a test, use StepHarnessWithFile"); + } + } } diff --git a/lib/src/main/java/com/diffplug/spotless/ProcessRunner.java b/lib/src/main/java/com/diffplug/spotless/ProcessRunner.java new file mode 100644 index 0000000000..7361a583a0 --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/ProcessRunner.java @@ -0,0 +1,198 @@ +/* + * Copyright 2020 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; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.function.BiConsumer; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +/** + * Shelling out to a process is harder than it ought to be in Java. + * If you don't read stdout and stderr on their own threads, you risk + * deadlock on a clogged buffer. + * + * ProcessRunner allocates two threads specifically for the purpose of + * flushing stdout and stderr to buffers. These threads will remain alive until + * the ProcessRunner is closed, so it is especially useful for repeated + * calls to an external process. + */ +public class ProcessRunner implements AutoCloseable { + private final ExecutorService threadStdOut = Executors.newSingleThreadExecutor(); + private final ExecutorService threadStdErr = Executors.newSingleThreadExecutor(); + private final ByteArrayOutputStream bufStdOut = new ByteArrayOutputStream(); + private final ByteArrayOutputStream bufStdErr = new ByteArrayOutputStream(); + + public ProcessRunner() {} + + /** Executes the given shell command (using `cmd` on windows and `sh` on unix). */ + public Result shell(String cmd) throws IOException, InterruptedException { + return shellWinUnix(cmd, cmd); + } + + /** Executes the given shell command (using `cmd` on windows and `sh` on unix). */ + public Result shellWinUnix(String cmdWin, String cmdUnix) throws IOException, InterruptedException { + List args; + if (FileSignature.machineIsWin()) { + args = Arrays.asList("cmd", "/c", cmdWin); + } else { + args = Arrays.asList("sh", "-c", cmdUnix); + } + return exec(args); + } + + /** Creates a process with the given arguments. */ + public Result exec(String... args) throws IOException, InterruptedException { + return exec(Arrays.asList(args)); + } + + /** Creates a process with the given arguments, the given byte array is written to stdin immediately. */ + public Result exec(byte[] stdin, String... args) throws IOException, InterruptedException { + return exec(stdin, Arrays.asList(args)); + } + + /** Creates a process with the given arguments. */ + public Result exec(List args) throws IOException, InterruptedException { + return exec(new byte[0], args); + } + + /** Creates a process with the given arguments, the given byte array is written to stdin immediately. */ + public Result exec(byte[] stdin, List args) throws IOException, InterruptedException { + ProcessBuilder builder = new ProcessBuilder(args); + Process process = builder.start(); + Future outputFut = threadStdOut.submit(() -> drainToBytes(process.getInputStream(), bufStdOut)); + Future errorFut = threadStdErr.submit(() -> drainToBytes(process.getErrorStream(), bufStdErr)); + // write stdin + process.getOutputStream().write(stdin); + process.getOutputStream().close(); + // wait for the process to finish + int exitCode = process.waitFor(); + try { + // collect the output + return new Result(args, exitCode, outputFut.get(), errorFut.get()); + } catch (ExecutionException e) { + throw ThrowingEx.asRuntime(e); + } + } + + private static void drain(InputStream input, OutputStream output) throws IOException { + byte[] buf = new byte[1024]; + int numRead; + while ((numRead = input.read(buf)) != -1) { + output.write(buf, 0, numRead); + } + } + + private static byte[] drainToBytes(InputStream input, ByteArrayOutputStream buffer) throws IOException { + buffer.reset(); + drain(input, buffer); + return buffer.toByteArray(); + } + + @Override + public void close() { + threadStdOut.shutdown(); + threadStdErr.shutdown(); + } + + @SuppressFBWarnings({"EI_EXPOSE_REP", "EI_EXPOSE_REP2"}) + public static class Result { + private final List args; + private final int exitCode; + private final byte[] stdOut, stdErr; + + public Result(List args, int exitCode, byte[] stdOut, byte[] stdErr) { + this.args = args; + this.exitCode = exitCode; + this.stdOut = stdOut; + this.stdErr = stdErr; + } + + public List args() { + return args; + } + + public int exitCode() { + return exitCode; + } + + public byte[] stdOut() { + return stdOut; + } + + public byte[] stdErr() { + return stdErr; + } + + /** Returns true if the exit code was not zero. */ + public boolean exitNotZero() { + return exitCode != 0; + } + + /** + * Asserts that the exit code was zero, and if so, returns + * the content of stdout encoded with the given charset. + * + * If the exit code was not zero, throws an exception + * with useful debugging information. + */ + public String assertExitZero(Charset charset) { + if (exitCode == 0) { + return new String(stdOut, charset); + } else { + throw new RuntimeException(toString()); + } + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("> arguments: " + args + "\n"); + builder.append("> exit code: " + exitCode + "\n"); + BiConsumer perStream = (name, content) -> { + String string = new String(content, Charset.defaultCharset()).trim(); + if (string.isEmpty()) { + builder.append("> " + name + ": (empty)\n"); + } else { + String[] lines = string.replace("\r", "").split("\n"); + if (lines.length == 1) { + builder.append("> " + name + ": " + lines[0] + "\n"); + } else { + builder.append("> " + name + ": (below)\n"); + for (String line : lines) { + builder.append("> "); + builder.append(line); + builder.append('\n'); + } + } + } + }; + perStream.accept(" stdout", stdOut); + perStream.accept(" stderr", stdErr); + return builder.toString(); + } + } +} diff --git a/lib/src/main/java/com/diffplug/spotless/cpp/ClangFormatStep.java b/lib/src/main/java/com/diffplug/spotless/cpp/ClangFormatStep.java new file mode 100644 index 0000000000..ade6978359 --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/cpp/ClangFormatStep.java @@ -0,0 +1,119 @@ +/* + * Copyright 2020 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.cpp; + +import java.io.File; +import java.io.IOException; +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import javax.annotation.Nullable; + +import com.diffplug.spotless.ForeignExe; +import com.diffplug.spotless.FormatterFunc; +import com.diffplug.spotless.FormatterStep; +import com.diffplug.spotless.ProcessRunner; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +public class ClangFormatStep { + public static String name() { + return "clang"; + } + + public static String defaultVersion() { + return "10.0.1"; + } + + private final String version; + private final @Nullable String pathToExe; + private final @Nullable String style; + + private ClangFormatStep(String version, @Nullable String pathToExe, @Nullable String style) { + this.version = version; + this.pathToExe = pathToExe; + this.style = style; + } + + public static ClangFormatStep withVersion(String version) { + return new ClangFormatStep(version, null, null); + } + + public ClangFormatStep withStyle(String style) { + return new ClangFormatStep(version, pathToExe, style); + } + + public ClangFormatStep withPathToExe(String pathToExe) { + return new ClangFormatStep(version, pathToExe, style); + } + + public FormatterStep create() { + return FormatterStep.createLazy(name(), this::createState, State::toFunc); + } + + private State createState() throws IOException, InterruptedException { + String howToInstall = "" + + "You can download clang-format from https://releases.llvm.org and " + + "then point Spotless to it with `pathToExe('/path/to/clang-format')` " + + "or you can use your platform's package manager:" + + "\n win: choco install llvm --version {version} (try dropping version if it fails)" + + "\n mac: brew install clang-format (TODO: how to specify version?)" + + "\n linux: apt install clang-format (try clang-format-{version} with dropped minor versions)" + + "\n github issue to handle this better: https://github.com/diffplug/spotless/issues/673"; + String exeAbsPath = ForeignExe.nameAndVersion("clang-format", version) + .pathToExe(pathToExe) + .fixCantFind(howToInstall) + .fixWrongVersion( + "You can tell Spotless to use the version you already have with `clangFormat('{versionFound}')`" + + "or you can download the currently specified version, {version}.\n" + howToInstall) + .confirmVersionAndGetAbsolutePath(); + return new State(this, exeAbsPath); + } + + @SuppressFBWarnings("SE_TRANSIENT_FIELD_NOT_RESTORED") + static class State implements Serializable { + private static final long serialVersionUID = -1825662356883926318L; + // used for up-to-date checks and caching + final String version; + final @Nullable String style; + // used for executing + final transient List args; + + State(ClangFormatStep step, String exeAbsPath) { + this.version = step.version; + this.style = step.style; + args = new ArrayList<>(2); + args.add(exeAbsPath); + if (style != null) { + args.add("--style=" + style); + } + } + + String format(ProcessRunner runner, String input, File file) throws IOException, InterruptedException { + String[] processArgs = args.toArray(new String[args.size() + 1]); + // add an argument to the end + processArgs[args.size()] = "--assume-filename=" + file.getName(); + return runner.exec(input.getBytes(StandardCharsets.UTF_8), processArgs).assertExitZero(StandardCharsets.UTF_8); + } + + FormatterFunc.Closeable toFunc() { + ProcessRunner runner = new ProcessRunner(); + return FormatterFunc.Closeable.of(runner, this::format); + } + } +} diff --git a/lib/src/main/java/com/diffplug/spotless/npm/PrettierFormatterStep.java b/lib/src/main/java/com/diffplug/spotless/npm/PrettierFormatterStep.java index 46487c5ce5..fb7c3ef1f9 100644 --- a/lib/src/main/java/com/diffplug/spotless/npm/PrettierFormatterStep.java +++ b/lib/src/main/java/com/diffplug/spotless/npm/PrettierFormatterStep.java @@ -84,7 +84,7 @@ public FormatterFunc createFormatterFunc() { ServerProcessInfo prettierRestServer = npmRunServer(); PrettierRestService restService = new PrettierRestService(prettierRestServer.getBaseUrl()); String prettierConfigOptions = restService.resolveConfig(this.prettierConfig.getPrettierConfigPath(), this.prettierConfig.getOptions()); - return Closeable.of(() -> endServer(restService, prettierRestServer), new PrettierFilePathPassingFormatterFunc(prettierConfigOptions, restService)); + return Closeable.ofDangerous(() -> endServer(restService, prettierRestServer), new PrettierFilePathPassingFormatterFunc(prettierConfigOptions, restService)); } catch (Exception e) { throw ThrowingEx.asRuntime(e); } diff --git a/lib/src/main/java/com/diffplug/spotless/npm/TsFmtFormatterStep.java b/lib/src/main/java/com/diffplug/spotless/npm/TsFmtFormatterStep.java index 2245cd0b05..3d2b1fcbc6 100644 --- a/lib/src/main/java/com/diffplug/spotless/npm/TsFmtFormatterStep.java +++ b/lib/src/main/java/com/diffplug/spotless/npm/TsFmtFormatterStep.java @@ -90,7 +90,7 @@ public FormatterFunc createFormatterFunc() { Map tsFmtOptions = unifyOptions(); ServerProcessInfo tsfmtRestServer = npmRunServer(); TsFmtRestService restService = new TsFmtRestService(tsfmtRestServer.getBaseUrl()); - return Closeable.of(() -> endServer(restService, tsfmtRestServer), input -> restService.format(input, tsFmtOptions)); + return Closeable.ofDangerous(() -> endServer(restService, tsfmtRestServer), input -> restService.format(input, tsFmtOptions)); } catch (Exception e) { throw ThrowingEx.asRuntime(e); } diff --git a/lib/src/main/java/com/diffplug/spotless/python/BlackStep.java b/lib/src/main/java/com/diffplug/spotless/python/BlackStep.java new file mode 100644 index 0000000000..01369b14ae --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/python/BlackStep.java @@ -0,0 +1,94 @@ +/* + * Copyright 2020 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.python; + +import java.io.IOException; +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; + +import javax.annotation.Nullable; + +import com.diffplug.spotless.ForeignExe; +import com.diffplug.spotless.FormatterFunc; +import com.diffplug.spotless.FormatterStep; +import com.diffplug.spotless.ProcessRunner; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +public class BlackStep { + public static String name() { + return "black"; + } + + public static String defaultVersion() { + return "19.10b0"; + } + + private final String version; + private final @Nullable String pathToExe; + + private BlackStep(String version, @Nullable String pathToExe) { + this.version = version; + this.pathToExe = pathToExe; + } + + public static BlackStep withVersion(String version) { + return new BlackStep(version, null); + } + + public BlackStep withPathToExe(String pathToExe) { + return new BlackStep(version, pathToExe); + } + + public FormatterStep create() { + return FormatterStep.createLazy(name(), this::createState, State::toFunc); + } + + private State createState() throws IOException, InterruptedException { + String trackingIssue = "\n github issue to handle this better: https://github.com/diffplug/spotless/issues/674"; + String exeAbsPath = ForeignExe.nameAndVersion("black", version) + .pathToExe(pathToExe) + .fixCantFind("Try running `pip install black=={version}`, or else tell Spotless where it is with `black().pathToExe('path/to/executable')`" + trackingIssue) + .fixWrongVersion("Try running `pip install --force-reinstall black=={version}`, or else specify `black('{versionFound}')` to Spotless" + trackingIssue) + .confirmVersionAndGetAbsolutePath(); + return new State(this, exeAbsPath); + } + + @SuppressFBWarnings("SE_TRANSIENT_FIELD_NOT_RESTORED") + static class State implements Serializable { + private static final long serialVersionUID = -1825662356883926318L; + // used for up-to-date checks and caching + final String version; + // used for executing + final transient List args; + + State(BlackStep step, String exeAbsPath) { + this.version = step.version; + this.args = Arrays.asList(exeAbsPath, "-"); + } + + String format(ProcessRunner runner, String input) throws IOException, InterruptedException { + return runner.exec(input.getBytes(StandardCharsets.UTF_8), args).assertExitZero(StandardCharsets.UTF_8); + } + + FormatterFunc.Closeable toFunc() { + ProcessRunner runner = new ProcessRunner(); + return FormatterFunc.Closeable.of(runner, this::format); + } + } +} diff --git a/plugin-gradle/CHANGES.md b/plugin-gradle/CHANGES.md index ff9da98684..73037ab305 100644 --- a/plugin-gradle/CHANGES.md +++ b/plugin-gradle/CHANGES.md @@ -3,6 +3,10 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `3.27.0`). ## [Unreleased] +### Added +- It is now much easier for Spotless to [integrate formatters with native executables](../../CONTRIBUTING.md#integrating-outside-the-jvm). ([#672](https://github.com/diffplug/spotless/pull/672)) + - Added support for [python](../#python), specifically [black](../#black). + - Added support for [clang-format](../#clang-format) for all formats. ### Fixed * If you executed `gradlew spotlessCheck` multiple times within a single second (hard in practice, easy for a unit test) you could sometimes get an erroneous failure message. Fixed in [#671](https://github.com/diffplug/spotless/pull/671). diff --git a/plugin-gradle/README.md b/plugin-gradle/README.md index 330e79c584..b04ee0deac 100644 --- a/plugin-gradle/README.md +++ b/plugin-gradle/README.md @@ -31,7 +31,7 @@ output = [ output = prefixDelimiterReplace(input, 'https://javadoc.io/static/com.diffplug.spotless/spotless-plugin-gradle/', '/', versionLast) --> -Spotless is a general-purpose formatting plugin used by [3,500 projects on GitHub (May 2020)](https://github.com/search?l=gradle&q=spotless&type=Code). It is completely à la carte, but also includes powerful "batteries-included" if you opt-in. +Spotless is a general-purpose formatting plugin used by [4,000 projects on GitHub (August 2020)](https://github.com/search?l=gradle&q=spotless&type=Code). It is completely à la carte, but also includes powerful "batteries-included" if you opt-in. To people who use your build, it looks like this ([IDE support also available]()): @@ -50,23 +50,31 @@ user@machine repo % ./gradlew build BUILD SUCCESSFUL ``` +Spotless supports all of Gradle's built-in performance features (incremental build, remote and local buildcache, lazy configuration, etc), and also automatically fixes [idempotence issues](https://github.com/diffplug/spotless/blob/main/PADDEDCELL.md), infers [line-endings from git](#line-endings-and-encodings-invisible-stuff), is cautious about [misconfigured encoding](https://github.com/diffplug/spotless/blob/08340a11566cdf56ecf50dbd4d557ed84a70a502/testlib/src/test/java/com/diffplug/spotless/EncodingErrorMsgTest.java#L34-L38) bugs, and can use git to [ratchet formatting](#ratchet) without "format-everything" commits. + + ### Table of Contents - [**Quickstart**](#quickstart) - [Requirements](#requirements) - **Languages** - - [Java](#java) ([google-java-format](#google-java-format), [eclipse jdt](#eclipse-jdt), [prettier](#prettier)) + - [Java](#java) ([google-java-format](#google-java-format), [eclipse jdt](#eclipse-jdt), [clang-format](#clang-format), [prettier](#prettier)) - [Groovy](#groovy) ([eclipse groovy](#eclipse-groovy)) - [Kotlin](#kotlin) ([ktlint](#ktlint), [ktfmt](#ktfmt), [prettier](#prettier)) - [Scala](#scala) ([scalafmt](#scalafmt)) - - [C/C++](#cc) ([eclipse cdt](#eclipse-cdt)) + - [C/C++](#cc) ([clang-format](#clang-format), [eclipse cdt](#eclipse-cdt)) + - [Python](#python) ([black](#black)) - [FreshMark](#freshmark) aka markdown - [Antlr4](#antlr4) ([antlr4formatter](#antlr4formatter)) - [SQL](#sql) ([dbeaver](#dbeaver), [prettier](#prettier)) - [Typescript](#typescript) ([tsfmt](#tsfmt), [prettier](#prettier)) - Multiple languages - [Prettier](#prettier) ([plugins](#prettier-plugins), [npm detection](#npm-detection)) + - javascript, jsx, angular, vue, flow, typescript, css, less, scss, html, json, graphql, markdown, ymaml + - [clang-format](#clang-format) + - c, c++, c#, objective-c, protobuf, javascript, java - [eclipse web tools platform](#eclipse-web-tools-platform) + - css, html, js, json, xml - **Language independent** - [License header](#license-header) ([slurp year from git](#retroactively-slurp-years-from-git-history)) - [How can I enforce formatting gradually? (aka "ratchet")](#ratchet) @@ -116,8 +124,6 @@ Spotless consists of a list of formats (in the example above, `misc` and `java`) All the generic steps live in [`FormatExtension`](https://javadoc.io/static/com.diffplug.spotless/spotless-plugin-gradle/5.1.2/com/diffplug/gradle/spotless/FormatExtension.html), and there are many language-specific steps which live in its language-specific subclasses, which are described below. -Spotless supports all of Gradle's built-in performance features (incremental build, buildcache, lazy configuration, etc), and also automatically fixes [idempotence issues](https://github.com/diffplug/spotless/blob/main/PADDEDCELL.md), infers [line-endings from git](#line-endings-and-encodings-invisible-stuff), is cautious about [misconfigured encoding](https://github.com/diffplug/spotless/blob/08340a11566cdf56ecf50dbd4d557ed84a70a502/testlib/src/test/java/com/diffplug/spotless/EncodingErrorMsgTest.java#L34-L38) bugs, and can use git to [ratchet formatting](#ratchet) without "format-everything" commits. - ### Requirements Spotless requires JRE 8+, and Gradle 5.4+. Some steps require JRE 11+, `Unsupported major.minor version` means you're using a step that needs a newer JRE. @@ -143,6 +149,7 @@ spotless { googleJavaFormat() // has its own section below eclipse() // has its own section below prettier() // has its own section below + clangFormat() // has its own section below licenseHeader '/* (C) $YEAR */' // or licenseHeaderFile } @@ -322,7 +329,8 @@ spotless { cpp { target 'src/native/**' // you have to set the target manually - eclipseCdt() // has its own section below + clangFormat() // has its own section below + eclipseCdt() // has its own section below licenseHeader '/* (C) $YEAR */' // or licenseHeaderFile } @@ -344,6 +352,38 @@ spotles { } ``` +## Python + +`com.diffplug.gradle.spotless.PythonExtension` [javadoc](https://javadoc.io/static/com.diffplug.spotless/spotless-plugin-gradle/5.1.2/com/diffplug/gradle/spotless/PythonExtension.html), [code](https://github.com/diffplug/spotless/blob/main/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/PythonExtension.java) + +```gradle +spotless { + python { + target 'src/main/**/*.py' // have to set manually + + black() // has its own section below + + licenseHeader '/* (C) $YEAR */', 'REGEX_TO_DEFINE_TOP_OF_FILE' // or licenseHeaderFile + } +} +``` + +### black + +[homepage](https://github.com/psf/black). [changelog](https://github.com/psf/black/blob/master/CHANGES.md). + +```gradle +black('19.10b0') // version is optional + +// if black is not on your path, you must specify its location manually +clangFormat().pathToExe('C:/myuser/.pyenv/versions/3.8.0/scripts/black.exe') +// Spotless always checks the version of the black it is using +// and will fail with an error if it does not match the expected version +// (whether manually specified or default). If there is a problem, Spotless +// will suggest commands to help install the correct version. +// TODO: handle installation & packaging automatically +``` + ## FreshMark @@ -549,6 +589,33 @@ spotless { prettier().npmExecutable('/usr/bin/npm').config(...) ``` +## clang-format + +[homepage](https://clang.llvm.org/docs/ClangFormat.html). [changelog](https://releases.llvm.org/download.html). `clang-format` is a formatter for c, c++, c#, objective-c, protobuf, javascript, and java. You can use clang-format in any language-specific format, but usually you will be creating a generic format. + +```gradle +spotless { + format 'csharp', { + // you have to set the target manually + target 'src/**/*.cs' + + clangFormat('10.0.1') // version is optional + + // can also specify a code style + clangFormat().style('LLVM') // or Google, Chromium, Mozilla, WebKit + // TODO: support arbitrary .clang-format + + // if clang-format is not on your path, you must specify its location manually + clangFormat().pathToExe('/usr/local/Cellar/clang-format/10.0.1/bin/clang-format') + // Spotless always checks the version of the clang-format it is using + // and will fail with an error if it does not match the expected version + // (whether manually specified or default). If there is a problem, Spotless + // will suggest commands to help install the correct version. + // TODO: handle installation & packaging automatically + } +} +``` + ## Eclipse web tools platform diff --git a/plugin-gradle/build.gradle b/plugin-gradle/build.gradle index db66119522..f24f2cd233 100644 --- a/plugin-gradle/build.gradle +++ b/plugin-gradle/build.gradle @@ -29,17 +29,7 @@ dependencies { test { testLogging.showStandardStreams = true } -test { - useJUnit { - excludeCategories 'com.diffplug.spotless.category.NpmTest' - } -} - -task npmTest(type: Test) { - useJUnit { - includeCategories 'com.diffplug.spotless.category.NpmTest' - } -} +apply from: rootProject.file('gradle/special-tests.gradle') // make it easy for eclipse to run against latest build tasks.eclipse.dependsOn(pluginUnderTestMetadata) @@ -82,7 +72,9 @@ if (version.endsWith('-SNAPSHOT')) { 'tsfmt', 'prettier', 'scalafmt', - 'scalafix' + 'scalafix', + 'black', + 'clang-format' ] plugins { spotlessPlugin { 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 31ac2ae584..be55495888 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 @@ -46,6 +46,7 @@ import com.diffplug.spotless.LazyForwardingEquality; import com.diffplug.spotless.LineEnding; import com.diffplug.spotless.Provisioner; +import com.diffplug.spotless.cpp.ClangFormatStep; import com.diffplug.spotless.extra.EclipseBasedStepBuilder; import com.diffplug.spotless.extra.wtp.EclipseWtpFormatterStep; import com.diffplug.spotless.generic.EndWithNewlineStep; @@ -561,6 +562,42 @@ public PrettierConfig prettier(Map devDependencies) { return prettierConfig; } + /** Uses the default version of clang-format. */ + public ClangFormatConfig clangFormat() { + return clangFormat(ClangFormatStep.defaultVersion()); + } + + /** Uses the specified version of clang-format. */ + public ClangFormatConfig clangFormat(String version) { + return new ClangFormatConfig(version); + } + + public class ClangFormatConfig { + ClangFormatStep stepCfg; + + ClangFormatConfig(String version) { + this.stepCfg = ClangFormatStep.withVersion(version); + addStep(createStep()); + } + + /** Any of: LLVM, Google, Chromium, Mozilla, WebKit. */ + public ClangFormatConfig style(String style) { + stepCfg = stepCfg.withStyle(style); + replaceStep(createStep()); + return this; + } + + public ClangFormatConfig pathToExe(String pathToBlack) { + stepCfg = stepCfg.withPathToExe(pathToBlack); + replaceStep(createStep()); + return this; + } + + private FormatterStep createStep() { + return stepCfg.create(); + } + } + public class EclipseWtpConfig { private final EclipseBasedStepBuilder builder; diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/PythonExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/PythonExtension.java new file mode 100644 index 0000000000..ca12acfcae --- /dev/null +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/PythonExtension.java @@ -0,0 +1,63 @@ +/* + * Copyright 2020 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 com.diffplug.spotless.FormatterStep; +import com.diffplug.spotless.python.BlackStep; + +public class PythonExtension extends FormatExtension { + static final String NAME = "python"; + + public PythonExtension(SpotlessExtension spotless) { + super(spotless); + } + + public BlackConfig black() { + return black(BlackStep.defaultVersion()); + } + + public BlackConfig black(String version) { + return new BlackConfig(version); + } + + public class BlackConfig { + BlackStep stepCfg; + + BlackConfig(String version) { + this.stepCfg = BlackStep.withVersion(version); + addStep(createStep()); + } + + public BlackConfig pathToExe(String pathToBlack) { + stepCfg = stepCfg.withPathToExe(pathToBlack); + replaceStep(createStep()); + return this; + } + + private FormatterStep createStep() { + return stepCfg.create(); + } + } + + @Override + protected void setupTask(SpotlessTask task) { + // defaults to all markdown files + if (target == null) { + throw noDefaultTargetException(); + } + super.setupTask(task); + } +} diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java index b7f1e6dc53..13ec0b4be4 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java @@ -166,6 +166,11 @@ public void antlr4(Action closure) { format(Antlr4Extension.NAME, Antlr4Extension.class, closure); } + /** Configures the special python-specific extension for python files. */ + public void python(Action closure) { + format(PythonExtension.NAME, PythonExtension.class, closure); + } + /** Configures a custom extension. */ public void format(String name, Action closure) { requireNonNull(name, "name"); diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/ClangFormatIntegrationTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/ClangFormatIntegrationTest.java new file mode 100644 index 0000000000..7f74842c1b --- /dev/null +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/ClangFormatIntegrationTest.java @@ -0,0 +1,43 @@ +/* + * Copyright 2020 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.Test; +import org.junit.experimental.categories.Category; + +import com.diffplug.spotless.category.ClangTest; + +@Category(ClangTest.class) +public class ClangFormatIntegrationTest extends GradleIntegrationHarness { + @Test + public void csharp() throws IOException { + setFile("build.gradle").toLines( + "plugins {", + " id 'com.diffplug.spotless'", + "}", + "spotless {", + " format 'csharp', {", + " target 'src/**/*.cs'", + " clangFormat()", + " }", + "}"); + setFile("src/test.cs").toResource("clang/example.cs"); + gradleRunner().withArguments("spotlessApply").build(); + assertFile("src/test.cs").sameAsResource("clang/example.cs.clean"); + } +} diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/PythonGradleTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/PythonGradleTest.java new file mode 100644 index 0000000000..eec3b9d0de --- /dev/null +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/PythonGradleTest.java @@ -0,0 +1,43 @@ +/* + * Copyright 2020 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.Test; +import org.junit.experimental.categories.Category; + +import com.diffplug.spotless.category.BlackTest; + +@Category(BlackTest.class) +public class PythonGradleTest extends GradleIntegrationHarness { + @Test + public void black() throws IOException { + setFile("build.gradle").toLines( + "plugins {", + " id 'com.diffplug.spotless'", + "}", + "spotless {", + " python {", + " target 'src/**/*.py'", + " black()", + " }", + "}"); + setFile("src/test.py").toResource("python/black/black.dirty"); + gradleRunner().withArguments("spotlessApply").build(); + assertFile("src/test.py").sameAsResource("python/black/black.clean"); + } +} diff --git a/plugin-maven/build.gradle b/plugin-maven/build.gradle index 62a945bd71..84f7ce366b 100644 --- a/plugin-maven/build.gradle +++ b/plugin-maven/build.gradle @@ -190,20 +190,10 @@ jar.doLast { Files.copy(jarIn.toPath(), jarOut.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING) } -test { - useJUnit { - excludeCategories 'com.diffplug.spotless.category.NpmTest' - } -} -task npmTest(type: Test) { - useJUnit { - includeCategories 'com.diffplug.spotless.category.NpmTest' - } -} +apply from: rootProject.file('gradle/special-tests.gradle') + tasks.withType(Test) { systemProperty "localMavenRepositoryDir", LOCAL_MAVEN_REPO_DIR systemProperty "spotlessMavenPluginVersion", project.version + dependsOn(jar) } -// usually test only depends on testClasses, which doesn't run the maven build that we need -test.dependsOn(jar) -npmTest.dependsOn(jar) diff --git a/testlib/build.gradle b/testlib/build.gradle index d53285623c..9a5c1b0e5a 100644 --- a/testlib/build.gradle +++ b/testlib/build.gradle @@ -20,9 +20,7 @@ dependencies { // we'll hold the testlib to a low standard (prize brevity) spotbugs { reportLevel = 'high' } // low|medium|high (low = sensitive to even minor mistakes) -test { useJUnit { excludeCategories 'com.diffplug.spotless.category.NpmTest' } } - -task npmTest(type: Test) { useJUnit { includeCategories 'com.diffplug.spotless.category.NpmTest' } } +apply from: rootProject.file('gradle/special-tests.gradle') javadoc { options.addStringOption('Xdoclint:none', '-quiet') diff --git a/testlib/src/main/java/com/diffplug/spotless/StepHarness.java b/testlib/src/main/java/com/diffplug/spotless/StepHarness.java index 64522197e5..38cf0fc332 100644 --- a/testlib/src/main/java/com/diffplug/spotless/StepHarness.java +++ b/testlib/src/main/java/com/diffplug/spotless/StepHarness.java @@ -36,7 +36,7 @@ public static StepHarness forStep(FormatterStep step) { // We don't care if an individual FormatterStep is misbehaving on line-endings, because // Formatter fixes that. No reason to care in tests either. It's likely to pop up when // running tests on Windows from time-to-time - return new StepHarness(FormatterFunc.Closeable.of( + return new StepHarness(FormatterFunc.Closeable.ofDangerous( () -> { if (step instanceof FormatterStepImpl.Standard) { ((FormatterStepImpl.Standard) step).cleanupFormatterFunc(); @@ -47,7 +47,7 @@ public static StepHarness forStep(FormatterStep step) { /** Creates a harness for testing a formatter whose steps don't depend on the file. */ public static StepHarness forFormatter(Formatter formatter) { - return new StepHarness(FormatterFunc.Closeable.of( + return new StepHarness(FormatterFunc.Closeable.ofDangerous( formatter::close, input -> formatter.compute(input, new File("")))); } diff --git a/testlib/src/main/java/com/diffplug/spotless/StepHarnessWithFile.java b/testlib/src/main/java/com/diffplug/spotless/StepHarnessWithFile.java index 296ccadc11..dced15cf47 100644 --- a/testlib/src/main/java/com/diffplug/spotless/StepHarnessWithFile.java +++ b/testlib/src/main/java/com/diffplug/spotless/StepHarnessWithFile.java @@ -36,7 +36,7 @@ public static StepHarnessWithFile forStep(FormatterStep step) { // We don't care if an individual FormatterStep is misbehaving on line-endings, because // Formatter fixes that. No reason to care in tests either. It's likely to pop up when // running tests on Windows from time-to-time - return new StepHarnessWithFile(FormatterFunc.Closeable.of( + return new StepHarnessWithFile(FormatterFunc.Closeable.ofDangerous( () -> { if (step instanceof FormatterStepImpl.Standard) { ((FormatterStepImpl.Standard) step).cleanupFormatterFunc(); @@ -57,7 +57,7 @@ public String apply(String unix, File file) throws Exception { /** Creates a harness for testing a formatter whose steps do depend on the file. */ public static StepHarnessWithFile forFormatter(Formatter formatter) { - return new StepHarnessWithFile(FormatterFunc.Closeable.of( + return new StepHarnessWithFile(FormatterFunc.Closeable.ofDangerous( formatter::close, input -> formatter.compute(input, new File("")))); } diff --git a/testlib/src/main/java/com/diffplug/spotless/category/BlackTest.java b/testlib/src/main/java/com/diffplug/spotless/category/BlackTest.java new file mode 100644 index 0000000000..4b77e4f4fa --- /dev/null +++ b/testlib/src/main/java/com/diffplug/spotless/category/BlackTest.java @@ -0,0 +1,18 @@ +/* + * Copyright 2016-2020 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.category; + +public interface BlackTest {} diff --git a/testlib/src/main/java/com/diffplug/spotless/category/ClangTest.java b/testlib/src/main/java/com/diffplug/spotless/category/ClangTest.java new file mode 100644 index 0000000000..f63cff4a3f --- /dev/null +++ b/testlib/src/main/java/com/diffplug/spotless/category/ClangTest.java @@ -0,0 +1,18 @@ +/* + * Copyright 2016-2020 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.category; + +public interface ClangTest {} diff --git a/testlib/src/main/resources/clang/example.c b/testlib/src/main/resources/clang/example.c new file mode 100644 index 0000000000..205efe447d --- /dev/null +++ b/testlib/src/main/resources/clang/example.c @@ -0,0 +1,2 @@ +#include +int main() {printf("Testing 123");return 0;} \ No newline at end of file diff --git a/testlib/src/main/resources/clang/example.c.clean b/testlib/src/main/resources/clang/example.c.clean new file mode 100644 index 0000000000..e71ede5b0f --- /dev/null +++ b/testlib/src/main/resources/clang/example.c.clean @@ -0,0 +1,5 @@ +#include +int main() { + printf("Testing 123"); + return 0; +} \ No newline at end of file diff --git a/testlib/src/main/resources/clang/example.cs b/testlib/src/main/resources/clang/example.cs new file mode 100644 index 0000000000..0f90333b32 --- /dev/null +++ b/testlib/src/main/resources/clang/example.cs @@ -0,0 +1,17 @@ +using System; +using System.Text; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Linq; + +namespace Testing +{ + class Program + { + static void Main(string[] args) + { + string message = "Testing 1, 2, 3"; + Console.WriteLine(message); + } + } +} \ No newline at end of file diff --git a/testlib/src/main/resources/clang/example.cs.clean b/testlib/src/main/resources/clang/example.cs.clean new file mode 100644 index 0000000000..f39d6ce749 --- /dev/null +++ b/testlib/src/main/resources/clang/example.cs.clean @@ -0,0 +1,14 @@ +using System; +using System.Text; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Linq; + +namespace Testing { +class Program { + static void Main(string[] args) { + string message = "Testing 1, 2, 3"; + Console.WriteLine(message); + } +} +} \ No newline at end of file diff --git a/testlib/src/main/resources/clang/example.java.clean b/testlib/src/main/resources/clang/example.java.clean new file mode 100644 index 0000000000..d67bb653a7 --- /dev/null +++ b/testlib/src/main/resources/clang/example.java.clean @@ -0,0 +1,3 @@ +public class Java { + public static void main(String[] args) { System.out.println("hello"); } +} \ No newline at end of file diff --git a/testlib/src/main/resources/clang/example.java.dirty b/testlib/src/main/resources/clang/example.java.dirty new file mode 100644 index 0000000000..5589c792d8 --- /dev/null +++ b/testlib/src/main/resources/clang/example.java.dirty @@ -0,0 +1,5 @@ +public class Java { +public static void main(String[] args) { +System.out.println("hello"); +} +} \ No newline at end of file diff --git a/testlib/src/main/resources/clang/example.js b/testlib/src/main/resources/clang/example.js new file mode 100644 index 0000000000..08ebafc1a2 --- /dev/null +++ b/testlib/src/main/resources/clang/example.js @@ -0,0 +1,21 @@ +var numbers=[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20, +]; + +const p = { + first: "Peter", + last : "Pan", + get fullName() { return this.first + " " + this.last; } +}; + +const str = "Hello, world!" +; + +var str2=str.charAt(3)+str[0]; + +var multilinestr = "Hello \ +World" +; + +function test (a, b = "world") { let combined =a+ b; return combined}; + +test ("Hello"); diff --git a/testlib/src/main/resources/clang/example.js.clean b/testlib/src/main/resources/clang/example.js.clean new file mode 100644 index 0000000000..fb67c3fea1 --- /dev/null +++ b/testlib/src/main/resources/clang/example.js.clean @@ -0,0 +1,23 @@ +var numbers = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, +]; + +const p = { + first : "Peter", + last : "Pan", + get fullName() { return this.first + " " + this.last; } +}; + +const str = "Hello, world!"; + +var str2 = str.charAt(3) + str[0]; + +var multilinestr = "Hello \ +World"; + +function test(a, b = "world") { + let combined = a + b; + return combined +}; + +test("Hello"); diff --git a/testlib/src/main/resources/clang/example.m b/testlib/src/main/resources/clang/example.m new file mode 100644 index 0000000000..b925417b90 --- /dev/null +++ b/testlib/src/main/resources/clang/example.m @@ -0,0 +1,3 @@ +- (int)method:(int)i { + return [self testing_123:i]; +} \ No newline at end of file diff --git a/testlib/src/main/resources/clang/example.m.clean b/testlib/src/main/resources/clang/example.m.clean new file mode 100644 index 0000000000..4e102e756b --- /dev/null +++ b/testlib/src/main/resources/clang/example.m.clean @@ -0,0 +1,3 @@ +- (int)method:(int)i { + return [self testing_123:i]; +} \ No newline at end of file diff --git a/testlib/src/main/resources/clang/example.proto b/testlib/src/main/resources/clang/example.proto new file mode 100644 index 0000000000..a90d58bbd0 --- /dev/null +++ b/testlib/src/main/resources/clang/example.proto @@ -0,0 +1,5 @@ +message Testing { + required string field1 = 1; + required int32 field2 = 2; + optional string field3 = 3; +} \ No newline at end of file diff --git a/testlib/src/main/resources/clang/example.proto.clean b/testlib/src/main/resources/clang/example.proto.clean new file mode 100644 index 0000000000..fa7a103c68 --- /dev/null +++ b/testlib/src/main/resources/clang/example.proto.clean @@ -0,0 +1,5 @@ +message Testing { + required string field1 = 1; + required int32 field2 = 2; + optional string field3 = 3; +} \ No newline at end of file diff --git a/testlib/src/main/resources/python/black/black.clean b/testlib/src/main/resources/python/black/black.clean new file mode 100644 index 0000000000..75e5ce55ff --- /dev/null +++ b/testlib/src/main/resources/python/black/black.clean @@ -0,0 +1,58 @@ +from seven_dwwarfs import Grumpy, Happy, Sleepy, Bashful, Sneezy, Dopey, Doc + +x = {"a": 37, "b": 42, "c": 927} + +x = 123456789.123456789e123456789 + +if ( + very_long_variable_name is not None + and very_long_variable_name.field > 0 + or very_long_variable_name.is_debug +): + z = "hello " + "world" +else: + world = "world" + a = "hello {}".format(world) + f = rf"hello {world}" +if this and that: + y = "hello " "world" # FIXME: https://github.com/python/black/issues/26 + + +class Foo(object): + def f(self): + return 37 * -2 + + def g(self, x, y=42): + return y + + +def f(a: List[int]): + return 37 - a[42 - u : y ** 3] + + +def very_important_function( + template: str, *variables, file: os.PathLike, debug: bool = False, +): + """Applies `variables` to the `template` and writes to `file`.""" + with open(file, "w") as f: + ... + + +# fmt: off +custom_formatting = [ + 0, 1, 2, + 3, 4, 5, + 6, 7, 8, +] +# fmt: on +regular_formatting = [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, +] diff --git a/testlib/src/main/resources/python/black/black.dirty b/testlib/src/main/resources/python/black/black.dirty new file mode 100644 index 0000000000..2c51c6d4e9 --- /dev/null +++ b/testlib/src/main/resources/python/black/black.dirty @@ -0,0 +1,40 @@ +from seven_dwwarfs import Grumpy, Happy, Sleepy, Bashful, Sneezy, Dopey, Doc +x = { 'a':37,'b':42, + +'c':927} + +x = 123456789.123456789E123456789 + +if very_long_variable_name is not None and \ + very_long_variable_name.field > 0 or \ + very_long_variable_name.is_debug: + z = 'hello '+'world' +else: + world = 'world' + a = 'hello {}'.format(world) + f = rf'hello {world}' +if (this +and that): y = 'hello ''world'#FIXME: https://github.com/python/black/issues/26 +class Foo ( object ): + def f (self ): + return 37*-2 + def g(self, x,y=42): + return y +def f ( a: List[ int ]) : + return 37-a[42-u : y**3] +def very_important_function(template: str,*variables,file: os.PathLike,debug:bool=False,): + """Applies `variables` to the `template` and writes to `file`.""" + with open(file, "w") as f: + ... +# fmt: off +custom_formatting = [ + 0, 1, 2, + 3, 4, 5, + 6, 7, 8, +] +# fmt: on +regular_formatting = [ + 0, 1, 2, + 3, 4, 5, + 6, 7, 8, +] \ No newline at end of file diff --git a/testlib/src/test/java/com/diffplug/spotless/cpp/ClangFormatStepTest.java b/testlib/src/test/java/com/diffplug/spotless/cpp/ClangFormatStepTest.java new file mode 100644 index 0000000000..f8500c4eb5 --- /dev/null +++ b/testlib/src/test/java/com/diffplug/spotless/cpp/ClangFormatStepTest.java @@ -0,0 +1,42 @@ +/* + * Copyright 2020 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.cpp; + +import java.io.File; +import java.util.Arrays; + +import org.junit.Test; +import org.junit.experimental.categories.Category; + +import com.diffplug.spotless.StepHarnessWithFile; +import com.diffplug.spotless.category.ClangTest; + +@Category(ClangTest.class) +public class ClangFormatStepTest { + @Test + public void test() throws Exception { + try (StepHarnessWithFile harness = StepHarnessWithFile.forStep(ClangFormatStep.withVersion(ClangFormatStep.defaultVersion()).create())) { + // can't be named java or it gets compiled into .class file + harness.testResource(new File("example.java"), "clang/example.java.dirty", "clang/example.java.clean"); + // test every other language clang supports + for (String ext : Arrays.asList("c", "cs", "js", "m", "proto")) { + String filename = "example." + ext; + String root = "clang/" + filename; + harness.testResource(new File(filename), root, root + ".clean"); + } + } + } +} diff --git a/testlib/src/test/java/com/diffplug/spotless/python/BlackStepTest.java b/testlib/src/test/java/com/diffplug/spotless/python/BlackStepTest.java new file mode 100644 index 0000000000..91f2f9e8f3 --- /dev/null +++ b/testlib/src/test/java/com/diffplug/spotless/python/BlackStepTest.java @@ -0,0 +1,32 @@ +/* + * Copyright 2020 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.python; + +import org.junit.Test; +import org.junit.experimental.categories.Category; + +import com.diffplug.spotless.StepHarness; +import com.diffplug.spotless.category.BlackTest; + +@Category(BlackTest.class) +public class BlackStepTest { + @Test + public void test() throws Exception { + StepHarness.forStep(BlackStep.withVersion(BlackStep.defaultVersion()).create()) + .testResource("python/black/black.dirty", "python/black/black.clean") + .close(); + } +}