diff --git a/CHANGES.md b/CHANGES.md index 5af6d41b63..cb8bf9706e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -14,6 +14,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format ( * `FileSignature.Promised` and `JarState.Promised` to facilitate round-trip serialization for the Gradle configuration cache. ([#1945](https://github.com/diffplug/spotless/pull/1945)) ### Removed * **BREAKING** Remove `JarState.getMavenCoordinate(String prefix)`. ([#1945](https://github.com/diffplug/spotless/pull/1945)) +* **BREAKING** Replace `PipeStepPair` with `FenceStep`. ([#1954](https://github.com/diffplug/spotless/pull/1954)) ### Fixed * Ignore system git config when running tests ([#1990](https://github.com/diffplug/spotless/issues/1990)) diff --git a/lib/src/main/java/com/diffplug/spotless/generic/FenceStep.java b/lib/src/main/java/com/diffplug/spotless/generic/FenceStep.java new file mode 100644 index 0000000000..cbfe016679 --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/generic/FenceStep.java @@ -0,0 +1,222 @@ +/* + * Copyright 2020-2024 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.Serializable; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.diffplug.spotless.Formatter; +import com.diffplug.spotless.FormatterFunc; +import com.diffplug.spotless.FormatterStep; +import com.diffplug.spotless.LineEnding; +import com.diffplug.spotless.SerializedFunction; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +public class FenceStep { + /** Declares the name of the step. */ + public static FenceStep named(String name) { + return new FenceStep(name); + } + + public static String defaultToggleName() { + return "toggle"; + } + + public static String defaultToggleOff() { + return "spotless:off"; + } + + public static String defaultToggleOn() { + return "spotless:on"; + } + + String name; + Pattern regex; + + private FenceStep(String name) { + this.name = Objects.requireNonNull(name); + } + + /** Defines the opening and closing markers. */ + public FenceStep openClose(String open, String close) { + return regex(Pattern.quote(open) + "([\\s\\S]*?)" + Pattern.quote(close)); + } + + /** Defines the pipe via regex. Must have *exactly one* capturing group. */ + public FenceStep regex(String regex) { + return regex(Pattern.compile(regex)); + } + + /** Defines the pipe via regex. Must have *exactly one* capturing group. */ + public FenceStep regex(Pattern regex) { + this.regex = Objects.requireNonNull(regex); + return this; + } + + private void assertRegexSet() { + Objects.requireNonNull(regex, "must call regex() or openClose()"); + } + + /** Returns a step which will apply the given steps but preserve the content selected by the regex / openClose pair. */ + public FormatterStep preserveWithin(List steps) { + assertRegexSet(); + return FormatterStep.createLazy(name, + () -> new PreserveWithin(regex, steps), + SerializedFunction.identity(), + state -> FormatterFunc.Closeable.of(state.buildFormatter(), state)); + } + + /** + * Returns a step which will apply the given steps only within the blocks selected by the regex / openClose pair. + * Linting within the substeps is not supported. + */ + public FormatterStep applyWithin(List steps) { + assertRegexSet(); + return FormatterStep.createLazy(name, + () -> new ApplyWithin(regex, steps), + SerializedFunction.identity(), + state -> FormatterFunc.Closeable.of(state.buildFormatter(), state)); + } + + static class ApplyWithin extends Apply implements FormatterFunc.Closeable.ResourceFuncNeedsFile { + private static final long serialVersionUID = 17061466531957339L; + + ApplyWithin(Pattern regex, List steps) { + super(regex, steps); + } + + @Override + public String apply(Formatter formatter, String unix, File file) throws Exception { + List groups = groupsZeroed(); + Matcher matcher = regex.matcher(unix); + while (matcher.find()) { + // apply the formatter to each group + groups.add(formatter.compute(matcher.group(1), file)); + } + // and then assemble the result right away + return assembleGroups(unix); + } + } + + static class PreserveWithin extends Apply implements FormatterFunc.Closeable.ResourceFuncNeedsFile { + private static final long serialVersionUID = -8676786492305178343L; + + PreserveWithin(Pattern regex, List steps) { + super(regex, steps); + } + + private void storeGroups(String unix) { + List groups = groupsZeroed(); + Matcher matcher = regex.matcher(unix); + while (matcher.find()) { + // store whatever is within the open/close tags + groups.add(matcher.group(1)); + } + } + + @Override + public String apply(Formatter formatter, String unix, File file) throws Exception { + storeGroups(unix); + String formatted = formatter.compute(unix, file); + return assembleGroups(formatted); + } + } + + @SuppressFBWarnings("SE_TRANSIENT_FIELD_NOT_RESTORED") + static class Apply implements Serializable { + private static final long serialVersionUID = -2301848328356559915L; + final Pattern regex; + final List steps; + + transient ArrayList groups = new ArrayList<>(); + transient StringBuilder builderInternal; + + public Apply(Pattern regex, List steps) { + this.regex = regex; + this.steps = steps; + } + + protected ArrayList groupsZeroed() { + if (groups == null) { + groups = new ArrayList<>(); + } else { + groups.clear(); + } + return groups; + } + + private StringBuilder builderZeroed() { + if (builderInternal == null) { + builderInternal = new StringBuilder(); + } else { + builderInternal.setLength(0); + } + return builderInternal; + } + + protected Formatter buildFormatter() { + return Formatter.builder() + .encoding(StandardCharsets.UTF_8) // can be any UTF, doesn't matter + .lineEndingsPolicy(LineEnding.UNIX.createPolicy()) // just internal, won't conflict with user + .steps(steps) + .rootDir(Path.of("")) // TODO: error messages will be suboptimal for now, but it will get fixed when we ship linting + .build(); + } + + protected String assembleGroups(String unix) { + if (groups.isEmpty()) { + return unix; + } + StringBuilder builder = builderZeroed(); + Matcher matcher = regex.matcher(unix); + int lastEnd = 0; + int groupIdx = 0; + while (matcher.find()) { + builder.append(unix, lastEnd, matcher.start(1)); + builder.append(groups.get(groupIdx)); + lastEnd = matcher.end(1); + ++groupIdx; + } + if (groupIdx == groups.size()) { + builder.append(unix, lastEnd, unix.length()); + return builder.toString(); + } else { + // these will be needed to generate Lints later on + // int startLine = 1 + (int) builder.toString().codePoints().filter(c -> c == '\n').count(); + // int endLine = 1 + (int) unix.codePoints().filter(c -> c == '\n').count(); + + // throw an error with either the full regex, or the nicer open/close pair + Matcher openClose = Pattern.compile("\\\\Q([\\s\\S]*?)\\\\E" + "\\Q([\\s\\S]*?)\\E" + "\\\\Q([\\s\\S]*?)\\\\E") + .matcher(regex.pattern()); + String pattern; + if (openClose.matches()) { + pattern = openClose.group(1) + " " + openClose.group(2); + } else { + pattern = regex.pattern(); + } + throw new Error("An intermediate step removed a match of " + pattern); + } + } + } +} diff --git a/lib/src/main/java/com/diffplug/spotless/generic/PipeStepPair.java b/lib/src/main/java/com/diffplug/spotless/generic/PipeStepPair.java index 38373fec59..b15cb0ca54 100644 --- a/lib/src/main/java/com/diffplug/spotless/generic/PipeStepPair.java +++ b/lib/src/main/java/com/diffplug/spotless/generic/PipeStepPair.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2021 DiffPlug + * Copyright 2020-2024 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -33,6 +33,10 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +/** + * @deprecated use FenceStep instead + */ +@Deprecated public class PipeStepPair { /** The two steps will be named {@code In} and {@code Out}. */ public static Builder named(String name) { 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 eae8ac667d..e0c0473737 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 @@ -1,5 +1,5 @@ /* - * Copyright 2016-2023 DiffPlug + * Copyright 2016-2024 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -57,11 +57,11 @@ import com.diffplug.spotless.extra.EclipseBasedStepBuilder; import com.diffplug.spotless.extra.wtp.EclipseWtpFormatterStep; import com.diffplug.spotless.generic.EndWithNewlineStep; +import com.diffplug.spotless.generic.FenceStep; import com.diffplug.spotless.generic.IndentStep; import com.diffplug.spotless.generic.LicenseHeaderStep; import com.diffplug.spotless.generic.LicenseHeaderStep.YearMode; import com.diffplug.spotless.generic.NativeCmdStep; -import com.diffplug.spotless.generic.PipeStepPair; import com.diffplug.spotless.generic.ReplaceRegexStep; import com.diffplug.spotless.generic.ReplaceStep; import com.diffplug.spotless.generic.TrimTrailingWhitespaceStep; @@ -989,7 +989,7 @@ public void withinBlocks(String name, String open, String close, Action void withinBlocks(String name, String open, String close, Class clazz, Action configure) { - withinBlocksHelper(PipeStepPair.named(name).openClose(open, close), clazz, configure); + withinBlocksHelper(FenceStep.named(name).openClose(open, close), clazz, configure); } /** @@ -1007,18 +1007,17 @@ public void withinBlocksRegex(String name, String regex, Action */ public void withinBlocksRegex(String name, String regex, Class clazz, Action configure) { - withinBlocksHelper(PipeStepPair.named(name).regex(regex), clazz, configure); + withinBlocksHelper(FenceStep.named(name).regex(regex), clazz, configure); } - private void withinBlocksHelper(PipeStepPair.Builder builder, Class clazz, + private void withinBlocksHelper(FenceStep fence, Class clazz, Action configure) { // create the sub-extension T formatExtension = spotless.instantiateFormatExtension(clazz); // configure it configure.execute(formatExtension); // create a step which applies all of those steps as sub-steps - FormatterStep step = builder.buildStepWhichAppliesSubSteps(spotless.project.getRootDir().toPath(), - formatExtension.steps); + FormatterStep step = fence.applyWithin(formatExtension.steps); addStep(step); } @@ -1027,17 +1026,17 @@ private void withinBlocksHelper(PipeStepPair.Builder * that captured group. */ public void toggleOffOnRegex(String regex) { - this.togglePair = PipeStepPair.named(PipeStepPair.defaultToggleName()).regex(regex).buildPair(); + this.toggleFence = FenceStep.named(FenceStep.defaultToggleName()).regex(regex); } /** Disables formatting between the given tags. */ public void toggleOffOn(String off, String on) { - this.togglePair = PipeStepPair.named(PipeStepPair.defaultToggleName()).openClose(off, on).buildPair(); + this.toggleFence = FenceStep.named(FenceStep.defaultToggleName()).openClose(off, on); } /** Disables formatting between {@code spotless:off} and {@code spotless:on}. */ public void toggleOffOn() { - toggleOffOn(PipeStepPair.defaultToggleOff(), PipeStepPair.defaultToggleOn()); + toggleOffOn(FenceStep.defaultToggleOff(), FenceStep.defaultToggleOn()); } /** @@ -1045,10 +1044,10 @@ public void toggleOffOn() { * {@link #toggleOffOn(String, String)}. */ public void toggleOffOnDisable() { - this.togglePair = null; + this.toggleFence = null; } - private @Nullable PipeStepPair togglePair; + private @Nullable FenceStep toggleFence; /** Sets up a format task according to the values in this extension. */ protected void setupTask(SpotlessTask task) { @@ -1057,11 +1056,8 @@ protected void setupTask(SpotlessTask task) { FileCollection totalTarget = targetExclude == null ? target : target.minus(targetExclude); task.setTarget(totalTarget); List steps; - if (togglePair != null) { - steps = new ArrayList<>(this.steps.size() + 2); - steps.add(togglePair.in()); - steps.addAll(this.steps); - steps.add(togglePair.out()); + if (toggleFence != null) { + steps = List.of(toggleFence.preserveWithin(this.steps)); } else { steps = this.steps; } 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 c4a6663087..6393c65858 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-2023 DiffPlug + * Copyright 2016-2024 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -36,7 +36,6 @@ import com.diffplug.spotless.Formatter; import com.diffplug.spotless.FormatterStep; import com.diffplug.spotless.LineEnding; -import com.diffplug.spotless.generic.PipeStepPair; import com.diffplug.spotless.maven.generic.EclipseWtp; import com.diffplug.spotless.maven.generic.EndWithNewline; import com.diffplug.spotless.maven.generic.Indent; @@ -97,9 +96,8 @@ public final Formatter newFormatter(Supplier> filesToFormat, Form .map(factory -> factory.newFormatterStep(stepConfig)) .collect(Collectors.toCollection(() -> new ArrayList())); if (toggle != null) { - PipeStepPair pair = toggle.createPair(); - formatterSteps.add(0, pair.in()); - formatterSteps.add(pair.out()); + List formatterStepsBeforeToggle = formatterSteps; + formatterSteps = List.of(toggle.createFence().preserveWithin(formatterStepsBeforeToggle)); } String formatterName = this.getClass().getSimpleName(); diff --git a/plugin-maven/src/main/java/com/diffplug/spotless/maven/generic/ToggleOffOn.java b/plugin-maven/src/main/java/com/diffplug/spotless/maven/generic/ToggleOffOn.java index fe1676aec9..984a82601f 100644 --- a/plugin-maven/src/main/java/com/diffplug/spotless/maven/generic/ToggleOffOn.java +++ b/plugin-maven/src/main/java/com/diffplug/spotless/maven/generic/ToggleOffOn.java @@ -1,5 +1,5 @@ /* - * Copyright 2020 DiffPlug + * Copyright 2020-2024 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,23 +17,23 @@ import org.apache.maven.plugins.annotations.Parameter; -import com.diffplug.spotless.generic.PipeStepPair; +import com.diffplug.spotless.generic.FenceStep; public class ToggleOffOn { @Parameter - public String off = PipeStepPair.defaultToggleOff(); + public String off = FenceStep.defaultToggleOff(); @Parameter - public String on = PipeStepPair.defaultToggleOn(); + public String on = FenceStep.defaultToggleOn(); @Parameter public String regex; - public PipeStepPair createPair() { + public FenceStep createFence() { if (regex != null) { - return PipeStepPair.named(PipeStepPair.defaultToggleName()).regex(regex).buildPair(); + return FenceStep.named(FenceStep.defaultToggleName()).regex(regex); } else { - return PipeStepPair.named(PipeStepPair.defaultToggleName()).openClose(off, on).buildPair(); + return FenceStep.named(FenceStep.defaultToggleName()).openClose(off, on); } } } diff --git a/testlib/src/main/java/com/diffplug/spotless/StepHarnessBase.java b/testlib/src/main/java/com/diffplug/spotless/StepHarnessBase.java index 2f1e397e4f..c82de128a1 100644 --- a/testlib/src/main/java/com/diffplug/spotless/StepHarnessBase.java +++ b/testlib/src/main/java/com/diffplug/spotless/StepHarnessBase.java @@ -46,6 +46,8 @@ protected StepHarnessBase(Formatter formatter) { supportsRoundTrip = true; } else if (onlyStepName.toLowerCase(Locale.ROOT).contains("eclipse")) { supportsRoundTrip = true; + } else if (onlyStepName.equals("fence")) { + supportsRoundTrip = true; } } } diff --git a/testlib/src/test/java/com/diffplug/spotless/combined/CombinedJavaFormatStepTest.java b/testlib/src/test/java/com/diffplug/spotless/combined/CombinedJavaFormatStepTest.java index d1eb023400..8d91d7ca6e 100644 --- a/testlib/src/test/java/com/diffplug/spotless/combined/CombinedJavaFormatStepTest.java +++ b/testlib/src/test/java/com/diffplug/spotless/combined/CombinedJavaFormatStepTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2023 DiffPlug + * Copyright 2023-2024 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,14 +17,16 @@ import static com.diffplug.spotless.TestProvisioner.mavenCentral; +import java.util.List; + import org.junit.jupiter.api.Test; import com.diffplug.spotless.FormatterStep; import com.diffplug.spotless.ResourceHarness; import com.diffplug.spotless.StepHarness; import com.diffplug.spotless.generic.EndWithNewlineStep; +import com.diffplug.spotless.generic.FenceStep; import com.diffplug.spotless.generic.IndentStep; -import com.diffplug.spotless.generic.PipeStepPair; import com.diffplug.spotless.generic.TrimTrailingWhitespaceStep; import com.diffplug.spotless.java.GoogleJavaFormatStep; import com.diffplug.spotless.java.ImportOrderStep; @@ -40,16 +42,15 @@ void checkIssue1679() { FormatterStep removeUnused = RemoveUnusedImportsStep.create(mavenCentral()); FormatterStep trimTrailing = TrimTrailingWhitespaceStep.create(); FormatterStep endWithNewLine = EndWithNewlineStep.create(); - PipeStepPair toggleOffOnPair = PipeStepPair.named(PipeStepPair.defaultToggleName()).openClose("formatting:off", "formatting:on").buildPair(); + FenceStep toggleOffOnPair = FenceStep.named(FenceStep.defaultToggleName()).openClose("formatting:off", "formatting:on"); try (StepHarness formatter = StepHarness.forSteps( - toggleOffOnPair.in(), - gjf, - indentWithSpaces, - importOrder, - removeUnused, - trimTrailing, - endWithNewLine, - toggleOffOnPair.out())) { + toggleOffOnPair.preserveWithin(List.of( + gjf, + indentWithSpaces, + importOrder, + removeUnused, + trimTrailing, + endWithNewLine)))) { formatter.testResource("combined/issue1679.dirty", "combined/issue1679.clean"); } } diff --git a/testlib/src/test/java/com/diffplug/spotless/generic/FenceStepTest.java b/testlib/src/test/java/com/diffplug/spotless/generic/FenceStepTest.java new file mode 100644 index 0000000000..17159ae5a4 --- /dev/null +++ b/testlib/src/test/java/com/diffplug/spotless/generic/FenceStepTest.java @@ -0,0 +1,156 @@ +/* + * Copyright 2020-2024 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.Serializable; +import java.util.Arrays; +import java.util.Objects; + +import javax.annotation.Nullable; + +import org.junit.jupiter.api.Test; + +import com.diffplug.common.base.StringPrinter; +import com.diffplug.spotless.FormatterFunc; +import com.diffplug.spotless.FormatterStep; +import com.diffplug.spotless.ResourceHarness; +import com.diffplug.spotless.StepHarness; +import com.diffplug.spotless.StepHarnessWithFile; + +class FenceStepTest extends ResourceHarness { + @Test + void single() { + FormatterStep fence = FenceStep.named("fence").openClose("spotless:off", "spotless:on") + .preserveWithin(Arrays.asList(createNeverUpToDateSerializable("lowercase", String::toLowerCase))); + StepHarness harness = StepHarness.forSteps(fence); + harness.test( + StringPrinter.buildStringFromLines( + "A B C", + "spotless:off", + "D E F", + "spotless:on", + "G H I"), + StringPrinter.buildStringFromLines( + "a b c", + "spotless:off", + "D E F", + "spotless:on", + "g h i")); + } + + @Test + void multiple() { + FormatterStep fence = FenceStep.named("fence").openClose("spotless:off", "spotless:on") + .preserveWithin(Arrays.asList(createNeverUpToDateSerializable("lowercase", String::toLowerCase))); + StepHarness harness = StepHarness.forSteps(fence); + harness.test( + StringPrinter.buildStringFromLines( + "A B C", + "spotless:off", + "D E F", + "spotless:on", + "G H I", + "spotless:off J K L spotless:on", + "M N O", + "P Q R", + "S T U spotless:off V W", + " X ", + " Y spotless:on Z", + "1 2 3"), + StringPrinter.buildStringFromLines( + "a b c", + "spotless:off", + "D E F", + "spotless:on", + "g h i", + "spotless:off J K L spotless:on", + "m n o", + "p q r", + "s t u spotless:off V W", + " X ", + " Y spotless:on z", + "1 2 3")); + } + + @Test + void broken() { + FormatterStep fence = FenceStep.named("fence").openClose("spotless:off", "spotless:on") + .preserveWithin(Arrays.asList(createNeverUpToDateSerializable("uppercase", String::toUpperCase))); + StepHarnessWithFile harness = StepHarnessWithFile.forStep(this, fence); + // this fails because uppercase turns spotless:off into SPOTLESS:OFF, etc + harness.testExceptionMsg(newFile("test"), StringPrinter.buildStringFromLines("A B C", + "spotless:off", + "D E F", + "spotless:on", + "G H I")).isEqualTo("An intermediate step removed a match of spotless:off spotless:on"); + } + + @Test + void andApply() { + FormatterStep fence = FenceStep.named("fence").openClose("", "") + .applyWithin(Arrays.asList(createNeverUpToDateSerializable("lowercase", String::toLowerCase))); + StepHarness.forSteps(fence).test( + StringPrinter.buildStringFromLines( + "A B C", + "", + "D E F", + "", + "G H I"), + StringPrinter.buildStringFromLines( + "A B C", + "", + "d e f", + "", + "G H I")); + } + + /** + * @param name + * The name of the formatter step + * @param function + * The function used by the formatter step + * @return A FormatterStep which will never report that it is up-to-date, because + * it is not equal to the serialized representation of itself. + */ + static FormatterStep createNeverUpToDateSerializable( + String name, + T function) { + Objects.requireNonNull(function, "function"); + return new NeverUpToDateSerializable(name, function); + } + + static class NeverUpToDateSerializable implements FormatterStep, Serializable { + private final String name; + private final T formatterFunc; + + private NeverUpToDateSerializable(String name, T formatterFunc) { + this.name = name; + this.formatterFunc = formatterFunc; + } + + @Override + public String getName() { + return name; + } + + @Nullable + @Override + public String format(String rawUnix, File file) throws Exception { + return formatterFunc.apply(rawUnix, file); + } + } +}