From 9fe114387a164989662b9dcf1e786ccd4d25b829 Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Thu, 20 Jul 2023 16:25:22 +0200 Subject: [PATCH] GH-221 - Temporarily shadow ArchUnit's Location type. It includes the fix submitted for TNG/ArchUnit#1131. To be removed once we can upgrade to a released version of the fix. --- .../archunit/core/importer/Location.java | 395 ++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 spring-modulith-core/src/main/java/com/tngtech/archunit/core/importer/Location.java diff --git a/spring-modulith-core/src/main/java/com/tngtech/archunit/core/importer/Location.java b/spring-modulith-core/src/main/java/com/tngtech/archunit/core/importer/Location.java new file mode 100644 index 00000000..aec9121b --- /dev/null +++ b/spring-modulith-core/src/main/java/com/tngtech/archunit/core/importer/Location.java @@ -0,0 +1,395 @@ +/* + * Copyright 2014-2023 TNG Technology Consulting GmbH + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.tngtech.archunit.core.importer; + +import static com.tngtech.archunit.PublicAPI.Usage.*; +import static com.tngtech.archunit.thirdparty.com.google.common.base.Preconditions.*; +import static java.util.Collections.*; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Enumeration; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import com.tngtech.archunit.PublicAPI; +import com.tngtech.archunit.base.ArchUnitException.LocationException; +import com.tngtech.archunit.base.ArchUnitException.UnsupportedUriSchemeException; +import com.tngtech.archunit.core.InitialConfiguration; +import com.tngtech.archunit.thirdparty.com.google.common.cache.Cache; +import com.tngtech.archunit.thirdparty.com.google.common.cache.CacheBuilder; +import com.tngtech.archunit.thirdparty.com.google.common.collect.ImmutableList; + +/** + * Handles various forms of location from where classes can be imported in a consistent way. Any location will be + * treated like a {@link URI}, thus there will not be any platform dependent file separator problems.
+ *
+ * Examples for locations could be + * + */ +@PublicAPI(usage = ACCESS) +public abstract class Location { + private static final InitialConfiguration> factories = new InitialConfiguration<>(); + + static { + ImportPlugin.Loader.loadForCurrentPlatform().plugInLocationFactories(factories); + } + + private static final Cache> ENTRY_CACHE = CacheBuilder.newBuilder() + .build(); + + final NormalizedUri uri; + + Location(NormalizedUri uri) { + this.uri = checkNotNull(uri); + } + + @PublicAPI(usage = ACCESS) + public URI asURI() { + return uri.toURI(); + } + + abstract ClassFileSource asClassFileSource(ImportOptions importOptions); + + /** + * @param part A part to check the respective location {@link URI} for + * @return {@code true}, if the respective {@link URI} contains the given part, {@code false} otherwise + */ + @PublicAPI(usage = ACCESS) + public boolean contains(String part) { + return uri.toString().contains(part); + } + + /** + * @param pattern A pattern to compare the respective location {@link URI} against + * @return {@code true}, if the respective {@link URI} matches the given pattern, {@code false} otherwise + */ + @PublicAPI(usage = ACCESS) + public boolean matches(Pattern pattern) { + return pattern.matcher(uri.toString()).matches(); + } + + @PublicAPI(usage = ACCESS) + public abstract boolean isJar(); + + /** + * This is a generalization of {@link #isJar()}. Before JDK 9, the only archives were Jar files, starting with JDK 9, + * we also have JRTs (the JDK modules). + * + * @return {@code true}, if this location represents an archive, like a JAR or JRT, {@code false} otherwise + */ + @PublicAPI(usage = ACCESS) + public abstract boolean isArchive(); + + // NOTE: URI behaves strange, if it is a JAR Uri, i.e. jar:file://.../some.jar!/, resolve does not work like expected + Location append(String relativeURI) { + relativeURI = encodeIllegalCharacters(relativeURI); + if (uri.toString().endsWith("/") && relativeURI.startsWith("/")) { + relativeURI = relativeURI.substring(1); + } + if (!uri.toString().endsWith("/") && !relativeURI.startsWith("/")) { + relativeURI = "/" + relativeURI; + } + return Location.of(URI.create(uri + relativeURI)); + } + + // NOTE: new URI(..) with more than one argument does URL encoding of illegal characters. URLEncoder on the other + // hand form-encodes all characters, even '/' which we do not want. + private String encodeIllegalCharacters(String relativeURI) { + try { + return new URI(null, null, relativeURI, null).toString(); + } catch (URISyntaxException e) { + throw new LocationException(e); + } + } + + void checkScheme(String scheme, NormalizedUri uri) { + String actualScheme = uri.getScheme(); + checkArgument(scheme.equals(actualScheme), + "URI %s of Location must have scheme %s, but has %s", uri, scheme, actualScheme); + } + + /** + * @return A Stream containing all class file names under this location, e.g. relative file names, Jar entry names, + * ... + */ + final Stream streamEntries() { + try { + return ENTRY_CACHE.get(uri, this::readResourceEntries).stream(); + } catch (ExecutionException e) { + throw new LocationException(e); + } + } + + abstract Collection readResourceEntries(); + + @Override + public int hashCode() { + return Objects.hash(uri); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + Location other = (Location) obj; + return Objects.equals(this.uri, other.uri); + } + + @Override + public String toString() { + return "Location{uri=" + uri + '}'; + } + + @PublicAPI(usage = ACCESS) + public static Location of(URL url) { + return of(toURI(url)); + } + + @PublicAPI(usage = ACCESS) + public static Location of(URI uri) { + uri = JarFileLocation.ensureJarProtocol(uri); + for (Factory factory : factories.get()) { + if (factory.supports(uri.getScheme())) { + return factory.create(uri); + } + } + throw new UnsupportedUriSchemeException(uri); + } + + @PublicAPI(usage = ACCESS) + public static Location of(JarFile jar) { + return JarFileLocation.from(jar); + } + + @PublicAPI(usage = ACCESS) + public static Location of(Path path) { + return FilePathLocation.from(path.toUri()); + } + + static URI toURI(URL url) { + try { + return url.toURI(); + } catch (URISyntaxException e) { + throw new LocationException(e); + } + } + + interface Factory { + boolean supports(String scheme); + + Location create(URI uri); + } + + static class JarFileLocationFactory implements Factory { + @Override + public boolean supports(String scheme) { + return JarFileLocation.SCHEME.equals(scheme); + } + + @Override + public Location create(URI uri) { + return JarFileLocation.from(uri); + } + } + + static class FilePathLocationFactory implements Factory { + @Override + public boolean supports(String scheme) { + return FilePathLocation.SCHEME.equals(scheme); + } + + @Override + public Location create(URI uri) { + return FilePathLocation.from(uri); + } + } + + private static class JarFileLocation extends Location { + private static final String SCHEME = "jar"; + + private JarFileLocation(NormalizedUri uri) { + super(uri); + checkScheme(SCHEME, uri); + } + + static URI ensureJarProtocol(URI uri) { + return !SCHEME.equals(uri.getScheme()) && uri.getPath().endsWith(".jar") ? newJarUri(uri) : uri; + } + + static JarFileLocation from(URI uri) { + checkArgument(uri.toString().contains("!/"), "JAR URI must contain '!/'"); + return new JarFileLocation(NormalizedUri.from(uri)); + } + + static JarFileLocation from(JarFile jar) { + return from(newJarUri(FilePathLocation.newFileUri(jar.getName()))); + } + + private static URI newJarUri(URI uri) { + return URI.create(String.format("%s:%s!/", SCHEME, uri)); + } + + @Override + ClassFileSource asClassFileSource(ImportOptions importOptions) { + try { + String uriString = uri.toString(); + int index = uriString.lastIndexOf("!/"); + String[] parts = { uriString.substring(0, index), uriString.substring(index + 2) }; + return new ClassFileSource.FromJar(new URL(parts[0] + "!/"), parts[1], importOptions); + } catch (IOException e) { + throw new LocationException(e); + } + } + + @Override + public boolean isJar() { + return true; + } + + @Override + public boolean isArchive() { + return true; + } + + @Override + Collection readResourceEntries() { + File file = getFileOfJar(); + if (!file.exists()) { + return emptySet(); + } + + return readJarFileContent(file); + } + + private File getFileOfJar() { + return new File(URI.create(uri.toString() + .replaceAll("^" + SCHEME + ":", "") + .replaceAll("!/.*", ""))); + } + + private Collection readJarFileContent(File fileOfJar) { + ImmutableList.Builder result = ImmutableList.builder(); + String prefix = uri.toString().replaceAll(".*!/", ""); + try (JarFile jarFile = new JarFile(fileOfJar)) { + result.addAll(readEntries(prefix, jarFile)); + } catch (IOException e) { + throw new LocationException(e); + } + return result.build(); + } + + private List readEntries(String prefix, JarFile jarFile) { + List result = new ArrayList<>(); + Enumeration entries = jarFile.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + if (entry.getName().startsWith(prefix) && entry.getName().endsWith(".class")) { + result.add(NormalizedResourceName.from(entry.getName())); + } + } + return result; + } + } + + private static class FilePathLocation extends Location { + private static final String SCHEME = "file"; + + private FilePathLocation(NormalizedUri uri) { + super(uri); + checkScheme(SCHEME, uri); + } + + static URI newFileUri(String fileName) { + return new File(fileName).toURI(); + } + + static FilePathLocation from(URI uri) { + return new FilePathLocation(NormalizedUri.from(uri)); + } + + @Override + ClassFileSource asClassFileSource(ImportOptions importOptions) { + return new ClassFileSource.FromFilePath(Paths.get(uri.toURI()), importOptions); + } + + @Override + public boolean isJar() { + return false; + } + + @Override + public boolean isArchive() { + return false; + } + + @Override + Collection readResourceEntries() { + try { + return getAllFilesBeneath(uri); + } catch (IOException e) { + throw new LocationException(e); + } + } + + private List getAllFilesBeneath(NormalizedUri uri) throws IOException { + File rootFile = new File(uri.toURI()); + if (!rootFile.exists()) { + return emptyList(); + } + + return getAllFilesBeneath(rootFile.toPath()); + } + + private List getAllFilesBeneath(Path root) throws IOException { + ImmutableList.Builder result = ImmutableList.builder(); + Files.walkFileTree(root, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + if (file.toString().endsWith(".class")) { + result.add(NormalizedResourceName.from(root.relativize(file).toString())); + } + return FileVisitResult.CONTINUE; + } + }); + return result.build(); + } + } +}