From 986d35b27fd1aab95a97136b2e4d686c0fd1c803 Mon Sep 17 00:00:00 2001 From: Jonathan Schneider Date: Tue, 16 Jul 2024 01:32:49 -0600 Subject: [PATCH] Recipe#buildRecipeList to aid AI code assistants to write recipes --- .../src/main/java/org/openrewrite/Recipe.java | 87 ++++++++++++++- .../main/java/org/openrewrite/RecipeList.java | 59 ++++++++++ .../java/org/openrewrite/RecipeListTest.java | 104 ++++++++++++++++++ 3 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 rewrite-core/src/main/java/org/openrewrite/RecipeList.java create mode 100644 rewrite-core/src/test/java/org/openrewrite/RecipeListTest.java diff --git a/rewrite-core/src/main/java/org/openrewrite/Recipe.java b/rewrite-core/src/main/java/org/openrewrite/Recipe.java index 6ed845bc49f..d1993685dd1 100644 --- a/rewrite-core/src/main/java/org/openrewrite/Recipe.java +++ b/rewrite-core/src/main/java/org/openrewrite/Recipe.java @@ -18,7 +18,10 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; import lombok.Setter; +import lombok.experimental.FieldDefaults; import org.intellij.lang.annotations.Language; import org.openrewrite.config.DataTableDescriptor; import org.openrewrite.config.OptionDescriptor; @@ -308,11 +311,40 @@ public boolean causesAnotherCycle() { * A list of recipes that run, source file by source file, * after this recipe. This method is guaranteed to be called only once * per cycle. + *

