Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package io.quarkus.deployment.dev;

import java.util.Map;
import java.util.Set;

import org.jboss.jandex.DotName;

import io.quarkus.builder.item.MultiBuildItem;

public final class AnnotationDependentClassesBuildItem extends MultiBuildItem {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Javadoc on build items please :)


private final DotName annotationName;

private final Map<DotName, Set<DotName>> dependencyToAnnotatedClasses;

public AnnotationDependentClassesBuildItem(DotName annotationName,
Map<DotName, Set<DotName>> dependencyToAnnotatedClasses) {
this.annotationName = annotationName;
this.dependencyToAnnotatedClasses = dependencyToAnnotatedClasses;
}

public DotName getAnnotationName() {
return annotationName;
}

public Map<DotName, Set<DotName>> getDependencyToAnnotatedClasses() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not immediately clear to me which way the dependency points, so javadoc would help explain it here.

return dependencyToAnnotatedClasses;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package io.quarkus.deployment.dev;

import java.util.Optional;
import java.util.Set;

import io.quarkus.runtime.annotations.ConfigPhase;
import io.quarkus.runtime.annotations.ConfigRoot;
import io.smallrye.config.ConfigMapping;

@ConfigRoot(phase = ConfigPhase.BUILD_TIME)
@ConfigMapping(prefix = "quarkus.dev")
public interface AnnotationDependentClassesConfig {

/**
* FQDNs of annotations that trigger automatic recompilation of annotated classes when their dependencies change
* during dev mode. This is useful for annotation processors that generate code based on these classes (e.g. Mapstruct).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not clear to me. annotated classes, their dependencies and these classes are ambiguous to me. It sounds a bit like this is a list of annotations that trigger a recompilation of the classes that have this annotation when they change. But this is standard behaviour, so it can't be it.

I think that what this list does is:

FQDN of annotations which, when present on an annotated type X, will trigger a recompilation of all the dependencies of X whenever the type X needs a recompilation. The dependencies of X include all the types present in the X source code.
For example, given the FQDN org.example.FroMage and the following type:

@FroMage
public record Munster(Ferment ferment, Milk milk){}

then, whenever Munster needs to be recompiled because it was edited, we will automatically recompile the Ferment and Milk types as well.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If… this is indeed what this does? 😅

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, so I was wrong, and this is not what it does, reading the description below. What this does is:

FQDN of annotations which, when present on an annotated type X, will trigger a recompilation of X whenever any dependency of X needs a recompilation. The dependencies of X include all the types directly present in the X source code, but also indirectly all the types present as members of these dependencies (fields, methods) as well as all their supertypes (superclass and superinterfaces) transitively.

For example, given the FQDN org.example.FroMage and the following type:

@FroMage
public record Munster(Ferment ferment, Milk milk){}

then, whenever Ferment or Milk need to be recompiled because they were edited, we will automatically recompile the Munster type as well. This would also trigger whenever an indirect dependency of Ferment or Milk was edited and needed to be recompiled.

*/
Optional<Set<String>> recompileAnnotations();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any better suggestions for the config name?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets discuss this when the javadoc is clearer, I'm sure the name will follow.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

quarkus.dev.recompile.annotated.classes.when.dependencies.change but that's a mouthful :(

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,335 @@
package io.quarkus.deployment.dev;

import java.lang.reflect.Modifier;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.jboss.jandex.AnnotationInstance;
import org.jboss.jandex.ClassInfo;
import org.jboss.jandex.ClassType;
import org.jboss.jandex.DotName;
import org.jboss.jandex.FieldInfo;
import org.jboss.jandex.IndexView;
import org.jboss.jandex.MethodInfo;
import org.jboss.jandex.MethodParameterInfo;
import org.jboss.jandex.ParameterizedType;
import org.jboss.jandex.Type;
import org.jboss.jandex.TypeVariable;
import org.jboss.jandex.WildcardType;
import org.jboss.logging.Logger;

import io.quarkus.deployment.annotations.BuildStep;
import io.quarkus.deployment.annotations.Produce;
import io.quarkus.deployment.builditem.CombinedIndexBuildItem;
import io.quarkus.deployment.builditem.ServiceStartBuildItem;

/**
* Processor which figures out annotation marked recompilation dependencies. This is needed to solve the problem of "how to
* recompile classes generated by annotation processor, where the generated class accesses another type in the users code".
* </p>
* Only classes annotated with an annotation configured in {@link AnnotationDependentClassesConfig} are discovered.
* </p>
* From these annotated classes, the set of all directly referenced types is collected. This includes all fields, method return
* types, and method parameters type of any visibility of the full class hierarchy.
* </p>
* Then based on the set of directly referenced types, another set of indirectly referenced types is discovered. This includes
* all the public, protected, and package private fields, method return types, and method parameters types of the current type
* and upwards in the hierarchy. Package private and protected (which is also package private) member are only included, if they
* are declared on a class in the same package as the annotated class. This step is addition repeated for each additional public
* referenced type, until every direct or public type was visited once.
* </p>
* Result is a Mapping of the referenced type to the annotated class. Note that no chaining information between the referenced
* types is kept. Since for the recompilation to take place we just need to resolve later on from the dependency to the
* annotated class. This discovery is repeated for all the configured annotations.
* </p>
*
* A consolidation step takes place, which resolves inner classes to their outer classes. Inner classes and outer classes share
* source file (at least in java). For our goal of recompilation we therefore only need to outer class.
* </p>
* After resolving outer classes, all the mappings of referenced type to annotated class are combined into one mapping, which is
* then given to the {@link RuntimeUpdatesProcessor#setRecompilationDependencies(Map)}.
*/
public class AnnotationDependentClassesProcessor {

private static final Logger LOGGER = Logger.getLogger(AnnotationDependentClassesProcessor.class);

@BuildStep
public List<AnnotationDependentClassesBuildItem> discoverAnnotationDependentClasses(AnnotationDependentClassesConfig config,
CombinedIndexBuildItem combinedIndexBuildItem) {

if (config.recompileAnnotations().isEmpty()) {
return Collections.emptyList();
}

// Sometimes the annotation itself is not in a jandex index, but that is fine as long as we can find it
Set<DotName> recompileAnnotationNames = resolveConfiguredAnnotationNames(config.recompileAnnotations().get(),
combinedIndexBuildItem.getComputingIndex());

List<AnnotationDependentClassesBuildItem> result = new ArrayList<>();
for (DotName recompileAnnotationName : recompileAnnotationNames) {
// the classes the annotation is applied on have to be in our index though.

AnnotationDependentClassesBuildItem annotationDependentClassesBuildItem = determineDependencies(
combinedIndexBuildItem.getIndex(), recompileAnnotationName);

if (annotationDependentClassesBuildItem != null) {
result.add(annotationDependentClassesBuildItem);
}
}

return result;
}

private Set<DotName> resolveConfiguredAnnotationNames(Set<String> recompileAnnotations, IndexView index) {
// quick check and warn if the annotation type even exists
// warning is enough, we just optionally want to determine additional classes to recompile based on the configured recompileAnnotations
Set<DotName> result = new HashSet<>();
for (String recompileAnnotation : recompileAnnotations) {

ClassInfo classByName = index.getClassByName(recompileAnnotation);
if (classByName == null) {
LOGGER.warnf("""
Configured recompile annotation type %s not found.\
Won't automatically recompile annotated classes when dependent classes change.""",
recompileAnnotation);
continue;
}

if (!classByName.isAnnotation()) {
LOGGER.warnf("""
Configured recompile annotation type %s is not an annotation class.""",
recompileAnnotation);
continue;
}

result.add(classByName.name());
}

return result;
}

private AnnotationDependentClassesBuildItem determineDependencies(IndexView index, DotName supportedAnnotationName) {
Collection<AnnotationInstance> annotations = index.getAnnotations(supportedAnnotationName);
Map<DotName, Set<DotName>> dependencyToAnnotatedClasses = new HashMap<>();
for (AnnotationInstance annotation : annotations) {
ClassInfo annotatedClass;
try {
annotatedClass = annotation.target().asClass();
} catch (Exception exception) {
LOGGER.warnf(exception, "Annotation %s is not placed on a class. Target %s instead. Skipping.",
supportedAnnotationName, annotation.target());
continue;
}

Set<DotName> referencedTypes = collectAllReferencedTypeNames(annotatedClass, index);

Set<DotName> dependencies = new HashSet<>();
for (DotName referencedType : referencedTypes) {
collectVisibleTypeNames(dependencies, referencedType, index, annotatedClass.name());
}

for (DotName dependency : dependencies) {
dependencyToAnnotatedClasses.computeIfAbsent(dependency, k -> new HashSet<>()).add(annotatedClass.name());
}
}

if (dependencyToAnnotatedClasses.isEmpty()) {
return null;
}

return new AnnotationDependentClassesBuildItem(supportedAnnotationName, dependencyToAnnotatedClasses);
}

private Set<DotName> collectAllReferencedTypeNames(ClassInfo startingClass, IndexView index) {
Set<DotName> visited = new HashSet<>();
ArrayDeque<DotName> stack = new ArrayDeque<>();
stack.add(startingClass.name());

Set<DotName> referencedTypeNames = new HashSet<>();
while (!stack.isEmpty()) {
DotName currentClassName = stack.poll();
if (!visited.add(currentClassName)) {
continue;
}
if (currentClassName.equals(DotName.OBJECT_NAME)) {
continue;
}

ClassInfo classInfo = index.getClassByName(currentClassName);
if (classInfo == null) {
continue;
}

// search up and down the inheritance chain
stack.add(classInfo.superClassType().name());
stack.addAll(classInfo.interfaceNames());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will likely not work with generics.

Consider these types:

class Base extends Top<Bar> {
}
class Top<T> {
 public T property;
}

By going up the type hierarchy with Top<Object> (raw), you're missing a Bar public member.


for (ClassInfo knownDirectSubclass : index.getKnownDirectSubclasses(currentClassName)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I did not realise that subtypes also had to be factored in. I guess the suggested javadoc above should reflect that.

stack.add(knownDirectSubclass.name());
}
for (ClassInfo knownDirectImplementation : index.getKnownDirectImplementations(currentClassName)) {
stack.add(knownDirectImplementation.name());
}

// Collect types of any fields in the inheritance chain of the annotated class
for (FieldInfo field : classInfo.fields()) {
if (!field.isSynthetic()) {
extractTypeNames(field.type(), referencedTypeNames);
}
}

// Collect types of any methods in the inheritance chain of the annotated class
for (MethodInfo method : classInfo.methods()) {
if (!method.isSynthetic()) {
extractTypeNames(method.returnType(), referencedTypeNames);
for (MethodParameterInfo parameter : method.parameters()) {
extractTypeNames(parameter.type(), referencedTypeNames);
}
}
}
}

return referencedTypeNames;
}

private void extractTypeNames(Type type, Collection<DotName> names) {
switch (type.kind()) {
case CLASS -> names.add(type.name());
case PARAMETERIZED_TYPE -> {
names.add(type.name());
ParameterizedType parameterizedType = type.asParameterizedType();
for (Type argument : parameterizedType.arguments()) {
// useful for nested generics, e.g. Map<String, List<SomeType>>
extractTypeNames(argument, names);
}
}
case WILDCARD_TYPE -> {
names.add(type.name());
WildcardType wildcardType = type.asWildcardType();
if (!ClassType.OBJECT_TYPE.equals(wildcardType.extendsBound())) {
extractTypeNames(wildcardType.extendsBound(), names);
} else if (wildcardType.superBound() != null) {
extractTypeNames(wildcardType.superBound(), names);
}
}
case TYPE_VARIABLE -> {
names.add(type.name());
TypeVariable typeVariable = type.asTypeVariable();
for (Type bound : typeVariable.bounds()) {
extractTypeNames(bound, names);
}
}
case ARRAY -> extractTypeNames(type.asArrayType().constituent(), names);
}
}

private void collectVisibleTypeNames(Set<DotName> collectedTypes, DotName startingPoint, IndexView index,
DotName annotatedClassName) {
ArrayDeque<DotName> stack = new ArrayDeque<>();
stack.add(startingPoint);

while (!stack.isEmpty()) {
DotName currentClassName = stack.poll();
if (!collectedTypes.add(currentClassName)) {
// already know about this property type
continue;
}
if (currentClassName.equals(DotName.OBJECT_NAME)) {
continue;
}

ClassInfo classInfo = index.getClassByName(currentClassName);
if (classInfo == null) {
continue;
}

// only search upwards. The annotated class should only contain references to the public types it can see, i.e. our own and our parents public types
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why that is different to the "referenced types".

stack.add(classInfo.superClassType().name());
stack.addAll(classInfo.interfaceNames());

for (FieldInfo field : classInfo.fields()) {
if (isVisibleForAnnotatedClass(field.flags(), classInfo, annotatedClassName) && !field.isSynthetic()) {
extractTypeNames(field.type(), stack);
}
}

for (MethodInfo method : classInfo.methods()) {
if (isVisibleForAnnotatedClass(method.flags(), classInfo, annotatedClassName) && !method.isSynthetic()) {
extractTypeNames(method.returnType(), stack);
for (MethodParameterInfo parameter : method.parameters()) {
extractTypeNames(parameter.type(), stack);
}
}
}
}
}

private boolean isVisibleForAnnotatedClass(short flags, ClassInfo declaringClass, DotName annotatedClassName) {
if (Modifier.isPublic(flags)) {
return true;
}

boolean isProtected = Modifier.isProtected(flags);
boolean isPackagePrivate = !isProtected && !Modifier.isPrivate(flags);
if (isProtected || isPackagePrivate) {
return declaringClass.name().packagePrefix().equals(annotatedClassName.packagePrefix());
}

return false;
}

@BuildStep
@Produce(ServiceStartBuildItem.class)
public void consolidateRecompilationDependencies(CombinedIndexBuildItem combinedIndexBuildItem,
List<AnnotationDependentClassesBuildItem> annotationDependentClassesBuildItems) {

// Cleanup and combine all the dependencyToAnnotatedClasses maps:
// - Resolve inner classes to their top-level class names
// - Remove entries where the class is not in the index

Map<DotName, Set<DotName>> dependencyToAnnotatedClasses = new HashMap<>();
for (AnnotationDependentClassesBuildItem buildItem : annotationDependentClassesBuildItems) {
for (Map.Entry<DotName, Set<DotName>> entry : buildItem.getDependencyToAnnotatedClasses().entrySet()) {

DotName dependency = resolveOutermostClassName(entry.getKey(), combinedIndexBuildItem.getIndex());
if (dependency == null) {
continue;
}

for (DotName annotatedClass : entry.getValue()) {
annotatedClass = resolveOutermostClassName(annotatedClass, combinedIndexBuildItem.getIndex());
if (annotatedClass == null) {
continue;
}

dependencyToAnnotatedClasses.computeIfAbsent(dependency, k -> new HashSet<>()).add(annotatedClass);
}
}
}

if (RuntimeUpdatesProcessor.INSTANCE != null) {
RuntimeUpdatesProcessor.INSTANCE.setRecompilationDependencies(dependencyToAnnotatedClasses);
}
}

private DotName resolveOutermostClassName(DotName name, IndexView index) {

ClassInfo classInfo = index.getClassByName(name);
if (classInfo == null) {
return null;
}

if (classInfo.nestingType() != ClassInfo.NestingType.TOP_LEVEL) {
return resolveOutermostClassName(classInfo.enclosingClassAlways(), index);
}

return name;
}
}
Loading
Loading