diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts index 44a85bf8d..413373f7f 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -7,7 +7,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.util.* plugins { - kotlin("jvm") version("1.9.0") + id("org.jetbrains.kotlin.jvm") version "1.9.0" id("com.gradle.plugin-publish") version "1.1.0" id("com.github.hierynomus.license") version "0.16.1" id("nebula.maven-apache-license") @@ -46,6 +46,8 @@ repositories { excludeVersionByRegex(".+", ".+", ".+-rc-?[0-9]*") } } + gradlePluginPortal() + google() } val latest = if (project.hasProperty("releasing")) { @@ -116,9 +118,10 @@ dependencies { "rewriteDependencies"("com.puppycrawl.tools:checkstyle:9.3") { because("Latest version supporting gradle 4.x") } - "rewriteDependencies"("com.fasterxml.jackson.module:jackson-module-kotlin:latest.release") + "rewriteDependencies"("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.2") "rewriteDependencies"("com.google.guava:guava:latest.release") implementation(platform("org.openrewrite:rewrite-bom:$latest")) + compileOnly("com.android.tools.build:gradle:7.0.4") compileOnly("org.jetbrains.kotlin:kotlin-gradle-plugin:latest.release") compileOnly("com.google.guava:guava:latest.release") diff --git a/plugin/src/main/java/org/openrewrite/gradle/DelegatingProjectParser.java b/plugin/src/main/java/org/openrewrite/gradle/DelegatingProjectParser.java index f4e84bec1..78e964cf8 100755 --- a/plugin/src/main/java/org/openrewrite/gradle/DelegatingProjectParser.java +++ b/plugin/src/main/java/org/openrewrite/gradle/DelegatingProjectParser.java @@ -17,6 +17,7 @@ import org.gradle.api.Project; import org.gradle.internal.service.ServiceRegistry; +import org.jspecify.annotations.Nullable; import java.lang.reflect.InvocationTargetException; import java.net.MalformedURLException; @@ -24,6 +25,7 @@ import java.net.URL; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Set; @@ -32,9 +34,11 @@ import java.util.stream.Collectors; public class DelegatingProjectParser implements GradleProjectParser { - protected final GradleProjectParser gpp; + @Nullable protected static List rewriteClasspath; + @Nullable protected static RewriteClassLoader rewriteClassLoader; + protected final GradleProjectParser gpp; public DelegatingProjectParser(Project project, RewriteExtension extension, Set classpath) { try { @@ -54,11 +58,16 @@ public DelegatingProjectParser(Project project, RewriteExtension extension, Set< .getResource("/org/openrewrite/gradle/isolated/DefaultProjectParser.class") .toString()); classpathUrls.add(currentJar); - if (rewriteClassLoader == null || !classpathUrls.equals(rewriteClasspath)) { + + ClassLoader pluginClassLoader = getPluginClassLoader(project); + + if (rewriteClassLoader == null || + !classpathUrls.equals(rewriteClasspath) || + rewriteClassLoader.getPluginClassLoader() != pluginClassLoader) { if (rewriteClassLoader != null) { rewriteClassLoader.close(); } - rewriteClassLoader = new RewriteClassLoader(classpathUrls); + rewriteClassLoader = new RewriteClassLoader(classpathUrls, pluginClassLoader); rewriteClasspath = classpathUrls; } @@ -163,4 +172,30 @@ private T unwrapInvocationException(Callable supplier) { throw new RuntimeException(e); } } + + private ClassLoader getPluginClassLoader(Project project) { + ClassLoader pluginClassLoader = getAndroidPluginClassLoader(project); + if (pluginClassLoader == null) { + pluginClassLoader = getClass().getClassLoader(); + } + return pluginClassLoader; + } + + @Nullable + private ClassLoader getAndroidPluginClassLoader(Project project) { + List pluginIds = Arrays.asList( + "com.android.application", + "com.android.library", + "com.android.feature", + "com.android.dynamic-feature", + "com.android.test"); + + for (String pluginId : pluginIds) { + if (project.getPlugins().hasPlugin(pluginId)) { + Object plugin = project.getPlugins().getPlugin(pluginId); + return plugin.getClass().getClassLoader(); + } + } + return null; + } } diff --git a/plugin/src/main/java/org/openrewrite/gradle/RewriteClassLoader.java b/plugin/src/main/java/org/openrewrite/gradle/RewriteClassLoader.java index c167efce7..bcffa0665 100755 --- a/plugin/src/main/java/org/openrewrite/gradle/RewriteClassLoader.java +++ b/plugin/src/main/java/org/openrewrite/gradle/RewriteClassLoader.java @@ -29,34 +29,47 @@ */ public class RewriteClassLoader extends URLClassLoader { - private static final List loadFromParent = Arrays.asList( - "org.openrewrite.gradle.GradleProjectParser", - "org.openrewrite.gradle.DefaultRewriteExtension", - "org.openrewrite.gradle.RewriteExtension", - "org.slf4j", - "org.gradle", - "groovy", - "org.codehaus.groovy" - ); + private static final List PARENT_LOADED_PACKAGES = Arrays.asList( + "org.openrewrite.gradle.GradleProjectParser", + "org.openrewrite.gradle.DefaultRewriteExtension", + "org.openrewrite.gradle.RewriteExtension", + "org.slf4j", + "org.gradle", + "groovy", + "org.codehaus.groovy"); + private static final List PLUGIN_LOADED_PACKAGES = Arrays.asList("com.android"); + private final ClassLoader pluginClassLoader; public RewriteClassLoader(Collection artifacts) { + this(artifacts, RewriteClassLoader.class.getClassLoader()); + } + + public RewriteClassLoader(Collection artifacts, ClassLoader pluginClassLoader) { super(artifacts.toArray(new URL[0]), RewriteClassLoader.class.getClassLoader()); + this.pluginClassLoader = pluginClassLoader; setDefaultAssertionStatus(true); } + public ClassLoader getPluginClassLoader() { + return pluginClassLoader; + } + /** * Load the named class. We want classes that extend org.openrewrite.Recipe to be loaded * by this ClassLoader only. But we want classes required to run recipes to continue - * to be loaded by their parent ClassLoader to avoid ClassCastExceptions. + * to be loaded by their parent ClassLoader to avoid ClassCastExceptions. In the case + * of Android Gradle plugin classes, we use the ClassLoader of the plugin. */ protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { Class foundClass = findLoadedClass(name); if (foundClass == null) { try { - if (!shouldBeParentLoaded(name)) { - foundClass = findClass(name); - } else { + if (shouldBeParentLoaded(name)) { foundClass = super.loadClass(name, resolve); + } else if (shouldBePluginLoaded(name)) { + foundClass = Class.forName(name, resolve, pluginClassLoader); + } else { + foundClass = findClass(name); } } catch (ClassNotFoundException e) { foundClass = super.loadClass(name, resolve); @@ -69,8 +82,16 @@ protected Class loadClass(String name, boolean resolve) throws ClassNotFoundE } protected boolean shouldBeParentLoaded(String name) { - for (String s : loadFromParent) { - if (name.startsWith(s)) { + return shouldBeLoaded(name, PARENT_LOADED_PACKAGES); + } + + protected boolean shouldBePluginLoaded(String name) { + return shouldBeLoaded(name, PLUGIN_LOADED_PACKAGES); + } + + private boolean shouldBeLoaded(String name, List packagesToLoad) { + for (String pkg : packagesToLoad) { + if (name.startsWith(pkg)) { return true; } } diff --git a/plugin/src/main/java/org/openrewrite/gradle/isolated/AndroidProjectCompileOptions.java b/plugin/src/main/java/org/openrewrite/gradle/isolated/AndroidProjectCompileOptions.java new file mode 100644 index 000000000..16e3343fe --- /dev/null +++ b/plugin/src/main/java/org/openrewrite/gradle/isolated/AndroidProjectCompileOptions.java @@ -0,0 +1,67 @@ +/* + * 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.gradle.isolated; + +import com.android.build.gradle.BaseExtension; +import org.gradle.api.JavaVersion; + +import java.lang.reflect.Method; +import java.nio.charset.Charset; + +/* + * AGP versions less than 4 define CompileOptions in com.android.build.gradle.internal.CompileOptions where as + * versions greater than 4 define it in com.android.build.api.dsl.CompileOptions. This class encapsulates fetching + * CompileOptions using either type. + */ +class AndroidProjectCompileOptions { + private final Charset encoding; + private final String sourceCompatibility; + private final String targetCompatibility; + + AndroidProjectCompileOptions(Charset encoding, String sourceCompatibility, String targetCompatibility) { + this.encoding = encoding; + this.sourceCompatibility = sourceCompatibility; + this.targetCompatibility = targetCompatibility; + } + + static AndroidProjectCompileOptions fromBaseExtension(BaseExtension baseExtension) throws ReflectiveOperationException { + Object compileOptions = callMethod(baseExtension, "getCompileOptions"); + String fileEncoding = callMethod(compileOptions, "getEncoding"); + JavaVersion sourceCompatibilityVersion = callMethod(compileOptions, "getSourceCompatibility"); + JavaVersion targetCompatibilityVersion = callMethod(compileOptions, "getTargetCompatibility"); + return new AndroidProjectCompileOptions( + Charset.forName(fileEncoding), + sourceCompatibilityVersion.toString(), + targetCompatibilityVersion.toString()); + } + + private static T callMethod(Object obj, String methodName) throws ReflectiveOperationException { + Method method = obj.getClass().getMethod(methodName); + return (T) method.invoke(obj); + } + + Charset getEncoding() { + return encoding; + } + + String getSourceCompatibility() { + return sourceCompatibility; + } + + String getTargetCompatibility() { + return targetCompatibility; + } +} diff --git a/plugin/src/main/java/org/openrewrite/gradle/isolated/AndroidProjectParser.java b/plugin/src/main/java/org/openrewrite/gradle/isolated/AndroidProjectParser.java new file mode 100644 index 000000000..93cba0b35 --- /dev/null +++ b/plugin/src/main/java/org/openrewrite/gradle/isolated/AndroidProjectParser.java @@ -0,0 +1,313 @@ +/* + * 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.gradle.isolated; + +import com.android.build.gradle.BaseExtension; +import com.android.build.gradle.LibraryExtension; +import com.android.build.gradle.api.BaseVariant; +import com.android.build.gradle.internal.dsl.BaseAppModuleExtension; +import org.gradle.api.DomainObjectSet; +import org.gradle.api.Project; +import org.gradle.api.logging.Logger; +import org.gradle.api.logging.Logging; +import org.openrewrite.ExecutionContext; +import org.openrewrite.InMemoryExecutionContext; +import org.openrewrite.SourceFile; +import org.openrewrite.Tree; +import org.openrewrite.gradle.RewriteExtension; +import org.openrewrite.java.JavaParser; +import org.openrewrite.java.internal.JavaTypeCache; +import org.openrewrite.java.marker.JavaSourceSet; +import org.openrewrite.java.marker.JavaVersion; +import org.openrewrite.kotlin.KotlinParser; +import org.openrewrite.polyglot.OmniParser; +import org.openrewrite.polyglot.ProgressBar; +import org.openrewrite.polyglot.SourceFileStream; +import org.openrewrite.style.NamedStyles; +import org.openrewrite.tree.ParsingExecutionContextView; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.*; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +class AndroidProjectParser { + private static final Logger logger = Logging.getLogger(DefaultProjectParser.class); + private final Path baseDir; + private final RewriteExtension rewriteExtension; + private final List styles; + + AndroidProjectParser(Path baseDir, RewriteExtension rewriteExtension, List styles) { + this.baseDir = baseDir; + this.rewriteExtension = rewriteExtension; + this.styles = styles; + } + + SourceFileStream parseProjectSourceSets(Project project, + ProgressBar progressBar, + Path buildDir, + Charset sourceCharset, + Set alreadyParsed, + Collection exclusions, + ExecutionContext ctx, + OmniParser omniParser) { + SourceFileStream sourceFileStream = SourceFileStream.build( + project.getPath(), + projectName -> progressBar.intermediateResult(":" + projectName)); + + for (AndroidProjectVariant variant : findAndroidProjectVariants(project)) { + JavaVersion javaVersion = getJavaVersion(project); + final Charset javaSourceCharset = getSourceFileEncoding(project, sourceCharset); + + for (String sourceSetName : variant.getSourceSetNames()) { + Stream sourceSetSourceFiles = Stream.of(); + int sourceSetSize = 0; + + Set javaAndKotlinDirectories = new HashSet<>(); + javaAndKotlinDirectories.addAll(variant.getJavaDirectories(sourceSetName)); + javaAndKotlinDirectories.addAll(variant.getKotlinDirectories(sourceSetName)); + + Set javaAndKotlinPaths = javaAndKotlinDirectories.stream() + .filter(Files::exists) + .filter(dir -> !alreadyParsed.contains(dir)) + .flatMap(dir -> { + try { + return Files.walk(dir); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }) + .filter(Files::isRegularFile) + .map(Path::toAbsolutePath) + .map(Path::normalize) + .filter(path -> !alreadyParsed.contains(path)) + .collect(Collectors.toSet()); + + List javaPaths = javaAndKotlinPaths.stream() + .filter(path -> path.toString().endsWith(".java")) + .collect(Collectors.toList()); + List kotlinPaths = javaAndKotlinPaths.stream() + .filter(path -> path.toString().endsWith(".kt")) + .collect(Collectors.toList()); + + JavaTypeCache javaTypeCache = new JavaTypeCache(); + + // The compilation classpath doesn't include the transitive dependencies + // The runtime classpath doesn't include compile only dependencies, e.g.: lombok, servlet-api + // So we use both together to get comprehensive type information. + Set dependencyPaths = new HashSet<>(); + try { + Stream.concat(variant.getCompileClasspath().stream(), variant.getRuntimeClasspath().stream()) + .map(Path::toAbsolutePath) + .map(Path::normalize) + .forEach(dependencyPaths::add); + } catch (Exception e) { + logger.warn("Unable to resolve classpath for variant {} sourceSet {}:{}", + variant.getName(), + project.getPath(), + sourceSetName, + e); + } + + if (!javaPaths.isEmpty()) { + alreadyParsed.addAll(javaPaths); + Stream parsedJavaFiles = parseJavaFiles(javaPaths, + ctx, + buildDir, + exclusions, + javaSourceCharset, + javaVersion, + dependencyPaths, + javaTypeCache); + sourceSetSourceFiles = Stream.concat(sourceSetSourceFiles, parsedJavaFiles); + sourceSetSize += javaPaths.size(); + + logger.info("Scanned {} Java sources in {}/{}", javaPaths.size(), project.getPath(), sourceSetName); + } + + if (!kotlinPaths.isEmpty()) { + alreadyParsed.addAll(kotlinPaths); + Stream parsedKotlinFiles = parseKotlinFiles(kotlinPaths, + ctx, + buildDir, + exclusions, + javaSourceCharset, + javaVersion, + dependencyPaths, + javaTypeCache); + sourceSetSourceFiles = Stream.concat(sourceSetSourceFiles, parsedKotlinFiles); + sourceSetSize += kotlinPaths.size(); + + logger.info("Scanned {} Kotlin sources in {}/{}", + kotlinPaths.size(), + project.getPath(), + sourceSetName); + } + + for (Path resourcesDir : variant.getResourcesDirectories(sourceSetName)) { + if (Files.exists(resourcesDir) && !alreadyParsed.contains(resourcesDir)) { + Set accepted = + omniParser.acceptedPaths(baseDir, resourcesDir) + .stream() + .filter(path -> !alreadyParsed.contains(path)) + .collect(Collectors.toSet()); + sourceSetSourceFiles = Stream.concat( + sourceSetSourceFiles, + omniParser.parse(accepted, baseDir, new InMemoryExecutionContext()) + .map(it -> it.withMarkers(it.getMarkers().add(javaVersion)))); + alreadyParsed.addAll(accepted); + sourceSetSize += accepted.size(); + } + } + + JavaSourceSet sourceSetProvenance = JavaSourceSet.build(sourceSetName, dependencyPaths); + sourceFileStream = sourceFileStream.concat(sourceSetSourceFiles.map(DefaultProjectParser.addProvenance( + sourceSetProvenance)), + sourceSetSize); + } + } + return sourceFileStream; + } + + Collection findSourceDirectories(Project project) { + Set sourceDirectories = new HashSet<>(); + for (AndroidProjectVariant variant : findAndroidProjectVariants(project)) { + for (String sourceSetName : variant.getSourceSetNames()) { + sourceDirectories.addAll(variant.getJavaDirectories(sourceSetName)); + sourceDirectories.addAll(variant.getKotlinDirectories(sourceSetName)); + sourceDirectories.addAll(variant.getResourcesDirectories(sourceSetName)); + } + } + return sourceDirectories; + } + + private List findAndroidProjectVariants(Project project) { + List variants = new ArrayList<>(); + Object extension = project.getExtensions().findByName("android"); + if (extension instanceof BaseAppModuleExtension) { + BaseAppModuleExtension appExtension = (BaseAppModuleExtension) extension; + addProjectVariant(variants, appExtension.getApplicationVariants()); + addProjectVariant(variants, appExtension.getTestVariants()); + addProjectVariant(variants, appExtension.getUnitTestVariants()); + } else if (extension instanceof LibraryExtension) { + LibraryExtension libraryExtension = (LibraryExtension) extension; + addProjectVariant(variants, libraryExtension.getLibraryVariants()); + addProjectVariant(variants, libraryExtension.getTestVariants()); + addProjectVariant(variants, libraryExtension.getUnitTestVariants()); + } else if (extension != null) { + throw new UnsupportedOperationException("Unhandled android extension type: " + extension.getClass()); + } + + return variants; + } + + private void addProjectVariant(List projectVariants, + DomainObjectSet variantSet) { + variantSet.stream().map(AndroidProjectVariant::fromBaseVariant).forEach(projectVariants::add); + } + + private JavaVersion getJavaVersion(Project project) { + String sourceCompatibility = ""; + String targetCompatibility = ""; + + Object extension = project.getExtensions().findByName("android"); + if (extension instanceof BaseExtension) { + try { + BaseExtension baseExtension = (BaseExtension) extension; + AndroidProjectCompileOptions compileOptions = AndroidProjectCompileOptions.fromBaseExtension( + baseExtension); + sourceCompatibility = compileOptions.getSourceCompatibility(); + targetCompatibility = compileOptions.getTargetCompatibility(); + } catch (Exception e) { + logger.warn("Unable to determine Java source or target compatibility versions", e); + } + } + return new JavaVersion(Tree.randomId(), + System.getProperty("java.runtime.version"), + System.getProperty("java.vm.vendor"), + sourceCompatibility, + targetCompatibility); + } + + Charset getSourceFileEncoding(Project project, Charset defaultCharset) { + Object extension = project.getExtensions().findByName("android"); + if (extension instanceof BaseExtension) { + try { + BaseExtension baseExtension = (BaseExtension) extension; + AndroidProjectCompileOptions compileOptions = AndroidProjectCompileOptions.fromBaseExtension( + baseExtension); + return compileOptions.getEncoding(); + } catch (Exception e) { + logger.warn("Unable to determine Java source file encoding", e); + } + } + return defaultCharset; + } + + private Stream parseJavaFiles(List javaPaths, + ExecutionContext ctx, + Path buildDir, + Collection exclusions, + Charset javaSourceCharset, + JavaVersion javaVersion, + Set dependencyPaths, + JavaTypeCache javaTypeCache) { + ParsingExecutionContextView.view(ctx).setCharset(javaSourceCharset); + + return Stream.of((Supplier) () -> JavaParser.fromJavaVersion() + .classpath(dependencyPaths) + .styles(styles) + .typeCache(javaTypeCache) + .logCompilationWarningsAndErrors(rewriteExtension.getLogCompilationWarningsAndErrors()) + .build()).map(Supplier::get).flatMap(jp -> jp.parse(javaPaths, baseDir, ctx)).map(cu -> { + if (DefaultProjectParser.isExcluded(exclusions, cu.getSourcePath()) || cu.getSourcePath() + .startsWith(buildDir)) { + return null; + } + return cu; + }).filter(Objects::nonNull).map(it -> it.withMarkers(it.getMarkers().add(javaVersion))); + } + + private Stream parseKotlinFiles(List kotlinPaths, + ExecutionContext ctx, + Path buildDir, + Collection exclusions, + Charset javaSourceCharset, + JavaVersion javaVersion, + Set dependencyPaths, + JavaTypeCache javaTypeCache) { + ParsingExecutionContextView.view(ctx).setCharset(javaSourceCharset); + + return Stream.of((Supplier) () -> KotlinParser.builder() + .classpath(dependencyPaths) + .styles(styles) + .typeCache(javaTypeCache) + .logCompilationWarningsAndErrors(rewriteExtension.getLogCompilationWarningsAndErrors()) + .build()).map(Supplier::get).flatMap(kp -> kp.parse(kotlinPaths, baseDir, ctx)).map(cu -> { + if (DefaultProjectParser.isExcluded(exclusions, cu.getSourcePath()) || cu.getSourcePath() + .startsWith(buildDir)) { + return null; + } + return cu; + }).filter(Objects::nonNull).map(it -> it.withMarkers(it.getMarkers().add(javaVersion))); + } +} diff --git a/plugin/src/main/java/org/openrewrite/gradle/isolated/AndroidProjectVariant.java b/plugin/src/main/java/org/openrewrite/gradle/isolated/AndroidProjectVariant.java new file mode 100644 index 000000000..e5f2920f7 --- /dev/null +++ b/plugin/src/main/java/org/openrewrite/gradle/isolated/AndroidProjectVariant.java @@ -0,0 +1,147 @@ +/* + * 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.gradle.isolated; + +import com.android.build.gradle.api.BaseVariant; +import com.android.builder.model.SourceProvider; +import org.gradle.api.logging.Logger; +import org.gradle.api.logging.Logging; + +import java.io.File; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.stream.Collectors; + +class AndroidProjectVariant { + private static final Logger logger = Logging.getLogger(AndroidProjectVariant.class); + private final String name; + private final Map> javaSourceSets; + private final Map> kotlinSourceSets; + private final Map> resourcesSourceSets; + private final Set sourceSetNames = new HashSet<>(); + private final Set compileClasspath; + private final Set runtimeClasspath; + + AndroidProjectVariant(String name, + Map> javaSourceSets, + Map> kotlinSourceSets, + Map> resourcesSourceSets, + Set compileClasspath, + Set runtimeClasspath) { + this.name = name; + this.javaSourceSets = javaSourceSets; + this.kotlinSourceSets = kotlinSourceSets; + this.resourcesSourceSets = resourcesSourceSets; + this.compileClasspath = compileClasspath; + this.runtimeClasspath = runtimeClasspath; + + sourceSetNames.addAll(javaSourceSets.keySet()); + sourceSetNames.addAll(kotlinSourceSets.keySet()); + sourceSetNames.addAll(this.resourcesSourceSets.keySet()); + } + + String getName() { + return name; + } + + Set getSourceSetNames() { + return sourceSetNames; + } + + Set getJavaDirectories(String sourceSetName) { + return javaSourceSets.computeIfAbsent(sourceSetName, key -> Collections.emptySet()); + } + + Set getKotlinDirectories(String sourceSetName) { + return kotlinSourceSets.computeIfAbsent(sourceSetName, key -> Collections.emptySet()); + } + + Set getResourcesDirectories(String sourceSetName) { + return resourcesSourceSets.computeIfAbsent(sourceSetName, key -> Collections.emptySet()); + } + + Set getCompileClasspath() { + return compileClasspath; + } + + Set getRuntimeClasspath() { + return runtimeClasspath; + } + + static AndroidProjectVariant fromBaseVariant(BaseVariant baseVariant) { + Map> javaSourceSets = new HashMap<>(); + Map> kotlinSourceSets = new HashMap<>(); + Map> resourceSourceSets = new HashMap<>(); + + for (SourceProvider sourceProvider : baseVariant.getSourceSets()) { + addSourceSets(javaSourceSets, sourceProvider.getName(), sourceProvider.getJavaDirectories()); + if (hasMethod(baseVariant, "getKotlinDirectories")) { + // Android gradle plugin versions prior to 7 do not have BaseVariant#getKotlinDirectories + addSourceSets(kotlinSourceSets, sourceProvider.getName(), sourceProvider.getKotlinDirectories()); + } + addSourceSets(resourceSourceSets, sourceProvider.getName(), sourceProvider.getResDirectories()); + addSourceSets(resourceSourceSets, sourceProvider.getName(), sourceProvider.getResourcesDirectories()); + } + + Set compileClasspath = new LinkedHashSet<>(); + try { + baseVariant.getCompileClasspath(null) + .getFiles() + .stream() + .map(File::toPath) + .forEach(compileClasspath::add); + } catch (RuntimeException e) { + // Calling BaseVariant#getCompileClasspath will throw an exception when run with + // an AGP version less than 8.0 and a gradle version less than 8, when trying to + // create a task using org.gradle.api.tasks.incremental.IncrementalTaskInputs which + // was removed in gradle 8. + logger.warn("Unable to determine compile class path", e); + } + + Set runtimeClasspath = new LinkedHashSet<>(); + + try { + baseVariant.getRuntimeConfiguration().getFiles() + .stream() + .map(File::toPath) + .forEach(runtimeClasspath::add); + } catch (Exception e) { + logger.warn("Unable to determine runtime class path", e); + } + + return new AndroidProjectVariant( + baseVariant.getName(), + javaSourceSets, + kotlinSourceSets, + resourceSourceSets, + compileClasspath, + runtimeClasspath); + } + + private static void addSourceSets(Map> sourceSets, String name, Collection directories) { + sourceSets.put(name, directories.stream().map(File::toPath).collect(Collectors.toSet())); + } + + private static boolean hasMethod(BaseVariant baseVariant, String methodName, Class... paramTypes) { + try { + baseVariant.getClass().getMethod(methodName, paramTypes); + return true; + } catch (NoSuchMethodException e) { + return false; + } + } +} diff --git a/plugin/src/main/java/org/openrewrite/gradle/isolated/DefaultProjectParser.java b/plugin/src/main/java/org/openrewrite/gradle/isolated/DefaultProjectParser.java index 12ef44f34..95785d377 100644 --- a/plugin/src/main/java/org/openrewrite/gradle/isolated/DefaultProjectParser.java +++ b/plugin/src/main/java/org/openrewrite/gradle/isolated/DefaultProjectParser.java @@ -30,7 +30,6 @@ import org.gradle.api.plugins.JavaPluginConvention; import org.gradle.api.plugins.JavaPluginExtension; import org.gradle.api.tasks.SourceSet; -import org.gradle.api.tasks.SourceSetContainer; import org.gradle.api.tasks.compile.CompileOptions; import org.gradle.api.tasks.compile.JavaCompile; import org.gradle.internal.service.ServiceRegistry; @@ -104,15 +103,19 @@ public class DefaultProjectParser implements GradleProjectParser { private static final String LOG_INDENT_INCREMENT = " "; - private final Logger logger = Logging.getLogger(DefaultProjectParser.class); + private static final Logger logger = Logging.getLogger(DefaultProjectParser.class); private final AtomicBoolean firstWarningLogged = new AtomicBoolean(false); protected final Path baseDir; protected final RewriteExtension extension; protected final Project project; private final List sharedProvenance; + @Nullable private List styles; + @Nullable private Environment environment; + @Nullable + private AndroidProjectParser androidProjectParser; public DefaultProjectParser(Project project, RewriteExtension extension) { this.baseDir = repositoryRoot(project); @@ -176,6 +179,17 @@ static Path repositoryRoot(Project project) { return maybeProp; } + private static boolean isAndroidProject(Project project) { + return project.hasProperty("android"); + } + + private AndroidProjectParser getAndroidProjectParser() { + if (androidProjectParser == null) { + androidProjectParser = new AndroidProjectParser(baseDir, extension, styles); + } + return androidProjectParser; + } + @Override public List getActiveRecipes() { String activeRecipe = getPropertyWithVariantNames("activeRecipe"); @@ -255,11 +269,20 @@ private static StringBuilder repeat(int repeat) { @Override public Collection listSources() { // Use a sorted collection so that gradle input detection isn't thrown off by ordering - Set result = new TreeSet<>(omniParser(emptySet(), project).acceptedPaths(baseDir, project.getProjectDir().toPath())); - SourceSetContainer sourceSets = findSourceSetContainer(project); - if (sourceSets != null) { - for (SourceSet sourceSet : sourceSets) { - sourceSet.getAllSource().getFiles().stream() + Set result = new TreeSet<>(omniParser(emptySet(), project).acceptedPaths( + baseDir, + project.getProjectDir().toPath())); + if (isAndroidProject(project)) { + getAndroidProjectParser().findSourceDirectories(project) + .stream() + .map(Path::toAbsolutePath) + .map(Path::normalize) + .forEach(result::add); + } else { + for (SourceSet sourceSet : findGradleSourceSets(project)) { + sourceSet.getAllSource() + .getFiles() + .stream() .map(File::toPath) .map(Path::toAbsolutePath) .map(Path::normalize) @@ -353,8 +376,7 @@ public void dryRun(Path reportPath, ResultsContainer results) { try (BufferedWriter writer = Files.newBufferedWriter(reportPath)) { Stream.concat( Stream.concat(results.generated.stream(), results.deleted.stream()), - Stream.concat(results.moved.stream(), results.refactoredInPlace.stream()) - ) + Stream.concat(results.moved.stream(), results.refactoredInPlace.stream())) // cannot meaningfully display diffs of these things. Console output notes that they were touched by a recipe. .filter(it -> !(it.getAfter() instanceof Binary) && !(it.getAfter() instanceof Quark)) .map(Result::diff) @@ -575,9 +597,7 @@ protected Environment environment() { if (environment == null) { Map gradleProps = project.getProperties().entrySet().stream() .filter(entry -> entry.getKey() != null && entry.getValue() != null) - .collect(toMap( - Map.Entry::getKey, - Map.Entry::getValue)); + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue)); Properties properties = new Properties(); properties.putAll(gradleProps); @@ -622,8 +642,7 @@ public Stream parse(Project subproject, Set alreadyParsed, Exe new RemoteProgressBarSender(Integer.parseInt(cliPort))) { SourceFileStream sourceFileStream = SourceFileStream.build( subproject.getPath(), - projectName -> progressBar.intermediateResult(":" + projectName) - ); + projectName -> progressBar.intermediateResult(":" + projectName)); Collection exclusions = extension.getExclusions().stream() .map(pattern -> subproject.getProjectDir().toPath().getFileSystem().getPathMatcher("glob:" + pattern)) @@ -636,230 +655,318 @@ public Stream parse(Project subproject, Set alreadyParsed, Exe logger.lifecycle("Scanning sources in project {}", subproject.getPath()); List styles = getStyles(); logger.lifecycle("Using active styles {}", styles.stream().map(NamedStyles::getName).collect(toList())); - SourceSetContainer sourceSetContainer = findSourceSetContainer(subproject); - List sourceSets; + + if (subproject.getPlugins() + .hasPlugin("org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension") || subproject.getExtensions() + .findByName("kotlin") != null && subproject.getExtensions() + .getByName("kotlin") + .getClass() + .getCanonicalName() + .startsWith("org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension")) { + sourceFileStream = sourceFileStream.concat(parseMultiplatformKotlinProject( + subproject, + exclusions, + alreadyParsed, + ctx)); + } + + Charset sourceCharset = Charset.forName(System.getProperty("file.encoding", "UTF-8")); + + Path buildDirPath = baseDir.relativize(subproject.getLayout() + .getBuildDirectory() + .get() + .getAsFile() + .toPath()); + + SourceFileStream projectSourceFileStream; + if (isAndroidProject(subproject)) { + projectSourceFileStream = parseAndroidProjectSourceSets( + subproject, + progressBar, + buildDirPath, + sourceCharset, + alreadyParsed, + exclusions, + ctx); + } else { + projectSourceFileStream = parseGradleProjectSourceSets( + subproject, + progressBar, + buildDirPath, + sourceCharset, + alreadyParsed, + exclusions, + ctx); + } + sourceFileStream = sourceFileStream.concat(projectSourceFileStream, projectSourceFileStream.size()); List projectProvenance; - if (sourceSetContainer == null) { + if (projectSourceFileStream.size() == 0) { projectProvenance = sharedProvenance; - sourceSets = emptyList(); } else { projectProvenance = new ArrayList<>(sharedProvenance); - projectProvenance.add(new JavaProject(randomId(), subproject.getName(), - new JavaProject.Publication(subproject.getGroup().toString(), + projectProvenance.add(new JavaProject( + randomId(), + subproject.getName(), + new JavaProject.Publication( + subproject.getGroup().toString(), subproject.getName(), subproject.getVersion().toString()))); - sourceSets = sourceSetContainer.stream() - .sorted(Comparator.comparingInt(sourceSet -> { - if ("main".equals(sourceSet.getName())) { - return 0; - } else if ("test".equals(sourceSet.getName())) { - return 1; - } else { - return 2; - } - })).collect(toList()); } - if (subproject.getPlugins().hasPlugin("org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension") || - subproject.getExtensions().findByName("kotlin") != null && subproject.getExtensions().getByName("kotlin").getClass() - .getCanonicalName().startsWith("org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension")) { - sourceFileStream = sourceFileStream.concat(parseMultiplatformKotlinProject(subproject, exclusions, alreadyParsed, ctx)); - } + SourceFileStream gradleFiles = parseGradleFiles(subproject, exclusions, alreadyParsed, ctx); + sourceFileStream = sourceFileStream.concat(gradleFiles, gradleFiles.size()); - Charset sourceCharset = Charset.forName(System.getProperty("file.encoding", "UTF-8")); + SourceFileStream gradleWrapperFiles = parseGradleWrapperFiles(exclusions, alreadyParsed, ctx); + sourceFileStream = sourceFileStream.concat(gradleWrapperFiles, gradleWrapperFiles.size()); - Path buildDirPath = baseDir.relativize(subproject.getLayout().getBuildDirectory().get().getAsFile().toPath()); - for (SourceSet sourceSet : sourceSets) { - Stream sourceSetSourceFiles = Stream.of(); - int sourceSetSize = 0; - - JavaTypeCache javaTypeCache = new JavaTypeCache(); - JavaCompile javaCompileTask = (JavaCompile) subproject.getTasks().getByName(sourceSet.getCompileJavaTaskName()); - JavaVersion javaVersion = new JavaVersion(randomId(), System.getProperty("java.runtime.version"), - System.getProperty("java.vm.vendor"), - javaCompileTask.getSourceCompatibility(), - javaCompileTask.getTargetCompatibility()); - - CompileOptions compileOptions = javaCompileTask.getOptions(); - final Charset javaSourceCharset = (compileOptions != null && compileOptions.getEncoding() != null) - ? Charset.forName(compileOptions.getEncoding()) : sourceCharset; - - List unparsedSources = sourceSet.getAllSource() - .getSourceDirectories() - .filter(it -> it.exists() && !alreadyParsed.contains(it.toPath())) - .getFiles() - .stream() - .flatMap(sourceDir -> { - try { - return Files.walk(sourceDir.toPath()); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - }) - .filter(Files::isRegularFile) + SourceFileStream nonProjectResources = parseNonProjectResources(subproject, alreadyParsed, ctx); + sourceFileStream = sourceFileStream.concat(nonProjectResources, nonProjectResources.size()); + + progressBar.setMax(sourceFileStream.size()); + return sourceFileStream.map(addProvenance(projectProvenance)).peek(it -> progressBar.step()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private SourceFileStream parseGradleProjectSourceSets(Project subproject, + ProgressBar progressBar, + Path buildDir, + Charset sourceCharset, + Set alreadyParsed, + Collection exclusions, + ExecutionContext ctx) { + SourceFileStream sourceFileStream = SourceFileStream.build( + subproject.getPath(), + projectName -> progressBar.intermediateResult(":" + projectName)); + + for (SourceSet sourceSet : findGradleSourceSets(subproject)) { + Stream sourceSetSourceFiles = Stream.of(); + int sourceSetSize = 0; + + JavaTypeCache javaTypeCache = new JavaTypeCache(); + JavaCompile javaCompileTask = (JavaCompile) subproject.getTasks() + .getByName(sourceSet.getCompileJavaTaskName()); + JavaVersion javaVersion = getJavaVersion(javaCompileTask); + + final Charset javaSourceCharset = getSourceFileEncoding(javaCompileTask, sourceCharset); + + List unparsedSources = sourceSet.getAllSource() + .getSourceDirectories() + .filter(dir -> dir.exists()) + .filter(dir -> !alreadyParsed.contains(dir.toPath())) + .getFiles() + .stream() + .map(File::toPath) + .flatMap(dirPath -> { + try { + return Files.walk(dirPath); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }) + .filter(Files::isRegularFile) + .map(Path::toAbsolutePath) + .map(Path::normalize) + .distinct() + .collect(Collectors.toList()); + + List javaPaths = unparsedSources.stream() + .filter(path -> !alreadyParsed.contains(path)) + .filter(path -> path.toString().endsWith(".java")) + .collect(toList()); + + // The compilation classpath doesn't include the transitive dependencies + // The runtime classpath doesn't include compile only dependencies, e.g.: lombok, servlet-api + // So we use both together to get comprehensive type information + Set dependencyPaths = new HashSet<>(); + try { + Stream.concat( + sourceSet.getRuntimeClasspath().getFiles().stream(), + sourceSet.getCompileClasspath().getFiles().stream()) + .map(File::toPath) .map(Path::toAbsolutePath) .map(Path::normalize) - .distinct() - .collect(Collectors.toList()); - List javaPaths = unparsedSources.stream() - .filter(it -> it.toString().endsWith(".java") && !alreadyParsed.contains(it)) + .forEach(dependencyPaths::add); + } catch (Exception e) { + logger.warn( + "Unable to resolve classpath for sourceSet {}:{}", + subproject.getPath(), + sourceSet.getName(), + e); + } + + if (!javaPaths.isEmpty()) { + alreadyParsed.addAll(javaPaths); + Stream parsedJavaFiles = parseJavaFiles( + javaPaths, + ctx, + buildDir, + exclusions, + javaSourceCharset, + javaVersion, + dependencyPaths, + javaTypeCache); + sourceSetSourceFiles = Stream.concat(sourceSetSourceFiles, parsedJavaFiles); + sourceSetSize += javaPaths.size(); + logger.info( + "Scanned {} Java sources in {}/{}", + javaPaths.size(), + subproject.getPath(), + sourceSet.getName()); + } + + if (subproject.getPlugins().hasPlugin("org.jetbrains.kotlin.jvm")) { + String excludedProtosPath = subproject.getProjectDir().getPath() + "/protos/build/generated"; + List kotlinPaths = unparsedSources.stream() + .filter(it -> it.toString().endsWith(".kt")) + .filter(it -> !it.toString().startsWith(excludedProtosPath)) .collect(toList()); - // The compilation classpath doesn't include the transitive dependencies - // The runtime classpath doesn't include compile only dependencies, e.g.: lombok, servlet-api - // So we use both together to get comprehensive type information - List dependencyPathsNonFinal; - try { - dependencyPathsNonFinal = Stream.concat( - sourceSet.getRuntimeClasspath().getFiles().stream(), - sourceSet.getCompileClasspath().getFiles().stream()) - .map(File::toPath) - .map(Path::toAbsolutePath) - .map(Path::normalize) - .distinct() - .collect(toList()); - } catch (Exception e) { - logger.warn("Unable to resolve classpath for sourceSet {}:{}", subproject.getPath(), sourceSet.getName(), e); - dependencyPathsNonFinal = emptyList(); - } - List dependencyPaths = dependencyPathsNonFinal; - - if (!javaPaths.isEmpty()) { - view(ctx).setCharset(javaSourceCharset); - - alreadyParsed.addAll(javaPaths); - Stream cus = Stream - .of((Supplier) () -> JavaParser.fromJavaVersion() - .classpath(dependencyPaths) - .styles(styles) - .typeCache(javaTypeCache) - .logCompilationWarningsAndErrors(extension.getLogCompilationWarningsAndErrors()) - .build()) - .map(Supplier::get) - .flatMap(jp -> jp.parse(javaPaths, baseDir, ctx)) - .map(cu -> { - if (isExcluded(exclusions, cu.getSourcePath()) || - cu.getSourcePath().startsWith(buildDirPath)) { - return null; - } - return cu; - }) - .filter(Objects::nonNull) - .map(it -> it.withMarkers(it.getMarkers().add(javaVersion))); - sourceSetSourceFiles = Stream.concat(sourceSetSourceFiles, cus); - sourceSetSize += javaPaths.size(); - logger.info("Scanned {} Java sources in {}/{}", javaPaths.size(), subproject.getPath(), sourceSet.getName()); + if (!kotlinPaths.isEmpty()) { + alreadyParsed.addAll(kotlinPaths); + Stream parsedKotlinFiles = parseKotlinFiles( + kotlinPaths, + ctx, + buildDir, + exclusions, + javaSourceCharset, + javaVersion, + dependencyPaths, + javaTypeCache); + sourceSetSourceFiles = Stream.concat(sourceSetSourceFiles, parsedKotlinFiles); + sourceSetSize += kotlinPaths.size(); + logger.info( + "Scanned {} Kotlin sources in {}/{}", + kotlinPaths.size(), + subproject.getPath(), + sourceSet.getName()); } + } + if (subproject.getPlugins().hasPlugin(GroovyPlugin.class)) { + List groovyPaths = unparsedSources.stream() + .filter(it -> it.toString().endsWith(".groovy")) + .collect(toList()); - if (subproject.getPlugins().hasPlugin("org.jetbrains.kotlin.jvm")) { - String excludedProtosPath = subproject.getProjectDir().getPath() + "/protos/build/generated"; - List kotlinPaths = unparsedSources.stream() - .filter(it -> it.toString().endsWith(".kt")) - .filter(it -> !it.toString().startsWith(excludedProtosPath)) + if (!groovyPaths.isEmpty()) { + // Groovy sources are aware of java types that are intermixed in the same directory/sourceSet + // Include the build directory containing class files so these definitions are available + List dependenciesWithBuildDirs = Stream.concat( + dependencyPaths.stream(), + sourceSet.getOutput().getClassesDirs().getFiles().stream().map(File::toPath)) .collect(toList()); - if (!kotlinPaths.isEmpty()) { - alreadyParsed.addAll(kotlinPaths); - Stream cus = Stream - .of((Supplier) () -> KotlinParser.builder() - .classpath(dependencyPaths) - .styles(styles) - .typeCache(javaTypeCache) - .logCompilationWarningsAndErrors(extension.getLogCompilationWarningsAndErrors()) - .build()) - .map(Supplier::get) - .flatMap(kp -> kp.parse(kotlinPaths, baseDir, ctx)) - .map(cu -> { - if (isExcluded(exclusions, cu.getSourcePath()) || - cu.getSourcePath().startsWith(buildDirPath)) { - return null; - } - return cu; - }) - .filter(Objects::nonNull) - .map(it -> it.withMarkers(it.getMarkers().add(javaVersion))); - sourceSetSourceFiles = Stream.concat(sourceSetSourceFiles, cus); - sourceSetSize += kotlinPaths.size(); - logger.info("Scanned {} Kotlin sources in {}/{}", kotlinPaths.size(), subproject.getPath(), sourceSet.getName()); - } - } - if (subproject.getPlugins().hasPlugin(GroovyPlugin.class)) { - List groovyPaths = unparsedSources.stream() - .filter(it -> it.toString().endsWith(".groovy")) - .collect(toList()); + alreadyParsed.addAll(groovyPaths); - if (!groovyPaths.isEmpty()) { - // Groovy sources are aware of java types that are intermixed in the same directory/sourceSet - // Include the build directory containing class files so these definitions are available - List dependenciesWithBuildDirs = Stream.concat( - dependencyPaths.stream(), - sourceSet.getOutput().getClassesDirs().getFiles().stream().map(File::toPath) - ).collect(toList()); - - alreadyParsed.addAll(groovyPaths); - - Stream cus = Stream - .of((Supplier) () -> GroovyParser.builder() - .classpath(dependenciesWithBuildDirs) - .styles(styles) - .typeCache(javaTypeCache) - .logCompilationWarningsAndErrors(false) - .build()) - .map(Supplier::get) - .flatMap(gp -> gp.parse(groovyPaths, baseDir, ctx)) - .map(cu -> { - if (isExcluded(exclusions, cu.getSourcePath()) || - cu.getSourcePath().startsWith(buildDirPath)) { - return null; - } - return cu; - }) - .filter(Objects::nonNull) - .map(it -> it.withMarkers(it.getMarkers().add(javaVersion))); - sourceSetSourceFiles = Stream.concat(sourceSetSourceFiles, cus); - sourceSetSize += groovyPaths.size(); - logger.info("Scanned {} Groovy sources in {}/{}", groovyPaths.size(), subproject.getPath(), sourceSet.getName()); - } + Stream cus = Stream.of((Supplier) () -> GroovyParser.builder() + .classpath(dependenciesWithBuildDirs) + .styles(styles) + .typeCache(javaTypeCache) + .logCompilationWarningsAndErrors(false) + .build()).map(Supplier::get).flatMap(gp -> gp.parse(groovyPaths, baseDir, ctx)).map(cu -> { + if (isExcluded(exclusions, cu.getSourcePath()) || cu.getSourcePath().startsWith(buildDir)) { + return null; + } + return cu; + }).filter(Objects::nonNull).map(it -> it.withMarkers(it.getMarkers().add(javaVersion))); + sourceSetSourceFiles = Stream.concat(sourceSetSourceFiles, cus); + sourceSetSize += groovyPaths.size(); + logger.info( + "Scanned {} Groovy sources in {}/{}", + groovyPaths.size(), + subproject.getPath(), + sourceSet.getName()); } + } - for (File resourcesDir : sourceSet.getResources().getSourceDirectories()) { - if (resourcesDir.exists() && !alreadyParsed.contains(resourcesDir.toPath())) { - OmniParser omniParser = omniParser(alreadyParsed, subproject); - List accepted = omniParser.acceptedPaths(baseDir, resourcesDir.toPath()); - sourceSetSourceFiles = Stream.concat( - sourceSetSourceFiles, - omniParser.parse(accepted, baseDir, new InMemoryExecutionContext()) - .map(it -> it.withMarkers(it.getMarkers().add(javaVersion))) - ); - alreadyParsed.addAll(accepted); - sourceSetSize += accepted.size(); - } + for (File resourcesDir : sourceSet.getResources().getSourceDirectories()) { + if (resourcesDir.exists() && !alreadyParsed.contains(resourcesDir.toPath())) { + OmniParser omniParser = omniParser(alreadyParsed, subproject); + List accepted = omniParser.acceptedPaths(baseDir, resourcesDir.toPath()); + sourceSetSourceFiles = Stream.concat( + sourceSetSourceFiles, + omniParser.parse(accepted, baseDir, new InMemoryExecutionContext()) + .map(it -> it.withMarkers(it.getMarkers().add(javaVersion)))); + alreadyParsed.addAll(accepted); + sourceSetSize += accepted.size(); } + } - JavaSourceSet sourceSetProvenance = JavaSourceSet.build(sourceSet.getName(), dependencyPaths); - sourceFileStream = sourceFileStream.concat(sourceSetSourceFiles.map(addProvenance(sourceSetProvenance)), sourceSetSize); - // Some source sets get misconfigured to have the same directories as other source sets - // Prevent files which appear in multiple source sets from being parsed more than once - for (File file : sourceSet.getAllSource().getSourceDirectories().getFiles()) { - alreadyParsed.add(file.toPath()); - } + JavaSourceSet sourceSetProvenance = JavaSourceSet.build(sourceSet.getName(), dependencyPaths); + sourceFileStream = sourceFileStream.concat( + sourceSetSourceFiles.map(addProvenance(sourceSetProvenance)), + sourceSetSize); + // Some source sets get misconfigured to have the same directories as other source sets + // Prevent files which appear in multiple source sets from being parsed more than once + for (File file : sourceSet.getAllSource().getSourceDirectories().getFiles()) { + alreadyParsed.add(file.toPath()); } - SourceFileStream gradleFiles = parseGradleFiles(subproject, exclusions, alreadyParsed, ctx); - sourceFileStream = sourceFileStream.concat(gradleFiles, gradleFiles.size()); + } + return sourceFileStream; + } - SourceFileStream gradleWrapperFiles = parseGradleWrapperFiles(exclusions, alreadyParsed, ctx); - sourceFileStream = sourceFileStream.concat(gradleWrapperFiles, gradleWrapperFiles.size()); + private SourceFileStream parseAndroidProjectSourceSets(Project subproject, + ProgressBar progressBar, + Path buildDir, + Charset sourceCharset, + Set alreadyParsed, + Collection exclusions, + ExecutionContext ctx) { + return getAndroidProjectParser().parseProjectSourceSets( + subproject, + progressBar, + buildDir, + sourceCharset, + alreadyParsed, + exclusions, + ctx, + omniParser(alreadyParsed, subproject)); + } - SourceFileStream nonProjectResources = parseNonProjectResources(subproject, alreadyParsed, ctx); - sourceFileStream = sourceFileStream.concat(nonProjectResources, nonProjectResources.size()); + private Stream parseJavaFiles(List javaPaths, + ExecutionContext ctx, + Path buildDir, + Collection exclusions, + Charset javaSourceCharset, + JavaVersion javaVersion, + Set dependencyPaths, + JavaTypeCache javaTypeCache) { + view(ctx).setCharset(javaSourceCharset); + + return Stream.of((Supplier) () -> JavaParser.fromJavaVersion() + .classpath(dependencyPaths) + .styles(styles) + .typeCache(javaTypeCache) + .logCompilationWarningsAndErrors(extension.getLogCompilationWarningsAndErrors()) + .build()).map(Supplier::get).flatMap(jp -> jp.parse(javaPaths, baseDir, ctx)).map(cu -> { + if (isExcluded(exclusions, cu.getSourcePath()) || cu.getSourcePath().startsWith(buildDir)) { + return null; + } + return cu; + }).filter(Objects::nonNull).map(it -> it.withMarkers(it.getMarkers().add(javaVersion))); + } - progressBar.setMax(sourceFileStream.size()); - return sourceFileStream - .map(addProvenance(projectProvenance)) - .peek(it -> progressBar.step()); - } catch (Exception e) { - throw new RuntimeException(e); - } + private Stream parseKotlinFiles(List kotlinPaths, + ExecutionContext ctx, + Path buildDir, + Collection exclusions, + Charset javaSourceCharset, + JavaVersion javaVersion, + Set dependencyPaths, + JavaTypeCache javaTypeCache) { + view(ctx).setCharset(javaSourceCharset); + + return Stream.of((Supplier) () -> KotlinParser.builder() + .classpath(dependencyPaths) + .styles(styles) + .typeCache(javaTypeCache) + .logCompilationWarningsAndErrors(extension.getLogCompilationWarningsAndErrors()) + .build()).map(Supplier::get).flatMap(kp -> kp.parse(kotlinPaths, baseDir, ctx)).map(cu -> { + if (isExcluded(exclusions, cu.getSourcePath()) || cu.getSourcePath().startsWith(buildDir)) { + return null; + } + return cu; + }).filter(Objects::nonNull).map(it -> it.withMarkers(it.getMarkers().add(javaVersion))); } private GradleParser gradleParser() { @@ -867,7 +974,10 @@ private GradleParser gradleParser() { if (GradleVersion.current().compareTo(GradleVersion.version("4.4")) >= 0) { try { Settings settings = ((DefaultGradle) project.getGradle()).getSettings(); - settingsClasspath = settings.getBuildscript().getConfigurations().getByName("classpath").resolve() + settingsClasspath = settings.getBuildscript() + .getConfigurations() + .getByName("classpath") + .resolve() .stream() .map(File::toPath) .collect(toList()); @@ -877,7 +987,10 @@ private GradleParser gradleParser() { } else { settingsClasspath = emptyList(); } - List buildscriptClasspath = project.getBuildscript().getConfigurations().getByName("classpath").resolve() + List buildscriptClasspath = project.getBuildscript() + .getConfigurations() + .getByName("classpath") + .resolve() .stream() .map(File::toPath) .collect(toList()); @@ -892,8 +1005,10 @@ private GradleParser gradleParser() { .build(); } - private SourceFileStream parseGradleFiles( - Project subproject, Collection exclusions, Set alreadyParsed, ExecutionContext ctx) { + private SourceFileStream parseGradleFiles(Project subproject, + Collection exclusions, + Set alreadyParsed, + ExecutionContext ctx) { Stream sourceFiles = Stream.empty(); int gradleFileCount = 0; @@ -996,7 +1111,8 @@ private SourceFileStream parseGradleWrapperFiles(Collection exclusi if (project == project.getRootProject()) { OmniParser omniParser = omniParser(alreadyParsed, project); List gradleWrapperFiles = Stream.of( - "gradlew", "gradlew.bat", + "gradlew", + "gradlew.bat", "gradle/wrapper/gradle-wrapper.jar", "gradle/wrapper/gradle-wrapper.properties") .map(project::file) @@ -1038,8 +1154,7 @@ private static Collection mergeExclusions(Project project, Path baseDir, return Stream.concat( project.getSubprojects().stream() .map(subproject -> separatorsToUnix(baseDir.relativize(subproject.getProjectDir().toPath()).toString())), - extension.getExclusions().stream() - ).collect(toList()); + extension.getExclusions().stream()).collect(toList()); } private Collection pathMatchers(Path basePath, Collection pathExpressions) { @@ -1093,7 +1208,9 @@ private SourceFileStream parseMultiplatformKotlinProject(Project subproject, Col // classpath doesn't include the transitive dependencies of the implementation configuration // These aren't needed for compilation, but we want them so recipes have access to comprehensive type information // The implementation configuration isn't resolvable, so we need a new configuration that extends from it - String implementationName = (String) sourceSet.getClass().getMethod("getImplementationConfigurationName").invoke(sourceSet); + String implementationName = (String) sourceSet.getClass() + .getMethod("getImplementationConfigurationName") + .invoke(sourceSet); Configuration implementation = subproject.getConfigurations().getByName(implementationName); Configuration rewriteImplementation = subproject.getConfigurations().maybeCreate("rewrite" + implementationName); if (!rewriteImplementation.getExtendsFrom().contains(implementation)) { @@ -1161,13 +1278,13 @@ private SourceFile logParseErrors(SourceFile source) { if (firstWarningLogged.compareAndSet(false, true)) { logger.warn("There were problems parsing some source files, run with --info to see full stack traces"); } - logger.warn("There were problems parsing " + source.getSourcePath()); + logger.warn("There were problems parsing {}", source.getSourcePath()); logger.debug(e.getMessage()); }); return source; } - private boolean isExcluded(Collection exclusions, Path path) { + static boolean isExcluded(Collection exclusions, Path path) { for (PathMatcher excluded : exclusions) { if (excluded.matches(path)) { return true; @@ -1275,7 +1392,7 @@ private UnaryOperator addProvenance(List proje }; } - private UnaryOperator addProvenance(Marker sourceSet) { + static UnaryOperator addProvenance(Marker sourceSet) { return s -> { Markers m = s.getMarkers(); m = m.addIfAbsent(sourceSet); @@ -1297,7 +1414,7 @@ private void logRecipe(RecipeDescriptor rd, String prefix) { StringBuilder recipeString = new StringBuilder(prefix + rd.getName()); if (!rd.getOptions().isEmpty()) { String opts = rd.getOptions().stream().map(option -> { - if (option.getValue() != null) { + if (option.getValue() != null) { return option.getName() + "=" + option.getValue(); } return null; @@ -1313,21 +1430,55 @@ private void logRecipe(RecipeDescriptor rd, String prefix) { } } - private @Nullable SourceSetContainer findSourceSetContainer(Project project) { - SourceSetContainer sourceSets = null; + private List findGradleSourceSets(Project project) { + List sourceSets = emptyList(); if (project.getGradle().getGradleVersion().compareTo("7.1") >= 0) { JavaPluginExtension javaPluginExtension = project.getExtensions().findByType(JavaPluginExtension.class); if (javaPluginExtension != null) { - sourceSets = javaPluginExtension.getSourceSets(); + sourceSets = new ArrayList<>(javaPluginExtension.getSourceSets()); } } else { //Using the older javaConvention because we need to support older versions of gradle. @SuppressWarnings("deprecation") JavaPluginConvention javaConvention = project.getConvention().findPlugin(JavaPluginConvention.class); if (javaConvention != null) { - sourceSets = javaConvention.getSourceSets(); + sourceSets = new ArrayList<>(javaConvention.getSourceSets()); + } + } + return sourceSets.stream().sorted(Comparator.comparingInt(sourceSet -> { + if ("main".equals(sourceSet.getName())) { + return 0; + } else if ("test".equals(sourceSet.getName())) { + return 1; + } else { + return 2; } + })).collect(toList()); + } + + private JavaVersion getJavaVersion(@Nullable JavaCompile javaCompileTask) { + String sourceCompatibility = null; + String targetCompatibility = null; + if (javaCompileTask != null) { + sourceCompatibility = javaCompileTask.getSourceCompatibility(); + targetCompatibility = javaCompileTask.getTargetCompatibility(); + } + return new JavaVersion( + randomId(), + System.getProperty("java.runtime.version"), + System.getProperty("java.vm.vendor"), + sourceCompatibility, + targetCompatibility); + + } + + private Charset getSourceFileEncoding(@Nullable JavaCompile javaCompileTask, + Charset defaultCharset) { + String sourceEncoding = null; + if (sourceEncoding == null && javaCompileTask != null) { + CompileOptions compileOptions = javaCompileTask.getOptions(); + sourceEncoding = compileOptions.getEncoding(); } - return sourceSets; + return Optional.ofNullable(sourceEncoding).map(Charset::forName).orElse(defaultCharset); } } diff --git a/plugin/src/main/kotlin/org/openrewrite/gradle/GradleProjectSpec.kt b/plugin/src/main/kotlin/org/openrewrite/gradle/GradleProjectSpec.kt index 2b8b87285..0233e4fdc 100644 --- a/plugin/src/main/kotlin/org/openrewrite/gradle/GradleProjectSpec.kt +++ b/plugin/src/main/kotlin/org/openrewrite/gradle/GradleProjectSpec.kt @@ -15,10 +15,12 @@ */ package org.openrewrite.gradle +import org.gradle.util.GradleVersion import org.intellij.lang.annotations.Language import java.io.File import java.nio.charset.Charset import java.nio.charset.StandardCharsets +import java.nio.file.Files /** * Utility to help with writing gradle projects to disk to assist with plugin testing @@ -80,20 +82,50 @@ class GradleProjectSpec( } fun build(): GradleProjectSpec { - dir.mkdirs() + Files.createDirectories(dir.toPath()) + val settings = dir.toPath().resolve("settings.gradle") + val lines = ArrayList() + if (settingsGradle == null) { + val gradleVersionString = System.getProperty("org.openrewrite.test.gradleVersion", "8.0") + val gradleVersion = GradleVersion.version(gradleVersionString) + if (gradleVersion > GradleVersion.version("5.0")) { + lines.add( + """ + pluginManagement { + repositories { + gradlePluginPortal() + mavenLocal() + mavenCentral() + google() + // jcenter is currently only required for AGP 3.* + jcenter() + } + } - val settings = File(dir, "settings.gradle") - if(settingsGradle == null) { - val settingsText = "rootProject.name = \"${dir.name}\"\n" - if (subprojects.isEmpty()) { - settings.writeText("rootProject.name = \"${dir.name}\"\n") - } else { - val subprojectsDeclarations = subprojects.joinToString("\n") { subproject -> "include('${subproject.dir.name}')" } - settings.writeText(settingsText + subprojectsDeclarations) + dependencyResolutionManagement { + repositories { + gradlePluginPortal() + google() + mavenLocal() + mavenCentral() + maven { + url = uri("https://oss.sonatype.org/content/repositories/snapshots") + } + // jcenter is currently only required for AGP 3.* + jcenter() + } + } + """.trimIndent() + ) + } + lines.add("rootProject.name = \"${dir.name}\"") + if (!subprojects.isEmpty()) { + subprojects.forEach {subproject -> lines.add("include('${subproject.dir.name}')")}; } } else { - settings.writeText(settingsGradle!!) + lines.add(settingsGradle!!) } + Files.write(settings, lines) if (groovyBuildScript != null) { File(dir, "build.gradle").writeText(groovyBuildScript!!) diff --git a/plugin/src/test/kotlin/org/openrewrite/gradle/RewriteDryRunTest.kt b/plugin/src/test/kotlin/org/openrewrite/gradle/RewriteDryRunTest.kt index 486bf95ed..88736e5d6 100644 --- a/plugin/src/test/kotlin/org/openrewrite/gradle/RewriteDryRunTest.kt +++ b/plugin/src/test/kotlin/org/openrewrite/gradle/RewriteDryRunTest.kt @@ -19,18 +19,21 @@ import org.assertj.core.api.Assertions.assertThat import org.gradle.testkit.runner.TaskOutcome import org.junit.jupiter.api.Test import org.junit.jupiter.api.condition.DisabledIf +import org.junit.jupiter.api.condition.EnabledIf import org.junit.jupiter.api.io.TempDir +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource import org.openrewrite.Issue import java.io.File class RewriteDryRunTest : RewritePluginTest { + @TempDir + lateinit var projectDir: File override fun taskName(): String = "rewriteDryRun" @Test - fun `rewriteDryRun runs successfully without modifying source files`( - @TempDir projectDir: File - ) { + fun `rewriteDryRun runs successfully without modifying source files`() { //language=java val helloWorld = """ package org.openrewrite.before; @@ -39,8 +42,9 @@ class RewriteDryRunTest : RewritePluginTest { }public static void main(String[] args) { sayGoodbye(); } } """.trimIndent() - gradleProject(projectDir) { - rewriteYaml(""" + gradleProject(projectDir) { + rewriteYaml( + """ type: specs.openrewrite.org/v1beta/recipe name: org.openrewrite.gradle.SayHello description: Test. @@ -51,8 +55,10 @@ class RewriteDryRunTest : RewritePluginTest { - org.openrewrite.java.ChangePackage: oldPackageName: org.openrewrite.before newPackageName: org.openrewrite.after - """) - buildGradle(""" + """ + ) + buildGradle( + """ plugins { id("java") id("org.openrewrite.rewrite") @@ -69,8 +75,9 @@ class RewriteDryRunTest : RewritePluginTest { rewrite { activeRecipe("org.openrewrite.gradle.SayHello", "org.openrewrite.java.format.AutoFormat") } - """) - sourceSet("main") { + """ + ) + sourceSet("main") { java(helloWorld) } } @@ -78,17 +85,17 @@ class RewriteDryRunTest : RewritePluginTest { val rewriteDryRunResult = result.task(":${taskName()}")!! assertThat(rewriteDryRunResult.outcome).isEqualTo(TaskOutcome.SUCCESS) - assertThat(File(projectDir, "src/main/java/org/openrewrite/before/HelloWorld.java") - .readText()).isEqualTo(helloWorld) + assertThat( + File(projectDir, "src/main/java/org/openrewrite/before/HelloWorld.java").readText() + ).isEqualTo(helloWorld) assertThat(File(projectDir, "build/reports/rewrite/rewrite.patch").exists()).isTrue } @Test - fun `A recipe with optional configuration can be activated directly`( - @TempDir projectDir: File - ) { - gradleProject(projectDir) { - buildGradle(""" + fun `A recipe with optional configuration can be activated directly`() { + gradleProject(projectDir) { + buildGradle( + """ plugins { id("java") id("org.openrewrite.rewrite") @@ -101,25 +108,24 @@ class RewriteDryRunTest : RewritePluginTest { url = uri("https://oss.sonatype.org/content/repositories/snapshots") } } - """) - sourceSet("main") { - java(""" + """ + ) + sourceSet("main") { + java( + """ package org.openrewrite.before; - - import java.util.ArrayList; import java.util.List; + import java.util.ArrayList; public class HelloWorld { - public static void sayHello() { - System.out.println("Hello world"); - } - + public static void main(String[] args) { - sayHello(); + System.out.println("Hello world"); } } - """) + """ + ) } } @@ -131,9 +137,10 @@ class RewriteDryRunTest : RewritePluginTest { @DisabledIf("lessThanGradle6_1") @Test - fun multiplatform(@TempDir projectDir: File) { - gradleProject(projectDir) { - buildGradle(""" + fun multiplatform() { + gradleProject(projectDir) { + buildGradle( + """ plugins { id("java") id("org.openrewrite.rewrite") @@ -171,8 +178,10 @@ class RewriteDryRunTest : RewritePluginTest { } } } - """) - settingsGradle(""" + """ + ) + settingsGradle( + """ pluginManagement { repositories { mavenLocal() @@ -181,15 +190,18 @@ class RewriteDryRunTest : RewritePluginTest { } } rootProject.name = "example" - """) - sourceSet("commonMain") { - kotlin(""" + """ + ) + sourceSet("commonMain") { + kotlin( + """ class HelloWorld { fun sayHello() { println("Hello world") } } - """) + """ + ) } } val result = runGradle(projectDir, taskName(), "-DactiveRecipe=org.openrewrite.kotlin.FindKotlinSources") @@ -204,11 +216,10 @@ class RewriteDryRunTest : RewritePluginTest { @DisabledIf("lessThanGradle7_4") @Issue("https://github.com/openrewrite/rewrite-gradle-plugin/issues/227") @Test - fun `rewriteDryRun is compatible with the configuration cache`( - @TempDir projectDir: File - ) { + fun `rewriteDryRun is compatible with the configuration cache`() { gradleProject(projectDir) { - buildGradle(""" + buildGradle( + """ plugins { id("org.openrewrite.rewrite") } @@ -220,10 +231,127 @@ class RewriteDryRunTest : RewritePluginTest { url = uri("https://oss.sonatype.org/content/repositories/snapshots") } } - """) + """ + ) } val result = runGradle(projectDir, taskName(), "--configuration-cache") val rewriteDryRunResult = result.task(":${taskName()}")!! assertThat(rewriteDryRunResult.outcome).isEqualTo(TaskOutcome.SUCCESS) } + + @ParameterizedTest + @ValueSource(strings = ["8.6.0", "7.0.4", "4.2.2"]) + fun `rewriteDryRun is compatible with AGP version 4 and over`(pluginVersion: String) { + if (lessThanGradle6_1()) { + // @DisabledIf doesn't seem to work with @ParameterizedTest + return; + } + gradleProject(projectDir) { + buildGradle( + """ + plugins { + id("com.android.application") version "$pluginVersion" + id("org.openrewrite.rewrite") + } + + group = "org.example" + version = "1.0-SNAPSHOT" + + android { + namespace = "example" + compileSdkVersion 30 + } + + dependencies { + implementation("com.google.guava:guava:33.3.0-android") + } + """ + ) + sourceSet("main") { + java( + """ + import java.util.List; + import java.util.Collections; + + class HelloWorld { + HelloWorld() { + super(); + } + } + """ + ) + } + } + val result = runGradle(projectDir, taskName(), "-DactiveRecipe=org.openrewrite.java.OrderImports") + val rewriteDryRunResult = result.task(":${taskName()}")!! + + assertThat(rewriteDryRunResult.outcome).isEqualTo(TaskOutcome.SUCCESS) + val patchFile = File(projectDir, "build/reports/rewrite/rewrite.patch") + assertThat(patchFile).exists() + assertThat(patchFile.readText().trim()) + .isEqualTo( + """ + diff --git a/src/main/java/HelloWorld.java b/src/main/java/HelloWorld.java + index d8a9002..7e3e2a0 100644 + --- a/src/main/java/HelloWorld.java + +++ b/src/main/java/HelloWorld.java + @@ -1,5 +1,5 @@ org.openrewrite.java.OrderImports + -import java.util.List; + import java.util.Collections; + +import java.util.List; + + class HelloWorld { + HelloWorld() { + """.trimIndent() + ) + + } + + @EnabledIf("isAgp3CompatibleGradleVersion") + @Test + fun `rewriteDryRun is compatible with AGP version 3`() { + gradleProject(projectDir) { + buildGradle( + """ + buildscript { + dependencies { + classpath 'com.android.tools.build:gradle:3.4.0' + } + } + + plugins { + id("org.openrewrite.rewrite") + } + + apply plugin: 'com.android.application' + + group = "org.example" + version = "1.0-SNAPSHOT" + + android { + compileSdkVersion 28 + } + """ + ) + sourceSet("main") { + java( + """ + import java.util.List; + import java.util.Collections; + + class HelloWorld { + HelloWorld() { + super(); + } + } + """ + ) + } + } + val result = runGradle(projectDir, taskName(), "-DactiveRecipe=org.openrewrite.java.OrderImports") + val rewriteDryRunResult = result.task(":${taskName()}")!! + + assertThat(rewriteDryRunResult.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(File(projectDir, "build/reports/rewrite/rewrite.patch")).exists() + } } diff --git a/plugin/src/test/kotlin/org/openrewrite/gradle/RewritePluginTest.kt b/plugin/src/test/kotlin/org/openrewrite/gradle/RewritePluginTest.kt index 38f60299b..d0d21a1e8 100644 --- a/plugin/src/test/kotlin/org/openrewrite/gradle/RewritePluginTest.kt +++ b/plugin/src/test/kotlin/org/openrewrite/gradle/RewritePluginTest.kt @@ -33,11 +33,11 @@ interface RewritePluginTest { fun taskName(): String - fun runGradle(testDir: File, vararg args: String): BuildResult = - GradleRunner.create() + fun runGradle(testDir: File, vararg args: String): BuildResult { + return GradleRunner.create() .withDebug(ManagementFactory.getRuntimeMXBean().inputArguments.toString().indexOf("-agentlib:jdwp") > 0) .withProjectDir(testDir) - .apply{ + .apply { if (gradleVersion != null) { withGradleVersion(gradleVersion) } @@ -46,6 +46,7 @@ interface RewritePluginTest { .withPluginClasspath() .forwardOutput() .build() + } fun lessThanGradle6_1(): Boolean { val currentVersion = if (gradleVersion == null) GradleVersion.current() else GradleVersion.version(gradleVersion) @@ -89,4 +90,11 @@ interface RewritePluginTest { val taskResult = result.task(":${taskName()}")!! assertThat(taskResult.outcome).isEqualTo(TaskOutcome.SUCCESS) } + + fun isAgp3CompatibleGradleVersion(): Boolean { + val currentVersion = if (gradleVersion == null) GradleVersion.current() else GradleVersion.version(gradleVersion) + return System.getenv("ANDROID_HOME") != null && + currentVersion >= GradleVersion.version("5.0") && + currentVersion < GradleVersion.version("8.0") + } }