+ * When creating a recipe with a fixed recipe list, either override + * this method or {@link #buildRecipeList(RecipeList)} but ideally not + * both, as their default implementations are interconnected. * * @return The list of recipes to run. */ public List getRecipeList() { - return Collections.emptyList(); + RecipeList list = new RecipeList(getName()); + buildRecipeList(list); + return list.getRecipes(); + } + + /** + * Used to build up a recipe list programmatically. Using the + * methods on {@link RecipeList}, the appearance of a recipe + * that chains other recipes with options will be not strikingly + * different from defining it in a recipe.yml. + *

+ * Building, or at least starting to build, recipes for complex + * migrations with this method is more amenable to AI coding assistants + * since these assistants are primarily optimized for providing completion + * assistance in a single file. + *

+ * When creating a recipe with a fixed recipe list, either override + * this method or {@link #getRecipeList()} but ideally not + * both, as their default implementations are interconnected. + * + * @param list A recipe list used to build up a series of recipes + * in code in a way that looks fairly declarative and + * therefore is more amenable to AI code completion. + */ + @SuppressWarnings("unused") + public void buildRecipeList(RecipeList list) { } /** @@ -424,4 +456,57 @@ public Object clone() { public interface DelegatingRecipe { Recipe getDelegate(); } + + /** + * @return A new recipe builder. + */ + @Incubating(since = "8.31.0") + public static Builder builder(@NlsRewrite.DisplayName @Language("markdown") String displayName, + @NlsRewrite.Description @Language("markdown") String description) { + return new Builder(displayName, description); + } + + @Incubating(since = "8.31.0") + @RequiredArgsConstructor + @FieldDefaults(level = AccessLevel.PRIVATE) + public static class Builder { + @NlsRewrite.DisplayName + @Language("markdown") + final String displayName; + + @NlsRewrite.Description + @Language("markdown") + final String description; + + TreeVisitor visitor = TreeVisitor.noop(); + + public Builder visitor(TreeVisitor visitor) { + this.visitor = visitor; + return this; + } + + public Recipe build(String name) { + return new Recipe() { + @Override + public String getName() { + return name; + } + + @Override + public String getDisplayName() { + return displayName; + } + + @Override + public String getDescription() { + return description; + } + + @Override + public TreeVisitor getVisitor() { + return visitor; + } + }; + } + } } diff --git a/rewrite-core/src/main/java/org/openrewrite/RecipeList.java b/rewrite-core/src/main/java/org/openrewrite/RecipeList.java new file mode 100644 index 00000000000..6f6e9174dec --- /dev/null +++ b/rewrite-core/src/main/java/org/openrewrite/RecipeList.java @@ -0,0 +1,59 @@ +/* + * Copyright 2024 the original author or authors. + *

+ * 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 + *

+ * https://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 org.openrewrite; + +import lombok.RequiredArgsConstructor; +import org.intellij.lang.annotations.Language; + +import java.util.ArrayList; +import java.util.List; + +import static java.util.Collections.emptyList; + +@Incubating(since = "8.31.0") +@RequiredArgsConstructor +public class RecipeList { + private final String parentRecipeName; + private int recipeIndex = 1; + + private List recipes; + + public RecipeList recipe(Recipe.Builder recipe) { + return addRecipe(recipe.build(parentRecipeName + "$" + recipeIndex++)); + } + + public RecipeList recipe(@NlsRewrite.DisplayName @Language("markdown") String displayName, + @NlsRewrite.Description @Language("markdown") String description, + TreeVisitor visitor) { + return recipe(Recipe.builder(displayName, description).visitor(visitor)); + } + + public RecipeList recipe(org.openrewrite.Recipe recipe) { + return addRecipe(recipe); + } + + public List getRecipes() { + return recipes == null ? emptyList() : recipes; + } + + private RecipeList addRecipe(Recipe recipe) { + if (recipes == null) { + recipes = new ArrayList<>(); + } + recipes.add(recipe); + return this; + } +} diff --git a/rewrite-core/src/test/java/org/openrewrite/RecipeListTest.java b/rewrite-core/src/test/java/org/openrewrite/RecipeListTest.java new file mode 100644 index 00000000000..cf625244f16 --- /dev/null +++ b/rewrite-core/src/test/java/org/openrewrite/RecipeListTest.java @@ -0,0 +1,104 @@ +/* + * Copyright 2024 the original author or authors. + *

+ * 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 + *

+ * https://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 org.openrewrite; + +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.junit.jupiter.api.Test; +import org.openrewrite.config.RecipeDescriptor; +import org.openrewrite.marker.RecipesThatMadeChanges; +import org.openrewrite.test.RewriteTest; +import org.openrewrite.text.FindAndReplace; +import org.openrewrite.text.PlainText; +import org.openrewrite.text.PlainTextVisitor; + +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.openrewrite.test.SourceSpecs.text; + +public class RecipeListTest implements RewriteTest { + + @Test + void declarativeRecipeInCode() { + rewriteRun( + specs -> specs.recipe(new FormalHello("jon", "jonathan")) + .expectedCyclesThatMakeChanges(1).cycles(1), + text( + "hi jon", + "hello jonathan", + spec -> spec.afterRecipe(txt -> { + Optional> recipeNames = txt.getMarkers().findFirst(RecipesThatMadeChanges.class) + .map(recipes -> recipes.getRecipes().stream() + .map(stack -> stack.stream().map(Recipe::getDescriptor).map(RecipeDescriptor::getName) + .collect(Collectors.joining("->"))) + ); + + assertThat(recipeNames).isPresent(); + assertThat(recipeNames.get()).containsExactly( + "org.openrewrite.FormalHello->org.openrewrite.text.FindAndReplace", + "org.openrewrite.FormalHello->org.openrewrite.FormalHello$1" + ); + }) + ) + ); + } +} + +@Value +@EqualsAndHashCode(callSuper = false) +class FormalHello extends Recipe { + @Option(displayName = "Before name", + description = "The name of a person being greeted") + String beforeName; + + @Option(displayName = "After name", + description = "The more formal name of the person.") + String afterName; + + @Override + public String getDisplayName() { + return "Formal hello"; + } + + @Override + public String getDescription() { + return "Be formal. Be cool."; + } + + @Override + public void buildRecipeList(RecipeList list) { + list + // TODO would these large option-set recipes + // benefit from builders? + .recipe(new FindAndReplace( + "hi", "hello", null, false, null, + null, null, null) + ) + .recipe( + "Say my name, say my name", + "It's late and I'm making bad jokes.", + new PlainTextVisitor<>() { + @Override + public PlainText visitText(PlainText text, ExecutionContext ctx) { + return text.withText(text.getText().replace(beforeName, afterName)); + } + } + ); + } +}