From 8f14a5220550635d81d8f58c1a6fcbcb72fea189 Mon Sep 17 00:00:00 2001 From: sullis Date: Tue, 8 Sep 2026 17:51:37 -0400 Subject: [PATCH] Add lombok.config recipes --- .../lombok/AddStopBubblingToLombokConfig.java | 120 ++ .../lombok/ConsolidateLombokConfig.java | 480 +++++ .../java/migrate/lombok/LombokConfig.java | 244 +++ .../resources/META-INF/rewrite/recipes.csv | 2 + .../AddStopBubblingToLombokConfigTest.java | 278 +++ .../lombok/ConsolidateLombokConfigTest.java | 1549 +++++++++++++++++ 6 files changed, 2673 insertions(+) create mode 100644 src/main/java/org/openrewrite/java/migrate/lombok/AddStopBubblingToLombokConfig.java create mode 100644 src/main/java/org/openrewrite/java/migrate/lombok/ConsolidateLombokConfig.java create mode 100644 src/main/java/org/openrewrite/java/migrate/lombok/LombokConfig.java create mode 100644 src/test/java/org/openrewrite/java/migrate/lombok/AddStopBubblingToLombokConfigTest.java create mode 100755 src/test/java/org/openrewrite/java/migrate/lombok/ConsolidateLombokConfigTest.java diff --git a/src/main/java/org/openrewrite/java/migrate/lombok/AddStopBubblingToLombokConfig.java b/src/main/java/org/openrewrite/java/migrate/lombok/AddStopBubblingToLombokConfig.java new file mode 100644 index 0000000000..36f8eb2b1a --- /dev/null +++ b/src/main/java/org/openrewrite/java/migrate/lombok/AddStopBubblingToLombokConfig.java @@ -0,0 +1,120 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * 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 org.openrewrite.java.migrate.lombok; + +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.jspecify.annotations.Nullable; +import org.openrewrite.ExecutionContext; +import org.openrewrite.ScanningRecipe; +import org.openrewrite.SourceFile; +import org.openrewrite.Tree; +import org.openrewrite.TreeVisitor; +import org.openrewrite.text.PlainText; +import org.openrewrite.text.PlainTextParser; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +import static java.util.Collections.singletonList; +import static java.util.Objects.requireNonNull; +import static org.openrewrite.java.migrate.lombok.LombokConfig.STOP_BUBBLING; +import static org.openrewrite.java.migrate.lombok.LombokConfig.append; +import static org.openrewrite.java.migrate.lombok.LombokConfig.declaresStopBubbling; +import static org.openrewrite.java.migrate.lombok.LombokConfig.expandImports; +import static org.openrewrite.java.migrate.lombok.LombokConfig.isConfig; +import static org.openrewrite.java.migrate.lombok.LombokConfig.isLombokConfig; +import static org.openrewrite.java.migrate.lombok.LombokConfig.parse; + +@Value +@EqualsAndHashCode(callSuper = false) +public class AddStopBubblingToLombokConfig extends ScanningRecipe { + + private static final String STOP_BUBBLING_TRUE = STOP_BUBBLING + " = true"; + + String displayName = "Add `config.stopBubbling` to the root `lombok.config`"; + + String description = "Append `config.stopBubbling = true` to the root `lombok.config`, so that Lombok reads the " + + "project's configuration and nothing else. Lombok resolves a key by walking up from the directory of the " + + "Java file it is compiling and does not stop at the project, so without this key a `lombok.config` in a " + + "parent directory of the checkout takes part in the build. Note that this cuts the project off from such " + + "a file whether or not it was meant to be read. Nothing is added when the key is already declared, " + + "whatever value it is assigned or whether the root file declares it or imports it."; + + public static class Accumulator { + @Nullable + Path rootConfig; + + List rootLines = new ArrayList<>(); + + /** + * Every file an {@code import} could name, so that a key the root imports is not declared a second time. + */ + final Map> configs = new HashMap<>(); + } + + @Override + public Accumulator getInitialValue(ExecutionContext ctx) { + return new Accumulator(); + } + + @Override + public TreeVisitor getScanner(Accumulator acc) { + return new TreeVisitor() { + @Override + public Tree visit(@Nullable Tree tree, ExecutionContext ctx) { + SourceFile sourceFile = (SourceFile) requireNonNull(tree); + if (!isConfig(sourceFile)) { + return sourceFile; + } + Path path = sourceFile.getSourcePath(); + List lines = parse(PlainTextParser.convert(sourceFile).getText()); + acc.configs.put(path, lines); + if (isLombokConfig(sourceFile) && path.getParent() == null) { + acc.rootConfig = path; + acc.rootLines = lines; + } + return sourceFile; + } + }; + } + + @Override + public TreeVisitor getVisitor(Accumulator acc) { + if (acc.rootConfig == null) { + return TreeVisitor.noop(); + } + List rootLines = expandImports(acc.rootConfig, acc.rootLines, acc.configs, new HashSet<>()); + if (rootLines == null || declaresStopBubbling(rootLines)) { + return TreeVisitor.noop(); + } + return new TreeVisitor() { + @Override + public Tree visit(@Nullable Tree tree, ExecutionContext ctx) { + SourceFile sourceFile = (SourceFile) requireNonNull(tree); + if (!sourceFile.getSourcePath().equals(acc.rootConfig)) { + return sourceFile; + } + PlainText plainText = PlainTextParser.convert(sourceFile); + return plainText.withText(append(plainText.getText(), singletonList(STOP_BUBBLING_TRUE))); + } + }; + } +} diff --git a/src/main/java/org/openrewrite/java/migrate/lombok/ConsolidateLombokConfig.java b/src/main/java/org/openrewrite/java/migrate/lombok/ConsolidateLombokConfig.java new file mode 100644 index 0000000000..8f0ca60c1c --- /dev/null +++ b/src/main/java/org/openrewrite/java/migrate/lombok/ConsolidateLombokConfig.java @@ -0,0 +1,480 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * 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 org.openrewrite.java.migrate.lombok; + +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.jspecify.annotations.Nullable; +import org.openrewrite.ExecutionContext; +import org.openrewrite.ScanningRecipe; +import org.openrewrite.SourceFile; +import org.openrewrite.Tree; +import org.openrewrite.TreeVisitor; +import org.openrewrite.text.PlainText; +import org.openrewrite.text.PlainTextParser; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; + +import static java.util.Comparator.comparingInt; +import static java.util.Objects.requireNonNull; +import static org.openrewrite.java.migrate.lombok.LombokConfig.LOMBOK_CONFIG; +import static org.openrewrite.java.migrate.lombok.LombokConfig.STOP_BUBBLING_KEY; +import static org.openrewrite.java.migrate.lombok.LombokConfig.append; +import static org.openrewrite.java.migrate.lombok.LombokConfig.expandImports; +import static org.openrewrite.java.migrate.lombok.LombokConfig.isConfig; +import static org.openrewrite.java.migrate.lombok.LombokConfig.isLombokConfig; +import static org.openrewrite.java.migrate.lombok.LombokConfig.parse; +import static org.openrewrite.java.migrate.lombok.LombokConfig.resolveImport; + +@Value +@EqualsAndHashCode(callSuper = false) +public class ConsolidateLombokConfig extends ScanningRecipe { + + String displayName = "Consolidate `lombok.config` files"; + + String description = "Merge the directives of every nested `lombok.config` into the root `lombok.config` and " + + "delete the nested files, so that a project has a single place where Lombok is configured. Directives " + + "are appended to the root file; what it already declares, itself or through an `import`, is left as " + + "written and not repeated. Note that hoisting a directive widens its scope from the directory that " + + "declared it to the whole project, so a directive only some directories can satisfy, such as " + + "`lombok.val.flagUsage = error`, will start to apply to all of them. A nested file is left in place when " + + "moving its directives would change what Lombok does: when it declares `config.stopBubbling`, `import`, " + + "`clear` or `-=`, when a `lombok.config` between it and the root would outrank the root once the " + + "directive moved there, when another `lombok.config` imports it, or when no Java source sits at or below " + + "it. No changes are made at all when two files assign conflicting values to the same key, or when the " + + "root imports a file that is not among the sources."; + + public static class Accumulator { + @Nullable + Path rootConfig; + + List rootLines = new ArrayList<>(); + + /** + * Every nested config, keyed by path so that they are merged in a predictable order. + */ + final SortedMap> nested = new TreeMap<>(); + + /** + * The directory of every Java source, so that a config Lombok never reads can be told apart from one it does. + */ + final Set javaSourceDirectories = new HashSet<>(); + + /** + * Every file an {@code import} could name, so that what a config pulls in can be read rather than guessed at. + */ + final Map> configs = new HashMap<>(); + } + + @Override + public Accumulator getInitialValue(ExecutionContext ctx) { + return new Accumulator(); + } + + @Override + public TreeVisitor getScanner(Accumulator acc) { + return new TreeVisitor() { + @Override + public Tree visit(@Nullable Tree tree, ExecutionContext ctx) { + SourceFile sourceFile = (SourceFile) requireNonNull(tree); + Path path = sourceFile.getSourcePath(); + if (isJavaSource(path)) { + Path directory = path.getParent(); + acc.javaSourceDirectories.add(directory == null ? Paths.get("") : directory); + return sourceFile; + } + if (!isConfig(sourceFile)) { + return sourceFile; + } + List lines = parse(PlainTextParser.convert(sourceFile).getText()); + acc.configs.put(path, lines); + if (isLombokConfig(sourceFile)) { + if (path.getParent() == null) { + acc.rootConfig = path; + acc.rootLines = lines; + } else { + acc.nested.put(path, lines); + } + } + return sourceFile; + } + }; + } + + @Override + public TreeVisitor getVisitor(Accumulator acc) { + if (acc.rootConfig == null) { + return TreeVisitor.noop(); + } + SortedMap> hoistable = hoistable(configuration(acc), importedConfigs(acc)); + if (hoistable.isEmpty()) { + return TreeVisitor.noop(); + } + List rootLines = expandImports(acc.rootConfig, acc.rootLines, acc.configs, new HashSet<>()); + if (rootLines == null || hasConflictingDirectives(rootLines, hoistable)) { + return TreeVisitor.noop(); + } + List additions = additions(rootLines, hoistable); + return new TreeVisitor() { + @Override + public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) { + SourceFile sourceFile = (SourceFile) requireNonNull(tree); + if (!isLombokConfig(sourceFile)) { + return sourceFile; + } + if (hoistable.containsKey(sourceFile.getSourcePath())) { + return null; + } + if (additions.isEmpty() || !sourceFile.getSourcePath().equals(acc.rootConfig)) { + return sourceFile; + } + PlainText plainText = PlainTextParser.convert(sourceFile); + return plainText.withText(append(plainText.getText(), additions)); + } + }; + } + + /** + * Judged on the name, so that a source Lombok compiles but this recipe did not parse counts all the same. + */ + private static boolean isJavaSource(Path path) { + return path.toString().endsWith(".java"); + } + + /** + * The nested configs that configure Lombok for this project. Lombok reads a config by walking up from the + * directory of the Java file it is compiling, so one with no Java source at or below it, such as a fixture under + * {@code src/test/resources}, is not configuration at all and is left where it is. When there is no Java source + * to be seen there is no layout to judge against, so each config is taken at its word. + */ + private static SortedMap> configuration(Accumulator acc) { + if (acc.javaSourceDirectories.isEmpty()) { + return acc.nested; + } + SortedMap> configuration = new TreeMap<>(); + for (Map.Entry> config : acc.nested.entrySet()) { + if (governsJavaSources(config.getKey(), acc.javaSourceDirectories)) { + configuration.put(config.getKey(), config.getValue()); + } + } + return configuration; + } + + /** + * Whether a Java source lives in the directory that declares the given config, or in one below it. + */ + private static boolean governsJavaSources(Path config, Set javaSourceDirectories) { + Path directory = requireNonNull(config.getParent()); + for (Path javaSourceDirectory : javaSourceDirectories) { + if (javaSourceDirectory.startsWith(directory)) { + return true; + } + } + return false; + } + + /** + * The nested configs whose directives can be moved into the root. A directory that stops bubbling puts + * everything under it out of reach of the root, and a config that stays behind can shadow a directive hoisted + * out of a directory below it, which is why configs are decided shallowest first: whether one stays behind is + * settled before the configs it could shadow are considered. + */ + private static SortedMap> hoistable(SortedMap> nested, + Set imported) { + Set stopBubblingDirectories = new HashSet<>(); + for (Map.Entry> config : nested.entrySet()) { + if (stopsBubbling(config.getValue())) { + stopBubblingDirectories.add(config.getKey().getParent()); + } + } + + SortedMap> hoistable = new TreeMap<>(); + for (Map.Entry> config : shallowestFirst(nested)) { + Path path = config.getKey(); + if (isHoistable(config.getValue()) && + !imported.contains(path) && + !isUnder(path, stopBubblingDirectories) && + !isShadowed(config.getValue(), leftInPlaceAbove(path, nested, hoistable))) { + hoistable.put(path, config.getValue()); + } + } + return hoistable; + } + + /** + * The given configs shallowest first, which is the order Lombok reads them in, and which path order is not, as + * that puts {@code a/b/lombok.config} before {@code a/lombok.config}. Configs of equal depth keep path order. + */ + private static List>> shallowestFirst( + SortedMap> configs) { + List>> shallowestFirst = new ArrayList<>(configs.entrySet()); + shallowestFirst.sort(comparingInt(config -> config.getKey().getNameCount())); + return shallowestFirst; + } + + /** + * The configs left in place between the given one and the root; the root itself is not among them. + */ + private static List> leftInPlaceAbove(Path path, + SortedMap> nested, + SortedMap> hoistable) { + List> leftInPlace = new ArrayList<>(); + for (Path directory = requireNonNull(path.getParent()).getParent(); directory != null; directory = directory.getParent()) { + Path config = directory.resolve(LOMBOK_CONFIG); + List lines = nested.get(config); + if (lines != null && !hoistable.containsKey(config)) { + leftInPlace.add(lines); + } + } + return leftInPlace; + } + + /** + * Whether one of the given configs would outrank a directive hoisted out of the given file. Lombok takes the + * first file that speaks about a key as it walks up, so a file left standing between that directory and the root + * now has the last word. A line saying exactly what the hoisted file says resolves to the same thing and is not + * shadowing it; an {@code import} pulls in directives this recipe cannot see and is assumed to shadow. + */ + private static boolean isShadowed(List lines, List> leftInPlaceAbove) { + Set keys = new HashSet<>(); + Set canonical = new HashSet<>(); + for (LombokConfig.Line line : lines) { + if (line.normalizedKey != null) { + keys.add(line.normalizedKey); + canonical.add(line.canonical()); + } + } + + for (List above : leftInPlaceAbove) { + for (LombokConfig.Line line : above) { + if (line.kind == LombokConfig.Kind.IMPORT) { + return true; + } + if (line.normalizedKey != null && + keys.contains(line.normalizedKey) && + !canonical.contains(line.canonical())) { + return true; + } + } + } + return false; + } + + private static boolean isUnder(Path file, Set directories) { + for (Path directory = file.getParent(); directory != null; directory = directory.getParent()) { + if (directories.contains(directory)) { + return true; + } + } + return false; + } + + /** + * The files that are imported; deleting one would leave the file that imports it pointing at nothing. + */ + private static Set importedConfigs(Accumulator acc) { + Set imported = new HashSet<>(); + for (Map.Entry> config : acc.configs.entrySet()) { + for (LombokConfig.Line line : config.getValue()) { + if (line.kind == LombokConfig.Kind.IMPORT) { + Path path = resolveImport(config.getKey(), line); + if (path != null) { + imported.add(path); + } + } + } + } + return imported; + } + + /** + * Whether a config stops bubbling. Only an explicit {@code false} keeps bubbling on; a value that cannot be read + * as a boolean is treated as stopping it, so a directory is left alone rather than hoisted on a guess. + */ + private static boolean stopsBubbling(List lines) { + boolean stopsBubbling = false; + for (LombokConfig.Line line : lines) { + if (STOP_BUBBLING_KEY.equals(line.normalizedKey)) { + stopsBubbling = !"false".equalsIgnoreCase(line.value); + } + } + return stopsBubbling; + } + + /** + * Whether a nested config can be merged into the root, judged on its own contents. {@code config.stopBubbling} + * has opted its directory out of the root configuration, an {@code import} resolves relative to the file that + * declares it, and a {@code clear} or {@code -=} only means anything in relation to the additions it undoes. A + * line Lombok cannot read is left where it is rather than thrown away, as is a file with nothing to hoist. + */ + private static boolean isHoistable(List lines) { + boolean hoistable = false; + for (LombokConfig.Line line : lines) { + switch (line.kind) { + case IMPORT: + case CLEAR: + case REMOVE: + case INVALID: + return false; + case ASSIGN: + case ADD: + if (STOP_BUBBLING_KEY.equals(line.normalizedKey)) { + return false; + } + hoistable = true; + break; + default: + break; + } + } + return hoistable; + } + + /** + * Whether the files that would be merged disagree, so that there is no single configuration to consolidate to. + * Two {@code =} assignments of the same key to different values conflict outright, whereas several {@code +=} and + * {@code -=} of one key are expected to coexist. A directive also conflicts with a root {@code clear} or + * {@code -=} of the same key, as appending it after that line would put back what the root took away. + */ + private static boolean hasConflictingDirectives(List rootLines, + SortedMap> hoistable) { + Set undoneByRoot = new HashSet<>(); + for (LombokConfig.Line line : rootLines) { + if (line.kind == LombokConfig.Kind.CLEAR || line.kind == LombokConfig.Kind.REMOVE) { + undoneByRoot.add(line.normalizedKey); + } + } + + List> files = new ArrayList<>(); + files.add(rootLines); + files.addAll(hoistable.values()); + + Map assignments = new HashMap<>(); + for (List lines : files) { + for (Map.Entry assignment : lastAssignments(lines).entrySet()) { + String previous = assignments.put(assignment.getKey(), assignment.getValue()); + if (previous != null && !previous.equals(assignment.getValue())) { + return true; + } + } + } + + for (List lines : hoistable.values()) { + for (LombokConfig.Line line : lines) { + if (undoneByRoot.contains(line.normalizedKey)) { + return true; + } + } + } + return false; + } + + /** + * What each key of one file is assigned, taking the last assignment the way Lombok does. + */ + private static Map lastAssignments(List lines) { + Map assignments = new LinkedHashMap<>(); + for (LombokConfig.Line line : lines) { + if (line.kind == LombokConfig.Kind.ASSIGN) { + assignments.put(line.normalizedKey, line.value); + } + } + return assignments; + } + + /** + * Where in a file each key is assigned last, which is the only assignment worth carrying over: one the file + * itself supersedes would become the last word once appended to the root. + */ + private static Map lastAssignmentIndexes(List lines) { + Map indexes = new HashMap<>(); + for (int i = 0; i < lines.size(); i++) { + LombokConfig.Line line = lines.get(i); + if (line.kind == LombokConfig.Kind.ASSIGN) { + indexes.put(line.normalizedKey, i); + } + } + return indexes; + } + + /** + * The lines to append to the root {@code lombok.config}: whatever each nested file has to say that the root does + * not, shallowest file first, so that a {@code +=} of a directory above another still adds to the list before it + * does. A key is appended at most once, as by this point every file being merged agrees on what it is assigned, + * whereas a {@code +=} is appended once per distinct value. Comments come along with the directive they precede + * and are dropped along with a directive that is not carried over; a comment at the end of a file, documenting + * no directive, is carried over as it stands, as the file is about to be deleted. + */ + private static List additions(List rootLines, + SortedMap> hoistable) { + Set present = new HashSet<>(); + for (LombokConfig.Line line : rootLines) { + String canonical = line.canonical(); + if (canonical != null) { + present.add(canonical); + } + } + Set assigned = new HashSet<>(lastAssignments(rootLines).keySet()); + + List additions = new ArrayList<>(); + for (Map.Entry> config : shallowestFirst(hoistable)) { + List lines = config.getValue(); + Map lastAssignment = lastAssignmentIndexes(lines); + List comments = new ArrayList<>(); + for (int i = 0; i < lines.size(); i++) { + LombokConfig.Line line = lines.get(i); + if (line.kind == LombokConfig.Kind.COMMENT) { + comments.add(line.text); + continue; + } + if (line.kind == LombokConfig.Kind.BLANK) { + continue; + } + if (isCarriedOver(line, i, lastAssignment, present, assigned)) { + additions.addAll(comments); + additions.add(line.text); + } + comments.clear(); + } + additions.addAll(comments); + } + return additions; + } + + /** + * Whether the given line has to be appended for the root to say what the file says, remembering it as said so + * that a later file does not repeat it. + */ + private static boolean isCarriedOver(LombokConfig.Line line, int index, Map lastAssignment, + Set present, Set assigned) { + if (line.kind == LombokConfig.Kind.ASSIGN) { + Integer last = lastAssignment.get(line.normalizedKey); + return last != null && last == index && assigned.add(line.normalizedKey); + } + String canonical = line.canonical(); + return canonical != null && present.add(canonical); + } +} diff --git a/src/main/java/org/openrewrite/java/migrate/lombok/LombokConfig.java b/src/main/java/org/openrewrite/java/migrate/lombok/LombokConfig.java new file mode 100644 index 0000000000..1f27a4e567 --- /dev/null +++ b/src/main/java/org/openrewrite/java/migrate/lombok/LombokConfig.java @@ -0,0 +1,244 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * 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 org.openrewrite.java.migrate.lombok; + +import org.jspecify.annotations.Nullable; +import org.openrewrite.SourceFile; +import org.openrewrite.binary.Binary; +import org.openrewrite.quark.Quark; +import org.openrewrite.remote.Remote; + +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static java.util.Objects.requireNonNull; + +/** + * Shared handling of Lombok's configuration format: which files Lombok reads, how a line of one is read, and how + * lines are written back to it. + */ +final class LombokConfig { + + static final String LOMBOK_CONFIG = "lombok.config"; + + private static final String CONFIG_EXTENSION = ".config"; + + /** + * Tells Lombok to stop looking at parent directories, so it is scoped to the directory that declares it. + */ + static final String STOP_BUBBLING = "config.stopBubbling"; + + static final String STOP_BUBBLING_KEY = normalizeKey(STOP_BUBBLING); + + /** + * The line grammar of {@code lombok.core.configuration.ConfigurationParser}; anything else is an invalid line. + */ + private static final Pattern IMPORT = Pattern.compile("import\\s+(.+)"); + + private static final Pattern CLEAR = Pattern.compile("clear\\s+([^=]+)"); + + private static final Pattern ASSIGNMENT = Pattern.compile("(\\S+?)\\s*([+-]?=)\\s*(.*)"); + + private LombokConfig() { + } + + enum Kind { + BLANK, COMMENT, IMPORT, CLEAR, ASSIGN, ADD, REMOVE, INVALID + } + + /** + * One line of a {@code lombok.config}, classified the way Lombok classifies it and kept as it was written, so + * that merging never has to reformat it. + */ + static class Line { + final Kind kind; + + final String text; + + final @Nullable String key; + + final @Nullable String value; + + /** + * The key as Lombok compares it; {@code null} for a line that names no key, an {@code import} included, as it + * names a path. + */ + final @Nullable String normalizedKey; + + Line(Kind kind, String text, @Nullable String key, @Nullable String value) { + this.kind = kind; + this.text = text; + this.key = key; + this.value = value; + this.normalizedKey = key == null || kind == Kind.IMPORT ? null : normalizeKey(key); + } + + /** + * The directive as Lombok compares it, so that {@code key=value}, {@code key = value} and {@code KEY = value} + * dedupe against each other; {@code null} for a line that is not a directive. + */ + @Nullable + String canonical() { + switch (kind) { + case ASSIGN: + return normalizedKey + "=" + value; + case ADD: + return normalizedKey + "+=" + value; + case REMOVE: + return normalizedKey + "-=" + value; + default: + return null; + } + } + } + + static boolean isLombokConfig(SourceFile sourceFile) { + return isConfig(sourceFile) && LOMBOK_CONFIG.equals(sourceFile.getSourcePath().getFileName().toString()); + } + + /** + * Whether a file is one Lombok reads, of its own accord or because another file imports it. Only a file parsed as + * text can be read. + */ + static boolean isConfig(SourceFile sourceFile) { + return !(sourceFile instanceof Quark || sourceFile instanceof Remote || sourceFile instanceof Binary) && + sourceFile.getSourcePath().getFileName().toString().endsWith(CONFIG_EXTENSION); + } + + /** + * A key as Lombok compares it. {@code ConfigurationKey.registeredKeys()} is ordered by + * {@link String#CASE_INSENSITIVE_ORDER}, so {@code lombok.val.flagUsage} and {@code Lombok.Val.FlagUsage} are the + * same key. + */ + static String normalizeKey(String key) { + return key.toLowerCase(Locale.ROOT); + } + + static List parse(String text) { + List lines = new ArrayList<>(); + for (String line : text.split("\r?\n")) { + lines.add(classify(line.trim())); + } + return lines; + } + + /** + * Reads one line the way Lombok reads it, trying {@code import} and {@code clear} before an assignment so that + * neither is mistaken for a key. + */ + static Line classify(String trimmed) { + if (trimmed.isEmpty()) { + return new Line(Kind.BLANK, trimmed, null, null); + } + if (trimmed.charAt(0) == '#') { + return new Line(Kind.COMMENT, trimmed, null, null); + } + Matcher anImport = IMPORT.matcher(trimmed); + if (anImport.matches()) { + return new Line(Kind.IMPORT, trimmed, anImport.group(1).trim(), null); + } + Matcher clear = CLEAR.matcher(trimmed); + if (clear.matches()) { + return new Line(Kind.CLEAR, trimmed, clear.group(1).trim(), null); + } + Matcher assignment = ASSIGNMENT.matcher(trimmed); + if (assignment.matches()) { + Kind kind = "+=".equals(assignment.group(2)) ? Kind.ADD : + "-=".equals(assignment.group(2)) ? Kind.REMOVE : Kind.ASSIGN; + return new Line(kind, trimmed, assignment.group(1), assignment.group(3).trim()); + } + return new Line(Kind.INVALID, trimmed, null, null); + } + + /** + * The file an {@code import} names, resolved against the directory of the file that declares it the way Lombok + * resolves it; {@code null} when the line does not name a path this file system can name. + */ + static @Nullable Path resolveImport(Path config, Line line) { + try { + Path path = Paths.get(requireNonNull(line.key)); + Path directory = config.getParent(); + return (directory == null ? path : directory.resolve(path)).normalize(); + } catch (InvalidPathException e) { + return null; + } + } + + /** + * The given lines with every {@code import} replaced, where it stands, by the lines of the file it names, so that + * a later line still has the last word. {@code null} when an imported file is not among the sources or the + * imports form a cycle, as there is then no telling what the file declares. + */ + static @Nullable List expandImports(Path config, List lines, Map> configs, + Set beingRead) { + if (!beingRead.add(config)) { + return null; + } + List expanded = new ArrayList<>(); + for (Line line : lines) { + if (line.kind != Kind.IMPORT) { + expanded.add(line); + continue; + } + Path path = resolveImport(config, line); + List imported = path == null ? null : configs.get(path); + if (imported == null) { + return null; + } + List importedExpanded = expandImports(path, imported, configs, beingRead); + if (importedExpanded == null) { + return null; + } + expanded.addAll(importedExpanded); + } + beingRead.remove(config); + return expanded; + } + + static boolean declaresStopBubbling(List lines) { + for (Line line : lines) { + if (STOP_BUBBLING_KEY.equals(line.normalizedKey)) { + return true; + } + } + return false; + } + + /** + * The given text with the given lines appended, preserving its line endings and trailing newline. + */ + static String append(String text, List additions) { + String newLine = text.contains("\r\n") ? "\r\n" : "\n"; + boolean endsWithNewLine = text.isEmpty() || text.endsWith("\n"); + StringBuilder merged = new StringBuilder(text); + if (!endsWithNewLine) { + merged.append(newLine); + } + merged.append(String.join(newLine, additions)); + if (endsWithNewLine) { + merged.append(newLine); + } + return merged.toString(); + } +} diff --git a/src/main/resources/META-INF/rewrite/recipes.csv b/src/main/resources/META-INF/rewrite/recipes.csv index af5d7289b4..36e2e57730 100644 --- a/src/main/resources/META-INF/rewrite/recipes.csv +++ b/src/main/resources/META-INF/rewrite/recipes.csv @@ -411,6 +411,7 @@ maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.l maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.logging.MigrateLogRecordSetMillisToSetInstant,Use `LogRecord#setInstant(Instant)`,Use `LogRecord#setInstant(Instant)` instead of the deprecated `LogRecord#setMillis(long)` in Java 9 or higher.,1,,`java.util.logging` APIs,Modernize,Java,,,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,, maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.logging.MigrateLoggerGlobalToGetGlobal,Use `Logger#getGlobal()`,The preferred way to get the global logger object is via the call `Logger#getGlobal()` over direct field access to `java.util.logging.Logger.global`.,1,,`java.util.logging` APIs,Modernize,Java,,,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,, maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.logging.MigrateLoggerLogrbToUseResourceBundle,"Use `Logger#logrb(.., ResourceBundle bundleName, ..)`","Use `Logger#logrb(.., ResourceBundle bundleName, ..)` instead of the deprecated `java.util.logging.Logger#logrb(.., String bundleName, ..)` in Java 8 or higher.",1,,`java.util.logging` APIs,Modernize,Java,,,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,, +maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.lombok.AddStopBubblingToLombokConfig,Add `config.stopBubbling` to the root `lombok.config`,"Append `config.stopBubbling = true` to the root `lombok.config` when it does not already declare that key, so that Lombok reads the project's configuration and nothing else. Lombok resolves a key by walking up from the directory of the Java file it is compiling and does not stop at the project, so without this key a `lombok.config` in a parent directory of wherever the project happens to be checked out takes part in the build. Note that this cuts the project off from such a file whether or not it was meant to be read, so a project that deliberately inherits configuration from a directory above it should not run this recipe. Nothing is added when the key is already declared, whatever value it is assigned, as a project that turns bubbling off explicitly, or back on again, is doing so deliberately; nor when the root file imports a file that is not among the sources, as there is then no telling whether that file declares the key.",1,,Lombok,Modernize,Java,,Recipes for working with [Lombok](https://projectlombok.org/).,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,, maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.lombok.AdoptLombokGetterMethodNames,Rename getter methods to fit Lombok,"Rename methods that are effectively getter to the name Lombok would give them. Limitations: @@ -424,6 +425,7 @@ Limitations: - If the correct name for a method is already taken by another method then the name will not be corrected. - Method name swaps or circular renaming within a class cannot be performed because the names block each other. E.g. `int getFoo() { return ba; } int getBa() { return foo; }` stays as it is.",1,,Lombok,Modernize,Java,,Recipes for working with [Lombok](https://projectlombok.org/).,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,, +maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.lombok.ConsolidateLombokConfig,Consolidate `lombok.config` files,"Merge the directives of every nested `lombok.config` into the root `lombok.config` and delete the nested files, so that a project has a single place where Lombok is configured. Directives are appended to the root file, leaving whatever it already contains exactly as it was written, and directives the root already declares are dropped rather than repeated. Note that hoisting a directive widens its scope from the directory that declared it to the whole project, so a directive that only some directories can satisfy, such as `lombok.val.flagUsage = error`, will start to apply to all of them. A nested file is left in place when its directives cannot be moved without changing what Lombok does: `config.stopBubbling` opts its directory, and everything under it, out of the root configuration, `import` resolves relative to the file that declares it, and `clear` and `-=` depend on the order they appear in relative to the additions they undo. A nested file is left in place as well when a `lombok.config` between it and the root speaks about one of the same directives, or imports a file that may, because that file would outrank the root once the directive moved there. A `lombok.config` another one imports is left in place as well, as deleting it would leave that import pointing at nothing, and so is a `lombok.config` with no Java source at or below it, such as a fixture under `src/test/resources`, as Lombok never reads it. What the root `lombok.config` imports is read as part of it, so that a directive the imported file already declares is neither repeated nor overridden. No changes are made when two files assign conflicting values to the same directive, as there is no way to tell which value the consolidated configuration should keep, nor when the root imports a file that is not among the sources, as there is then no telling what that file declares.",1,,Lombok,Modernize,Java,,Recipes for working with [Lombok](https://projectlombok.org/).,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,, maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.lombok.LombokBestPractices,Lombok Best Practices,Applies all recipes that enforce best practices for using Lombok.,28,,Lombok,Modernize,Java,,Recipes for working with [Lombok](https://projectlombok.org/).,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,,"[{""name"":""org.openrewrite.maven.table.MavenMetadataFailures"",""displayName"":""Maven metadata failures"",""instanceName"":""Maven metadata failures"",""description"":""Attempts to resolve maven metadata that failed."",""columns"":[{""name"":""group"",""type"":""String"",""displayName"":""Group id"",""description"":""The groupId of the artifact for which the metadata download failed.""},{""name"":""artifactId"",""type"":""String"",""displayName"":""Artifact id"",""description"":""The artifactId of the artifact for which the metadata download failed.""},{""name"":""version"",""type"":""String"",""displayName"":""Version"",""description"":""The version of the artifact for which the metadata download failed.""},{""name"":""mavenRepositoryUri"",""type"":""String"",""displayName"":""Maven repository"",""description"":""The URL of the Maven repository that the metadata download failed on.""},{""name"":""snapshots"",""type"":""String"",""displayName"":""Snapshots"",""description"":""Does the repository support snapshots.""},{""name"":""releases"",""type"":""String"",""displayName"":""Releases"",""description"":""Does the repository support releases.""},{""name"":""failure"",""type"":""String"",""displayName"":""Failure"",""description"":""The reason the metadata download failed.""}]}]" maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.lombok.LombokOnXToOnX_,Migrate Lombok's `@__` syntax to `onX_` for Java 8+,"Migrates Lombok's `onX` annotations from the Java 7 style using `@__` to the Java 8+ style using `onX_`. For example, `@Getter(onMethod=@__({@Id}))` becomes `@Getter(onMethod_={@Id})`.",1,,Lombok,Modernize,Java,,Recipes for working with [Lombok](https://projectlombok.org/).,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,, maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.lombok.LombokValToFinalVar,Prefer `final var` over `lombok.val`,Prefer the Java standard library's `final var` and `var` over third-party usage of Lombok's `lombok.val` and `lombok.var` in Java 10 or higher.,1,,Lombok,Modernize,Java,,Recipes for working with [Lombok](https://projectlombok.org/).,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,, diff --git a/src/test/java/org/openrewrite/java/migrate/lombok/AddStopBubblingToLombokConfigTest.java b/src/test/java/org/openrewrite/java/migrate/lombok/AddStopBubblingToLombokConfigTest.java new file mode 100644 index 0000000000..4d686d52d9 --- /dev/null +++ b/src/test/java/org/openrewrite/java/migrate/lombok/AddStopBubblingToLombokConfigTest.java @@ -0,0 +1,278 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * 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 org.openrewrite.java.migrate.lombok; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.test.SourceSpecs.text; + +class AddStopBubblingToLombokConfigTest implements RewriteTest { + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new AddStopBubblingToLombokConfig()); + } + + @DocumentExample + @Test + void addStopBubblingToRootConfig() { + rewriteRun( + text( + """ + lombok.val.flagUsage = error + lombok.var.flagUsage = error + """, + """ + lombok.val.flagUsage = error + lombok.var.flagUsage = error + config.stopBubbling = true + """, + spec -> spec.path("lombok.config") + ) + ); + } + + @Test + void rootConfigThatAlreadyStopsBubblingIsNotChanged() { + rewriteRun( + text( + """ + config.stopBubbling = true + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ) + ); + } + + @Test + void rootConfigThatTurnsOffStopBubblingIsLeftAlone() { + rewriteRun( + text( + """ + config.stopBubbling = false + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ) + ); + } + + @Test + void stopBubblingSpelledWithADifferentCaseIsNotAddedAgain() { + rewriteRun( + text( + """ + config.stopbubbling = true + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ) + ); + } + + @Test + void stopBubblingDeclaredByAnImportedFileIsNotAddedAgain() { + rewriteRun( + text( + """ + import shared/base.config + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + config.stopBubbling = true + """, + spec -> spec.path("shared/base.config") + ) + ); + } + + @Test + void rootConfigImportingAFileThatIsNotAmongTheSourcesIsLeftAlone() { + rewriteRun( + text( + """ + import shared/base.config + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ) + ); + } + + @Test + void importsAreFollowedThroughTheFilesTheyName() { + rewriteRun( + text( + """ + import shared/base.config + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + import deeper.config + """, + spec -> spec.path("shared/base.config") + ), + text( + """ + config.stopBubbling = true + """, + spec -> spec.path("shared/deeper.config") + ) + ); + } + + @Test + void importsThatLeadBackAroundLeaveTheRootAlone() { + rewriteRun( + text( + """ + import shared/base.config + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + import ../lombok.config + """, + spec -> spec.path("shared/base.config") + ) + ); + } + + @Test + void nestedConfigsAreNotChanged() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void nestedConfigIsNotTreatedAsTheRootConfig() { + rewriteRun( + text( + """ + lombok.val.flagUsage = error + """, + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void doNothingWhenLombokConfigIsAbsent() { + rewriteRun( + text( + """ + This is a README file. + """, + spec -> spec.path("README.md") + ) + ); + } + + @Test + void unrelatedConfigFileIsNotChanged() { + rewriteRun( + text( + """ + whatever=true + """, + spec -> spec.path("unrelated.config") + ) + ); + } + + @Test + void emptyRootConfig() { + rewriteRun( + text( + "", + """ + config.stopBubbling = true + + """, + spec -> spec.path("lombok.config") + ) + ); + } + + @Test + void rootConfigWithoutATrailingNewLine() { + rewriteRun( + text( + "lombok.val.flagUsage = error", + """ + lombok.val.flagUsage = error + config.stopBubbling = true""", + spec -> spec.path("lombok.config") + ) + ); + } + + @Test + void rootConfigWithWindowsLineEndingsIsAppendedToWithWindowsLineEndings() { + rewriteRun( + text( + "lombok.val.flagUsage = error\r\nlombok.var.flagUsage = error", + "lombok.val.flagUsage = error\r\nlombok.var.flagUsage = error\r\nconfig.stopBubbling = true", + spec -> spec.path("lombok.config") + ) + ); + } + + @Test + void rootConfigIsNotReformatted() { + rewriteRun( + text( + """ + # Keep the comments and the odd spacing exactly as they are. + lombok.val.flagUsage=error + + lombok.var.flagUsage = error + """, + """ + # Keep the comments and the odd spacing exactly as they are. + lombok.val.flagUsage=error + + lombok.var.flagUsage = error + config.stopBubbling = true + """, + spec -> spec.path("lombok.config") + ) + ); + } +} diff --git a/src/test/java/org/openrewrite/java/migrate/lombok/ConsolidateLombokConfigTest.java b/src/test/java/org/openrewrite/java/migrate/lombok/ConsolidateLombokConfigTest.java new file mode 100755 index 0000000000..4e87e80dc0 --- /dev/null +++ b/src/test/java/org/openrewrite/java/migrate/lombok/ConsolidateLombokConfigTest.java @@ -0,0 +1,1549 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * 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 org.openrewrite.java.migrate.lombok; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.java.Assertions.java; +import static org.openrewrite.test.SourceSpecs.text; + +class ConsolidateLombokConfigTest implements RewriteTest { + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new ConsolidateLombokConfig()); + } + + @DocumentExample + @Test + void mergeNestedConfigsIntoRootConfig() { + rewriteRun( + text( + """ + config.stopBubbling = true + lombok.addLombokGeneratedAnnotation = true + lombok.anyConstructor.addConstructorProperties = true + lombok.extern.findbugs.addSuppressFBWarnings = true + lombok.val.flagUsage = error + lombok.var.flagUsage = error + """, + """ + config.stopBubbling = true + lombok.addLombokGeneratedAnnotation = true + lombok.anyConstructor.addConstructorProperties = true + lombok.extern.findbugs.addSuppressFBWarnings = true + lombok.val.flagUsage = error + lombok.var.flagUsage = error + a1=a1value + a2=a2value + other=foobar + b1=b1value + b2=b2value + lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier + """, + spec -> spec.path("lombok.config") + ), + text( + """ + a1=a1value + a2=a2value + other=foobar + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ), + text( + """ + b1=b1value + b2=b2value + other=foobar + lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier + """, + doesNotExist(), + spec -> spec.path("b/lombok.config") + ) + ); + } + + @Test + void conflictingDirectiveInNestedConfig() { + rewriteRun( + text( + """ + config.stopBubbling = true + lombok.addLombokGeneratedAnnotation = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.addLombokGeneratedAnnotation = false + """, + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void conflictingDirectivesInNestedConfigs() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.addLombokGeneratedAnnotation = true + """, + spec -> spec.path("a/lombok.config") + ), + text( + """ + lombok.addLombokGeneratedAnnotation = false + """, + spec -> spec.path("b/lombok.config") + ) + ); + } + + @Test + void listDirectivesWithDifferentValuesDoNotConflict() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + """ + config.stopBubbling = true + lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier + lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Value + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ), + text( + """ + lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Value + """, + doesNotExist(), + spec -> spec.path("b/lombok.config") + ) + ); + } + + @Test + void spacingAroundTheOperatorDoesNotPreventDeduplication() { + rewriteRun( + text( + """ + config.stopBubbling = true + lombok.addLombokGeneratedAnnotation = true + """, + """ + config.stopBubbling = true + lombok.addLombokGeneratedAnnotation = true + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.addLombokGeneratedAnnotation=true + lombok.val.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void indentedDirectivesAreNormalized() { + rewriteRun( + text( + """ + config.stopBubbling = true + lombok.addLombokGeneratedAnnotation = true + """, + """ + config.stopBubbling = true + lombok.addLombokGeneratedAnnotation = true + lombok.copyableAnnotations += com.example.Ann + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + // The first line is left unindented so that `trimIndent` does not strip the indentation under test. + text( + """ + lombok.copyableAnnotations += com.example.Ann + lombok.addLombokGeneratedAnnotation = true + lombok.val.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void singleConfigIsNotChanged() { + rewriteRun( + text( + """ + config.stopBubbling = true + lombok.addLombokGeneratedAnnotation = true + lombok.anyConstructor.addConstructorProperties = true + lombok.extern.findbugs.addSuppressFBWarnings = true + lombok.val.flagUsage = error + lombok.var.flagUsage = error + """, + spec -> spec.path("lombok.config") + ) + ); + } + + @Test + void ignoreUnrelatedConfigFile() { + rewriteRun( + text( + """ + config.stopBubbling = true + lombok.val.flagUsage = error + lombok.var.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + whatever=true + """, + spec -> spec.path("unrelated/unrelated.config") + ) + ); + } + + @Test + void doNothingWhenLombokConfigIsAbsent() { + rewriteRun( + text( + """ + This is a README file. + """, + spec -> spec.path("README.md") + ) + ); + } + + @Test + void rootConfigIsNotReformatted() { + rewriteRun( + text( + """ + # Lombok configuration for this project. + + # val is fine, var is not. + lombok.val.flagUsage = allow + lombok.var.flagUsage=error + + config.stopBubbling = true + """, + """ + # Lombok configuration for this project. + + # val is fine, var is not. + lombok.val.flagUsage = allow + lombok.var.flagUsage=error + + config.stopBubbling = true + lombok.addLombokGeneratedAnnotation = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.addLombokGeneratedAnnotation = true + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void rootConfigImportingAFileThatIsNotAmongTheSourcesIsLeftAlone() { + rewriteRun( + text( + """ + import shared/base.config + config.stopBubbling = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void importInRootConfigIsPreservedAtTheTopOfTheFile() { + rewriteRun( + text( + """ + import shared/base.config + config.stopBubbling = true + """, + """ + import shared/base.config + config.stopBubbling = true + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.addLombokGeneratedAnnotation = true + """, + spec -> spec.path("shared/base.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void directiveTheRootImportsAlreadyDeclaresIsNotRepeated() { + rewriteRun( + text( + """ + import shared/base.config + config.stopBubbling = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + spec -> spec.path("shared/base.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void directiveConflictingWithWhatTheRootImportsAbortsTheRecipe() { + rewriteRun( + text( + """ + import shared/base.config + config.stopBubbling = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.val.flagUsage = warning + """, + spec -> spec.path("shared/base.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void importsAreFollowedThroughTheFilesTheyName() { + rewriteRun( + text( + """ + import shared/base.config + config.stopBubbling = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + import more.config + """, + spec -> spec.path("shared/base.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + spec -> spec.path("shared/more.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void importsThatLeadBackAroundAbortTheRecipe() { + rewriteRun( + text( + """ + import shared/base.config + config.stopBubbling = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + import ../lombok.config + """, + spec -> spec.path("shared/base.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void configImportedByAnotherConfigIsLeftInPlace() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + """ + config.stopBubbling = true + lombok.var.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + import ../b/lombok.config + """, + spec -> spec.path("a/lombok.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + spec -> spec.path("b/lombok.config") + ), + text( + """ + lombok.var.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("c/lombok.config") + ) + ); + } + + @Test + void clearInRootConfigIsPreserved() { + rewriteRun( + text( + """ + clear lombok.copyableAnnotations + config.stopBubbling = true + """, + """ + clear lombok.copyableAnnotations + config.stopBubbling = true + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void linesLombokCannotReadArePreserved() { + rewriteRun( + text( + """ + this is not a directive + config.stopBubbling = true + """, + """ + this is not a directive + config.stopBubbling = true + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void nestedConfigDeclaringStopBubblingIsLeftInPlace() { + rewriteRun( + text( + """ + config.stopBubbling = true + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + config.stopBubbling = true + lombok.addLombokGeneratedAnnotation = true + """, + spec -> spec.path("generated/lombok.config") + ) + ); + } + + @Test + void nestedConfigTurningOffStopBubblingIsLeftInPlace() { + rewriteRun( + text( + """ + config.stopBubbling = true + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + config.stopBubbling = false + a=aValue + """, + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void nestedImportIsLeftInPlace() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + import base.config + lombok.val.flagUsage = error + """, + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void nestedClearIsLeftInPlace() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + clear lombok.copyableAnnotations + lombok.copyableAnnotations += com.example.Ann + """, + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void nestedRemoveIsLeftInPlaceBecauseItsOrderMatters() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.copyableAnnotations -= com.example.Ann + lombok.copyableAnnotations += com.example.Ann + """, + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void nestedLinesLombokCannotReadAreLeftInPlace() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + this is not a directive + lombok.val.flagUsage = error + """, + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void nestedConfigWithNothingToHoistIsLeftInPlace() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + # Nothing to see here. + """, + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void nestedCommentsAreHoistedWithTheirDirectives() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + """ + config.stopBubbling = true + # Generated sources need the annotation. + lombok.addLombokGeneratedAnnotation = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + # Generated sources need the annotation. + lombok.addLombokGeneratedAnnotation = true + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void commentAtTheEndOfANestedConfigIsCarriedOver() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + """ + config.stopBubbling = true + lombok.val.flagUsage = error + # TODO revisit once module a no longer uses val. + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.val.flagUsage = error + # TODO revisit once module a no longer uses val. + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void commentAtTheEndOfARedundantNestedConfigIsCarriedOver() { + rewriteRun( + text( + """ + config.stopBubbling = true + lombok.val.flagUsage = error + """, + """ + config.stopBubbling = true + lombok.val.flagUsage = error + # TODO revisit once module a no longer uses val. + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.val.flagUsage = error + # TODO revisit once module a no longer uses val. + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void additionsOfADirectoryAboveAnotherAreAppendedFirstToKeepTheirOrderInTheList() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + """ + config.stopBubbling = true + lombok.copyableAnnotations += com.example.A + lombok.copyableAnnotations += com.example.B + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.copyableAnnotations += com.example.A + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ), + text( + """ + lombok.copyableAnnotations += com.example.B + """, + doesNotExist(), + spec -> spec.path("a/b/lombok.config") + ) + ); + } + + @Test + void rootClearOfAKeyBlocksHoistingThatKey() { + rewriteRun( + text( + """ + config.stopBubbling = true + clear lombok.copyableAnnotations + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.copyableAnnotations += com.example.Ann + """, + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void rootRemovalOfAValueBlocksHoistingThatKey() { + rewriteRun( + text( + """ + config.stopBubbling = true + lombok.copyableAnnotations -= com.example.Ann + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.copyableAnnotations += com.example.Ann + """, + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void redundantNestedConfigIsDeletedWithoutChangingTheRoot() { + rewriteRun( + text( + """ + config.stopBubbling = true + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void deeplyNestedConfigsAreMergedInAPredictableOrder() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + """ + config.stopBubbling = true + b=bValue + a=aValue + """, + spec -> spec.path("lombok.config") + ), + text( + """ + a=aValue + """, + doesNotExist(), + spec -> spec.path("modules/z/deep/lombok.config") + ), + text( + """ + b=bValue + """, + doesNotExist(), + spec -> spec.path("modules/a/lombok.config") + ) + ); + } + + @Test + void nestedConfigsAreLeftInPlaceWithoutARootConfig() { + rewriteRun( + text( + """ + lombok.val.flagUsage = error + """, + spec -> spec.path("module/lombok.config") + ), + text( + """ + lombok.var.flagUsage = error + """, + spec -> spec.path("module/nested/lombok.config") + ) + ); + } + + @Test + void nothingIsChangedWhenNothingCanBeHoisted() { + rewriteRun( + text( + """ + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + import base.config + lombok.var.flagUsage = error + """, + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void hoistableAndUnhoistableNestedConfigsAreHandledIndependently() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + """ + config.stopBubbling = true + b=bValue + """, + spec -> spec.path("lombok.config") + ), + text( + """ + import base.config + a=aValue + """, + spec -> spec.path("a/lombok.config") + ), + text( + """ + b=bValue + """, + doesNotExist(), + spec -> spec.path("b/lombok.config") + ) + ); + } + + @Test + void commentSyntaxLombokDoesNotSupportIsLeftInPlace() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + // Lombok comments start with a hash, so Lombok cannot read this line. + lombok.val.flagUsage = error + """, + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void nestedConfigWithOnlyBlankLinesIsLeftInPlace() { + rewriteRun( + text( + """ + + + """, + spec -> spec.path("a/lombok.config") + ), + text( + """ + config.stopBubbling = true + """, + spec -> spec.path("lombok.config") + ) + ); + } + + @Test + void blankLinesInNestedConfigsAreNotHoisted() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + """ + config.stopBubbling = true + lombok.val.flagUsage = error + lombok.var.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.val.flagUsage = error + + lombok.var.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void spacingAroundTheListOperatorDoesNotPreventDeduplication() { + rewriteRun( + text( + """ + config.stopBubbling = true + lombok.copyableAnnotations += com.example.Ann + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.copyableAnnotations+=com.example.Ann + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void valueContainingAnEqualsSignIsReadAsPartOfTheValue() { + rewriteRun( + text( + """ + config.stopBubbling = true + lombok.nonNull.exceptionType = a=b + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.nonNull.exceptionType=a=b + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void emptyRootConfig() { + rewriteRun( + text( + "", + """ + lombok.val.flagUsage = error + + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void rootConfigWithoutATrailingNewLine() { + rewriteRun( + text( + "config.stopBubbling = true", + """ + config.stopBubbling = true + lombok.val.flagUsage = error""", + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void rootConfigWithWindowsLineEndingsIsAppendedToWithWindowsLineEndings() { + rewriteRun( + text( + "config.stopBubbling = true\r\nlombok.var.flagUsage = error", + "config.stopBubbling = true\r\nlombok.var.flagUsage = error\r\nlombok.val.flagUsage = error", + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void rootConfigEndingWithABlankLineKeepsItsTrailingNewLine() { + rewriteRun( + text( + """ + config.stopBubbling = true + + """, + """ + config.stopBubbling = true + lombok.val.flagUsage = error + + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void nestedConfigUnderAStopBubblingAncestorIsLeftInPlace() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + config.stopBubbling = true + lombok.addLombokGeneratedAnnotation = true + """, + spec -> spec.path("generated/lombok.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + spec -> spec.path("generated/nested/lombok.config") + ) + ); + } + + @Test + void commentIsNotHoistedWhenItsDirectiveIsAlreadyDeclaredByTheRoot() { + rewriteRun( + text( + """ + config.stopBubbling = true + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + # val is banned here too. + lombok.val.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void keyAssignedTwiceInOneFileIsNotAConflict() { + rewriteRun( + text( + """ + config.stopBubbling = true + lombok.val.flagUsage = allow + lombok.val.flagUsage = error + """, + """ + config.stopBubbling = true + lombok.val.flagUsage = allow + lombok.val.flagUsage = error + lombok.addLombokGeneratedAnnotation = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.addLombokGeneratedAnnotation = true + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void hoistingContinuesBelowADirectoryThatTurnsOffStopBubbling() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + """ + config.stopBubbling = true + lombok.var.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + config.stopBubbling = false + lombok.val.flagUsage = error + """, + spec -> spec.path("a/lombok.config") + ), + text( + """ + lombok.var.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("a/nested/lombok.config") + ) + ); + } + + @Test + void onlyCommentsWhoseDirectiveIsHoistedAreCarriedOver() { + rewriteRun( + text( + """ + config.stopBubbling = true + lombok.val.flagUsage = error + """, + """ + config.stopBubbling = true + lombok.val.flagUsage = error + # var is banned here as well. + lombok.var.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + # val is banned everywhere. + lombok.val.flagUsage = error + # var is banned here as well. + lombok.var.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void keyAssignedTwiceInANestedConfigKeepsOnlyTheAssignmentThatWins() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + """ + config.stopBubbling = true + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.val.flagUsage = allow + lombok.val.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void assignmentSupersededInItsOwnFileDoesNotOutrankTheRoot() { + rewriteRun( + text( + """ + config.stopBubbling = true + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.val.flagUsage = warning + lombok.val.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void assignmentSupersededInItsOwnFileDoesNotOutrankAnotherNestedConfig() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + """ + config.stopBubbling = true + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ), + text( + """ + lombok.val.flagUsage = warning + lombok.val.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("b/lombok.config") + ) + ); + } + + @Test + void nestedStopBubblingSpelledWithADifferentCaseIsLeftInPlace() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + config.stopbubbling = true + lombok.addLombokGeneratedAnnotation = true + """, + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void conflictingValuesForAKeySpelledWithADifferentCaseAbortTheRecipe() { + rewriteRun( + text( + """ + config.stopBubbling = true + lombok.addLombokGeneratedAnnotation = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.addlombokgeneratedannotation = false + """, + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void keySpelledWithADifferentCaseIsRecognizedAsAlreadyDeclaredByTheRoot() { + rewriteRun( + text( + """ + config.stopBubbling = true + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + Lombok.Val.FlagUsage = error + """, + doesNotExist(), + spec -> spec.path("a/lombok.config") + ) + ); + } + + @Test + void nestedConfigShadowedByAnAncestorAssignmentIsLeftInPlace() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + clear lombok.copyableAnnotations + lombok.fieldDefaults.defaultFinal = false + """, + spec -> spec.path("a/lombok.config") + ), + text( + """ + lombok.fieldDefaults.defaultFinal = true + """, + spec -> spec.path("a/b/lombok.config") + ) + ); + } + + @Test + void nestedConfigWhoseAdditionAnAncestorClearsIsLeftInPlace() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + clear lombok.copyableAnnotations + """, + spec -> spec.path("a/lombok.config") + ), + text( + """ + lombok.copyableAnnotations += com.example.Ann + """, + spec -> spec.path("a/b/lombok.config") + ) + ); + } + + @Test + void nestedConfigBelowAnAncestorThatImportsIsLeftInPlace() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + import base.config + """, + spec -> spec.path("a/lombok.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + spec -> spec.path("a/b/lombok.config") + ) + ); + } + + @Test + void nestedConfigIsHoistedWhenAnAncestorLeftInPlaceSaysTheSameThing() { + rewriteRun( + text( + """ + config.stopBubbling = true + """, + """ + config.stopBubbling = true + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + clear lombok.copyableAnnotations + lombok.val.flagUsage = error + """, + spec -> spec.path("a/lombok.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("a/b/lombok.config") + ) + ); + } + + @Test + void configWithNoJavaSourceBelowItIsLeftInPlace() { + rewriteRun( + java( + """ + class Foo { + } + """, + spec -> spec.path("src/main/java/Foo.java") + ), + text( + """ + config.stopBubbling = true + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + spec -> spec.path("src/test/resources/fixtures/lombok.config") + ) + ); + } + + @Test + void configGoverningTestSourcesIsHoisted() { + rewriteRun( + java( + """ + package com.foo; + + class Foo { + } + """, + spec -> spec.path("src/test/java/com/foo/Foo.java") + ), + text( + """ + config.stopBubbling = true + """, + """ + config.stopBubbling = true + lombok.val.flagUsage = error + """, + spec -> spec.path("lombok.config") + ), + text( + """ + lombok.val.flagUsage = error + """, + doesNotExist(), + spec -> spec.path("src/test/java/com/foo/lombok.config") + ) + ); + } +}