diff --git a/.devpack-for-spring/.gitignore b/.devpack-for-spring/.gitignore new file mode 100644 index 0000000..9515f83 --- /dev/null +++ b/.devpack-for-spring/.gitignore @@ -0,0 +1 @@ +devpack-for-spring-cli \ No newline at end of file diff --git a/.devpack-for-spring/plugin-configuration.yaml b/.devpack-for-spring/plugin-configuration.yaml new file mode 100644 index 0000000..4c62fee --- /dev/null +++ b/.devpack-for-spring/plugin-configuration.yaml @@ -0,0 +1,617 @@ +format: + gradle: + id: io.spring.javaformat + version: 0.0.47 + default-task: format + description: Format the source code + tasks: + format: format + repository: gradlePluginPortal() + maven: + id: io.spring.javaformat:spring-javaformat-maven-plugin + version: 0.0.47 + default-task: format + description: Format the source code + tasks: + format: :apply +dependencies: + gradle: + id: io.github.rockcrafters.rockcraft + version: 1.2.4 + repository: gradlePluginPortal() + default-task: dependencies-export + description: | + Save project dependencies + tasks: + dependencies-export: dependencies-export + maven: + id: io.github.rockcrafters:rockcraft-maven-plugin + version: 1.2.4 + default-task: dependencies-export + description: | + Save project dependencies + tasks: + dependencies-export: :create-build-rock +rockcraft: + gradle: + id: io.github.rockcrafters.rockcraft + version: 1.2.4 + repository: gradlePluginPortal() + default-task: build-rock + description: | + Plugin for rock image generation + tasks: + create-rock: create-rock + build-rock: build-rock + create-build-rock: create-build-rock + build-build-rock: build-build-rock + push-rock: push-rock + push-build-rock: push-build-rock + maven: + id: io.github.rockcrafters:rockcraft-maven-plugin + version: 1.2.4 + default-task: build-rock + description: | + Plugin for rock image generation + tasks: + create-rock: + - install + - :create-rock + build-rock: + - install + - :create-rock + - :build-rock + create-build-rock: + - :create-build-rock + push-rock: + - install + - :create-rock + - :build-rock + - :push-rock + push-build-rock: + - install + - :create-build-rock + - :build-build-rock + - :push-build-rock +checkstyleGoogle: + resources: + - path: .config/checkstyle/checkstyle.xml + content: | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + gradle: + id: checkstyle + default-task: checkstyle + configuration: + gradleGroovy: | + checkstyle { + toolVersion = '13.3.0' + configDirectory = file("${rootProject.projectDir}/.config/checkstyle") + } + dependencies { + checkstyle 'com.puppycrawl.tools:checkstyle:13.3.0' + } + gradleKotlin: | + checkstyle { + toolVersion = "13.3.0" + configDirectory = file("${rootProject.projectDir}/.config/checkstyle") + } + dependencies { + checkstyle("com.puppycrawl.tools:checkstyle:13.3.0") + } + tasks: + checkstyle: + - checkStyleMain + - checkStyleTest + checkstyleMain: + - checkstyleMain + checkstyleTest: + - checkstyleTest + maven: + id: org.apache.maven.plugins:maven-checkstyle-plugin + version: 3.6.0 + default-task: check + tasks: + check: checkstyle:check + configuration: + maven: + configuration: | + + ${project.basedir}/.config/checkstyle/checkstyle.xml + + dependencies: | + + + com.puppycrawl.tools + checkstyle + 13.3.0 + + + executions: | + + + checkstyle + validate + + check + + + diff --git a/.github/workflows/pr-self-hosted.yml b/.github/workflows/pr-self-hosted.yml index 4341437..9b45a30 100644 --- a/.github/workflows/pr-self-hosted.yml +++ b/.github/workflows/pr-self-hosted.yml @@ -28,7 +28,6 @@ jobs: rm -rf /home/runner/.local/state/rockcraft/log/* sudo snap install rockcraft --classic - run: | - export LOGGING_LEVEL_COM_CANONICAL_DEVPACKSPRING_PROCESSUTIL=DEBUG ./gradlew build -PexcludeTags=boot,maven-modification,maven-dependency --no-daemon --stacktrace - name: Store reports if: failure() diff --git a/build.gradle b/build.gradle index 1723578..38530a4 100644 --- a/build.gradle +++ b/build.gradle @@ -221,3 +221,7 @@ if (useAot) { } } + +bootJar { + requiresUnpack '**/kotlin-compiler-embeddable-*.jar' +} diff --git a/src/main/java/com/canonical/devpackspring/ConfigUtil.java b/src/main/java/com/canonical/devpackspring/ConfigUtil.java index edf983d..034fdbc 100644 --- a/src/main/java/com/canonical/devpackspring/ConfigUtil.java +++ b/src/main/java/com/canonical/devpackspring/ConfigUtil.java @@ -33,13 +33,31 @@ public abstract class ConfigUtil { private static final Log LOG = LogFactory.getLog(ConfigUtil.class); /** - * Opens configuration file stream + * Opens configuration file stream from: 1. System Property 2. Environment Variable 3. + * Current directory .devpack-for-spring/conffile 4. User home + * .config/devpack-for-spring/conffile 5. Embedded resource + * /com/canonical/devpackspring/conffile * @param environment - environment variable specifying path to the file * @param fileName - configuration file name * @return configuration file InputStream * @throws FileNotFoundException - configuration file not found */ public static InputStream openConfigurationFile(String environment, String fileName) throws FileNotFoundException { + + String pluginConfigurationFile = System.getProperty(environment); + if (pluginConfigurationFile == null) { + pluginConfigurationFile = System.getenv(environment); + } + if (pluginConfigurationFile != null) { + if (Files.exists(Path.of(pluginConfigurationFile))) { + LOG.info("Reading configuration from " + pluginConfigurationFile); + return new FileInputStream(pluginConfigurationFile); + } + else { + LOG.warn("Configuration file " + environment + "=" + pluginConfigurationFile + " does not exist."); + } + } + Path currentConfigPath = Path.of(System.getProperty("user.dir")) .resolve(".devpack-for-spring") .resolve(fileName); @@ -57,19 +75,6 @@ public static InputStream openConfigurationFile(String environment, String fileN return new FileInputStream(configPath.toFile()); } - String pluginConfigurationFile = System.getenv(environment); - if (pluginConfigurationFile == null) { - pluginConfigurationFile = System.getProperty(environment); - } - if (pluginConfigurationFile != null) { - if (Files.exists(Path.of(pluginConfigurationFile))) { - LOG.info("Reading configuration from " + pluginConfigurationFile); - return new FileInputStream(pluginConfigurationFile); - } - else { - LOG.warn("Configuration file " + environment + "=" + pluginConfigurationFile + " does not exist."); - } - } LOG.info("Reading default configuration " + fileName); return ConfigUtil.class.getResourceAsStream(String.format("/com/canonical/devpackspring/%s", fileName)); } diff --git a/src/main/java/com/canonical/devpackspring/rewrite/AddConfigurationRecipe.java b/src/main/java/com/canonical/devpackspring/rewrite/AddConfigurationRecipe.java index a3f8226..2888242 100644 --- a/src/main/java/com/canonical/devpackspring/rewrite/AddConfigurationRecipe.java +++ b/src/main/java/com/canonical/devpackspring/rewrite/AddConfigurationRecipe.java @@ -127,6 +127,16 @@ private boolean addOrReplace(List targetStatements, Statement configS for (int i = 0; i < targetStatements.size(); i++) { Statement targetStmt = targetStatements.get(i); if (matches(targetStmt, configStmt)) { + // Special case: merge dependencies{} blocks instead of replacing + if (isDependenciesBlock(targetStmt)) { + Statement merged = DependencyMergeUtil.mergeDependenciesBlock(targetStmt, configStmt, targetCu, + configCu); + if (merged != null) { + targetStatements.set(i, merged); + return true; + } + return false; + } // avoid modifying if the trimmed outputs are strictly identical org.openrewrite.Cursor targetCursor = new org.openrewrite.Cursor( new org.openrewrite.Cursor(null, targetCu), targetStmt); @@ -142,6 +152,10 @@ private boolean addOrReplace(List targetStatements, Statement configS return true; } + private boolean isDependenciesBlock(Statement stmt) { + return stmt instanceof J.MethodInvocation m && "dependencies".equals(m.getSimpleName()); + } + private boolean matches(Statement targetStmt, Statement configStmt) { // handle project.ext.set -> we want to override only properties with the same // name diff --git a/src/main/java/com/canonical/devpackspring/rewrite/DependencyMergeUtil.java b/src/main/java/com/canonical/devpackspring/rewrite/DependencyMergeUtil.java new file mode 100644 index 0000000..285da94 --- /dev/null +++ b/src/main/java/com/canonical/devpackspring/rewrite/DependencyMergeUtil.java @@ -0,0 +1,148 @@ +/* + * Copyright 2026 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 com.canonical.devpackspring.rewrite; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; + +import org.openrewrite.Cursor; +import org.openrewrite.SourceFile; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.Statement; + +public abstract class DependencyMergeUtil { + + public static Statement mergeDependenciesBlock(Statement targetStmt, Statement configStmt, SourceFile targetCu, + SourceFile configCu) { + J.MethodInvocation targetMethod = (J.MethodInvocation) targetStmt; + J.MethodInvocation configMethod = (J.MethodInvocation) configStmt; + + List targetDeps = getDependenciesStatements(targetMethod); + List configDeps = getDependenciesStatements(configMethod); + if (targetDeps == null) { + return configStmt; + } + if (configDeps == null) { + return null; // nothing to merge, skip it + } + + HashSet deps = new HashSet<>(); + HashMap target = new HashMap<>(); + HashMap source = new HashMap<>(); + targetDeps.forEach(x -> updateDependencies(x, deps, target)); + configDeps.forEach(x -> updateDependencies(x, deps, source)); + + List mergedDeps = new ArrayList<>(); + var sortedList = new ArrayList<>(deps); + Collections.sort(sortedList); + for (var key : sortedList) { + Statement stm = source.get(key); + if (stm != null) { + if (!mergedDeps.isEmpty()) { + stm = stm.withPrefix(mergedDeps.getLast().getPrefix()); + } + mergedDeps.add(stm); + continue; + } + stm = target.get(key); + if (stm != null) { + if (!mergedDeps.isEmpty()) { + stm = stm.withPrefix(mergedDeps.getLast().getPrefix()); + } + mergedDeps.add(stm); + } + } + Statement rebuilt = rebuildDependenciesStatements(targetMethod, mergedDeps); + if (rebuilt == null) { + return null; + } + Cursor rc = new Cursor(new Cursor(null, targetCu), rebuilt); + String newResult = rebuilt.printTrimmed(rc).trim(); + Cursor cc = new Cursor(new Cursor(null, configCu), targetStmt); + String oldResult = targetStmt.printTrimmed(cc).trim(); + if (oldResult.equals(newResult)) { + return null; + } + return rebuilt; + } + + private static void updateDependencies(Statement stm, HashSet deps, HashMap depMap) { + var key = getDependencyKey(stm); + if (key == null) { + throw new RuntimeException("Unexpected element in depends block " + stm.toString()); + } + deps.add(key); + depMap.put(key, stm); + } + + private static List getDependenciesStatements(J.MethodInvocation m) { + if (m.getArguments().size() == 1) { + org.openrewrite.java.tree.Expression arg = m.getArguments().getFirst(); + if (arg instanceof J.Lambda lambda && lambda.getBody() instanceof J.Block block) { + return block.getStatements().stream().map(stmt -> { + if (stmt instanceof J.Return ret) { + return (Statement) ret.getExpression(); + } + return stmt; + }).toList(); + } + } + return null; + } + + private static Statement rebuildDependenciesStatements(J.MethodInvocation m, List newStatements) { + if (m.getArguments().size() == 1) { + org.openrewrite.java.tree.Expression arg = m.getArguments().getFirst(); + if (arg instanceof J.Lambda lambda && lambda.getBody() instanceof J.Block block) { + J.Block newBlock = block.withStatements(newStatements.stream().map(stmt -> { + String ws = stmt.getPrefix().getWhitespace(); + if (!ws.contains("\n")) { + stmt = stmt.withPrefix(stmt.getPrefix().withWhitespace("\n" + ws)); + } + return stmt; + }).toList()); + J.Lambda newLambda = lambda.withBody(newBlock); + return m.withArguments(List.of(newLambda)); + } + } + return null; + } + + private static String getDependencyKey(Statement stmt) { + if (!(stmt instanceof J.MethodInvocation m)) { + return null; + } + String scope = m.getSimpleName(); + if (m.getArguments().isEmpty()) { + return null; + } + String coord = m.getArguments().getFirst().toString(); + // Strip the version segment: "group:artifact:version" -> "group:artifact" + String[] parts = coord.split(":"); + if (parts.length >= 2) { + return scope + ":" + parts[0] + ":" + parts[1]; + } + if (parts.length == 1) { + return scope + ":" + coord; + } + throw new RuntimeException("Unexpected statement " + stmt); + } + +} diff --git a/src/main/java/com/canonical/devpackspring/rewrite/visitors/KotlinAddPluginVisitor.java b/src/main/java/com/canonical/devpackspring/rewrite/visitors/KotlinAddPluginVisitor.java index a42582a..b74ff70 100644 --- a/src/main/java/com/canonical/devpackspring/rewrite/visitors/KotlinAddPluginVisitor.java +++ b/src/main/java/com/canonical/devpackspring/rewrite/visitors/KotlinAddPluginVisitor.java @@ -20,6 +20,8 @@ import java.util.List; import com.canonical.devpackspring.rewrite.StatementUtil; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.jspecify.annotations.NonNull; import org.openrewrite.ExecutionContext; import org.openrewrite.InMemoryExecutionContext; @@ -31,9 +33,12 @@ import org.openrewrite.kotlin.KotlinIsoVisitor; import org.openrewrite.kotlin.KotlinParser; import org.openrewrite.kotlin.tree.K; +import org.openrewrite.tree.ParseError; public class KotlinAddPluginVisitor extends KotlinIsoVisitor { + private static final Log LOG = LogFactory.getLog(KotlinAddPluginVisitor.class); + private final String pluginTemplateKotlin = "plugins {\n\tid(\"%s\") version \"%s\"\n}\n"; private final String builtInTemplateKotlin = "plugins {\n\tid(\"%s\")\n}\n"; @@ -56,7 +61,10 @@ public KotlinAddPluginVisitor(String pluginName, String pluginVersion) { Paths.get("/tmp"), context) .findFirst() .orElseThrow(() -> new IllegalArgumentException("Could not parse as Gradle Kotlin")); - + if (templateSource instanceof ParseError error) { + LOG.error("Unable to parse: " + pluginDefinition); + throw new RuntimeException("Parser Error:" + error.printAll()); + } List statements = ((K.CompilationUnit) templateSource).getStatements(); J.Block block = (J.Block) statements.get(0); K.MethodInvocation stm = (K.MethodInvocation) block.getStatements().get(0); diff --git a/src/test/java/com/canonical/devpackspring/rewrite/AddConfigurationRecipeTests.java b/src/test/java/com/canonical/devpackspring/rewrite/AddConfigurationRecipeTests.java index 38620b0..ff22c6b 100644 --- a/src/test/java/com/canonical/devpackspring/rewrite/AddConfigurationRecipeTests.java +++ b/src/test/java/com/canonical/devpackspring/rewrite/AddConfigurationRecipeTests.java @@ -330,4 +330,92 @@ void testKotlinConfigurationAppendProperty() { """)); } + @Test + void testGroovyDependenciesMerge() { + String config = """ + dependencies { + implementation 'org.springframework.boot:spring-boot-starter:3.5.0' + testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0' + } + """; + G.CompilationUnit cu = parseGroovyConfig(config); + rewriteRun(spec -> spec.recipe(new AddConfigurationRecipe(cu, false)), Assertions.buildGradle(""" + dependencies { + implementation 'org.springframework.boot:spring-boot-starter:3.3.0' + runtimeOnly 'org.postgresql:postgresql:42.7.0' + } + """, """ + dependencies { + implementation 'org.springframework.boot:spring-boot-starter:3.5.0' + runtimeOnly 'org.postgresql:postgresql:42.7.0' + testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0' + } + """)); + rewriteRun(spec -> spec.recipe(new AddConfigurationRecipe(cu, false)), Assertions.buildGradle(""" + dependencies { + } + """, """ + dependencies { + implementation 'org.springframework.boot:spring-boot-starter:3.5.0' + testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0' + } + """)); + } + + @Test + void testGroovyDependenciesMergeNewLines() { + String config = """ + dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0' + } + """; + G.CompilationUnit cu = parseGroovyConfig(config); + rewriteRun(spec -> spec.recipe(new AddConfigurationRecipe(cu, false)), Assertions.buildGradle(""" + dependencies { implementation 'org.springframework.boot:spring-boot-starter:3.3.0' } + """, """ + dependencies { + implementation 'org.springframework.boot:spring-boot-starter:3.3.0' + testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0' } + """)); + } + + @Test + void testKotlinDependenciesMergeNewLines() { + String config = """ + dependencies { + testImplementation("org.junit.jupiter:junit-jupiter:5.11.0") + } + """; + K.CompilationUnit cu = parseKotlinConfig(config); + rewriteRun(spec -> spec.recipe(new AddConfigurationRecipe(cu, true)), Assertions.buildGradleKts(""" + dependencies { implementation("org.springframework.boot:spring-boot-starter:3.3.0") } + """, """ + dependencies { + implementation("org.springframework.boot:spring-boot-starter:3.3.0") + testImplementation("org.junit.jupiter:junit-jupiter:5.11.0") }""")); + } + + @Test + void testKotlinDependenciesMerge() { + String config = """ + dependencies { + implementation("org.springframework.boot:spring-boot-starter:3.5.0") + testImplementation("org.junit.jupiter:junit-jupiter:5.11.0") + } + """; + K.CompilationUnit cu = parseKotlinConfig(config); + rewriteRun(spec -> spec.recipe(new AddConfigurationRecipe(cu, true)), Assertions.buildGradleKts(""" + dependencies { + implementation("org.springframework.boot:spring-boot-starter:3.3.0") + runtimeOnly("org.postgresql:postgresql:42.7.0") + } + """, """ + dependencies { + implementation("org.springframework.boot:spring-boot-starter:3.5.0") + runtimeOnly("org.postgresql:postgresql:42.7.0") + testImplementation("org.junit.jupiter:junit-jupiter:5.11.0") + } + """)); + } + } diff --git a/src/test/java/com/canonical/devpackspring/rewrite/AddGradlePluginRecipeTests.java b/src/test/java/com/canonical/devpackspring/rewrite/AddGradlePluginRecipeTests.java index cc04e7f..37a0998 100644 --- a/src/test/java/com/canonical/devpackspring/rewrite/AddGradlePluginRecipeTests.java +++ b/src/test/java/com/canonical/devpackspring/rewrite/AddGradlePluginRecipeTests.java @@ -158,4 +158,23 @@ void testKotlinAppendPlugin() { """)); } + @Test + void testKotlinAppendDefaultPlugin() { + rewriteRun(spec -> spec.recipe(new AddGradlePluginRecipe("checkstyle", null, true)), + Assertions.buildGradleKts(""" + plugins { + kotlin("jvm") version "1.9.22" + } + group = "com.example" + version = "1.0" + """, """ + plugins { + kotlin("jvm") version "1.9.22" + id("checkstyle") + } + group = "com.example" + version = "1.0" + """)); + } + } diff --git a/test-data/projects/gradle-kotlin/build.gradle.kts b/test-data/projects/gradle-kotlin/build.gradle.kts index c25b77f..e28cbed 100644 --- a/test-data/projects/gradle-kotlin/build.gradle.kts +++ b/test-data/projects/gradle-kotlin/build.gradle.kts @@ -9,7 +9,7 @@ version = "0.0.1-SNAPSHOT" java { toolchain { - languageVersion = JavaLanguageVersion.of(17) + languageVersion = JavaLanguageVersion.of(25) } }