ArchUnit icon indicating copy to clipboard operation
ArchUnit copied to clipboard

Analyze a path with @AnalyzeClasses

Open TheConen opened this issue 2 years ago • 3 comments

The ClassFileImporter offers the method "importPath()" to import classes in a specific path instead of a specific package. Is there an equivalent for @AnalyzeClasses? Something like

@AnalyzeClasses(path = "src/main")

Our project uses a lof of generated code that uses the same package structure as non-generated code and we would like to exclude the generated code from being scanned.

TheConen avatar Dec 16 '22 18:12 TheConen

You can use the LocationProvider API for that. E.g. create a custom location provider

class PathLocationProvider implements LocationProvider {
    @Override
    public Set<Location> get(Class<?> testClass) {
        return Collections.singleton(Location.of(Paths.get("/some/path")));
    }
}

Then use it via

@AnalyzeClasses(locations = PathLocationProvider.class)

If you need it reusable you could introduce a custom path annotation

class PathLocationProvider implements LocationProvider {
    @Override
    public Set<Location> get(Class<?> testClass) {
        if (!testClass.isAnnotationPresent(SelectedPath.class)) {
            throw new IllegalArgumentException(
                    String.format("%s can only be used on classes annotated with @%s",
                            getClass().getSimpleName(), SelectedPath.class.getSimpleName()));
        }

        String path = testClass.getAnnotation(SelectedPath.class).value();
        return Collections.singleton(Location.of(Paths.get(path)));
    }
}

@Retention(RUNTIME)
@interface SelectedPath {
    String value();
}

Then use it via

@SelectedPath("/some/path")
@AnalyzeClasses(locations = PathLocationProvider.class)

Does that help you? Maybe we should support this use case out of the box, since I also had the need already several times in the past :thinking: But file paths are always a little tricky with respect to the "current directory" set by the runtime and I wasn't sure how projects in general would want to use such an API :man_shrugging:

codecholeric avatar Dec 26 '22 09:12 codecholeric

This did, in fact, help me - although it was a bit more complicated then I initially assumed.

We're using a Maven project with basically the following structure (standard maven):

src
    main
        java
            de.foo.bar
                Foo.java
src-gen (generated code)
    main
        java
            de.foo.bar
                Bar.java

We only want to scan Foo.java, but not Bar.java. Initially, I thought it would be as easy as this:

class PathLocationProvider implements LocationProvider {
    @Override
    public Set<Location> get(Class<?> testClass) {
        return Collections.singleton(Location.of(Paths.get("src/main/java")));
    }
}

As it turns out, ArchUnit seems to expect the LocationProvider to return the paths to *.class files, not to *.java files. Maven, however, will compile the above structure to

target
    classes
        de.foo.bar
            Foo.class
            Bar.class

This makes a differentiation between manually written code from src and automatically generated code from src-gen a tad more complicated. What we ended up with is the following, which seems to work for our use case:

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Stream;

import com.tngtech.archunit.core.importer.Location;
import com.tngtech.archunit.junit.LocationProvider;

public class OnlyScanSrcFolderLocationProvider implements LocationProvider {

    private static final String SOURCE_ROOT = "src/main/java";
    private static final String CLASS_ROOT = "target/classes";

    @Override
    public Set<Location> get(Class<?> testClass) {
        Path rootPath = Paths.get(SOURCE_ROOT);
        Set<Location> result = new HashSet<>();
        try (Stream<Path> pathStream = Files.walk(rootPath)) {
            pathStream.filter(Files::isReadable)
                .filter(Files::isRegularFile)
                .filter((path -> path.getFileName()
                    .toString()
                    .endsWith(".java")))
                .forEach(path -> {
                    Path classFilePath = Paths.get(CLASS_ROOT,
                        path.subpath(rootPath.getNameCount(), path.getNameCount() - 1).toString(),
                        path.getFileName()
                            .toString()
                            .replaceAll("\\.java$", ".class"));
                    result.add(Location.of(classFilePath));
                });
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

        return result;
    }
}

I can't imagine that we are the only one with the use case "do not scan generated code", so if someone has a more elegant solution to this that I don't know about, please let me know :)

TheConen avatar Jan 13 '23 15:01 TheConen

Yes, ArchUnit works on bytecode base, so even if you do importPackages("foo.bar") it will internally ask the ClassLoader "please give me the resource path of the compiled classes" and import all the .class files. For me the root problem is that your Maven configuration throws generated and non-generated classes together in the same output path. If the build tool would do this cleaner, you might have target/classes and target/gen-classes or something like this and all your logic wouldn't be necessary. But of course then all the environments would need to be configured to put both these file paths on the classpath. Given that all you have is one folder where all compiled classes are thrown together I also don't know of a better solution than the one you already found :thinking: Other other typical solution would be to configure the source generator to put something like generated into the package name, then you can also simply exclude */generated/* from your import and you can simply import packages again :man_shrugging:

codecholeric avatar Jan 22 '23 07:01 codecholeric