Skip to content

Commit 4711063

Browse files
committed
Fix context scanner
1 parent 04f083a commit 4711063

File tree

5 files changed

+426
-93
lines changed

5 files changed

+426
-93
lines changed

build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ group = 'com.coditory.quark'
1212
description = 'Coditory Quark Context Library'
1313

1414
dependencies {
15-
api 'org.slf4j:slf4j-api:2.0.3'
15+
api 'org.slf4j:slf4j-api:2.0.5'
1616
api 'org.jetbrains:annotations:23.0.0'
1717
api 'com.coditory.quark:quark-eventbus:0.0.5'
18-
testImplementation 'ch.qos.logback:logback-classic:1.4.4'
18+
testImplementation 'ch.qos.logback:logback-classic:1.4.5'
1919
testImplementation 'org.spockframework:spock-core:2.3-groovy-4.0'
2020
testImplementation 'org.skyscreamer:jsonassert:1.5.1'
2121
}
Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
package com.coditory.quark.context;
2+
3+
import org.slf4j.Logger;
4+
import org.slf4j.LoggerFactory;
5+
6+
import java.io.File;
7+
import java.io.IOException;
8+
import java.net.MalformedURLException;
9+
import java.net.URISyntaxException;
10+
import java.net.URL;
11+
import java.net.URLClassLoader;
12+
import java.util.ArrayList;
13+
import java.util.Arrays;
14+
import java.util.Enumeration;
15+
import java.util.HashSet;
16+
import java.util.LinkedHashMap;
17+
import java.util.LinkedHashSet;
18+
import java.util.List;
19+
import java.util.Map;
20+
import java.util.Set;
21+
import java.util.jar.Attributes;
22+
import java.util.jar.JarEntry;
23+
import java.util.jar.JarFile;
24+
import java.util.jar.Manifest;
25+
26+
import static java.util.Collections.unmodifiableList;
27+
import static java.util.Collections.unmodifiableMap;
28+
import static java.util.Collections.unmodifiableSet;
29+
import static java.util.Objects.requireNonNull;
30+
import static java.util.stream.Collectors.toSet;
31+
32+
final class ClassPath {
33+
private static final Logger logger = LoggerFactory.getLogger(ClassPath.class.getName());
34+
private static final String CLASS_FILE_NAME_EXTENSION = ".class";
35+
private static final String PATH_SEPARATOR_SYS_PROP = System.getProperty("path.separator");
36+
private static final String JAVA_CLASS_PATH_SYS_PROP = System.getProperty("java.class.path");
37+
38+
private final Set<ResourceInfo> resources;
39+
40+
private ClassPath(Set<ResourceInfo> resources) {
41+
this.resources = resources;
42+
}
43+
44+
public static ClassPath from(ClassLoader classloader) throws IOException {
45+
requireNonNull(classloader);
46+
Set<LocationInfo> locations = locationsFrom(classloader);
47+
Set<File> scanned = new LinkedHashSet<>();
48+
for (LocationInfo location : locations) {
49+
scanned.add(location.file());
50+
}
51+
Set<ResourceInfo> resources = new LinkedHashSet<>();
52+
for (LocationInfo location : locations) {
53+
resources.addAll(location.scanResources(scanned));
54+
}
55+
return new ClassPath(resources);
56+
}
57+
58+
public Set<ClassInfo> getTopLevelClasses() {
59+
return resources.stream()
60+
.filter(r -> r instanceof ClassInfo)
61+
.map(r -> (ClassInfo) r)
62+
.filter(ClassInfo::isTopLevel)
63+
.collect(toSet());
64+
}
65+
66+
public Set<ClassInfo> getTopLevelClassesRecursive(String packageName) {
67+
requireNonNull(packageName);
68+
String packagePrefix = packageName + '.';
69+
Set<ClassInfo> classes = new LinkedHashSet<>();
70+
for (ClassInfo classInfo : getTopLevelClasses()) {
71+
if (classInfo.getName().startsWith(packagePrefix)) {
72+
classes.add(classInfo);
73+
}
74+
}
75+
return unmodifiableSet(classes);
76+
}
77+
78+
public static class ResourceInfo {
79+
private final File file;
80+
private final String resourceName;
81+
82+
final ClassLoader loader;
83+
84+
static ResourceInfo of(File file, String resourceName, ClassLoader loader) {
85+
return resourceName.endsWith(CLASS_FILE_NAME_EXTENSION)
86+
? new ClassInfo(file, resourceName, loader)
87+
: new ResourceInfo(file, resourceName, loader);
88+
}
89+
90+
ResourceInfo(File file, String resourceName, ClassLoader loader) {
91+
this.file = requireNonNull(file);
92+
this.resourceName = requireNonNull(resourceName);
93+
this.loader = requireNonNull(loader);
94+
}
95+
96+
public File getFile() {
97+
return file;
98+
}
99+
100+
@Override
101+
public int hashCode() {
102+
return resourceName.hashCode();
103+
}
104+
105+
@Override
106+
public boolean equals(Object obj) {
107+
if (obj instanceof ResourceInfo that) {
108+
return resourceName.equals(that.resourceName)
109+
&& loader == that.loader;
110+
}
111+
return false;
112+
}
113+
114+
@Override
115+
public String toString() {
116+
return resourceName;
117+
}
118+
}
119+
120+
public static final class ClassInfo extends ResourceInfo {
121+
private final String className;
122+
123+
ClassInfo(File file, String resourceName, ClassLoader loader) {
124+
super(file, resourceName, loader);
125+
this.className = getClassName(resourceName);
126+
}
127+
128+
public String getName() {
129+
return className;
130+
}
131+
132+
public boolean isTopLevel() {
133+
return className.indexOf('$') == -1;
134+
}
135+
136+
@Override
137+
public String toString() {
138+
return className;
139+
}
140+
}
141+
142+
static Set<LocationInfo> locationsFrom(ClassLoader classloader) {
143+
Set<LocationInfo> locations = new LinkedHashSet<>();
144+
for (Map.Entry<File, ClassLoader> entry : getClassPathEntries(classloader).entrySet()) {
145+
locations.add(new LocationInfo(entry.getKey(), entry.getValue()));
146+
}
147+
return unmodifiableSet(locations);
148+
}
149+
150+
static final class LocationInfo {
151+
final File home;
152+
private final ClassLoader classloader;
153+
154+
LocationInfo(File home, ClassLoader classloader) {
155+
this.home = requireNonNull(home);
156+
this.classloader = requireNonNull(classloader);
157+
}
158+
159+
public File file() {
160+
return home;
161+
}
162+
163+
public Set<ResourceInfo> scanResources(Set<File> scannedFiles) throws IOException {
164+
Set<ResourceInfo> resources = new LinkedHashSet<>();
165+
scannedFiles.add(home);
166+
scan(home, scannedFiles, resources);
167+
return unmodifiableSet(resources);
168+
}
169+
170+
private void scan(File file, Set<File> scannedUris, Set<ResourceInfo> result)
171+
throws IOException {
172+
try {
173+
if (!file.exists()) {
174+
return;
175+
}
176+
} catch (SecurityException e) {
177+
logger.warn("Cannot access " + file + ": " + e);
178+
return;
179+
}
180+
if (file.isDirectory()) {
181+
scanDirectory(file, result);
182+
} else {
183+
scanJar(file, scannedUris, result);
184+
}
185+
}
186+
187+
private void scanJar(File file, Set<File> scannedUris, Set<ResourceInfo> result) throws IOException {
188+
JarFile jarFile;
189+
try {
190+
jarFile = new JarFile(file);
191+
} catch (IOException e) {
192+
// Not a jar file
193+
return;
194+
}
195+
try {
196+
for (File path : getClassPathFromManifest(file, jarFile.getManifest())) {
197+
// We only scan each file once independent of the classloader that file might be
198+
// associated with.
199+
if (scannedUris.add(path.getCanonicalFile())) {
200+
scan(path, scannedUris, result);
201+
}
202+
}
203+
scanJarFile(jarFile, result);
204+
} finally {
205+
try {
206+
jarFile.close();
207+
} catch (IOException ignored) { // similar to try-with-resources, but don't fail scanning
208+
}
209+
}
210+
}
211+
212+
private void scanJarFile(JarFile file, Set<ResourceInfo> result) {
213+
Enumeration<JarEntry> entries = file.entries();
214+
while (entries.hasMoreElements()) {
215+
JarEntry entry = entries.nextElement();
216+
if (entry.isDirectory() || entry.getName().equals(JarFile.MANIFEST_NAME)) {
217+
continue;
218+
}
219+
result.add(ResourceInfo.of(new File(file.getName()), entry.getName(), classloader));
220+
}
221+
}
222+
223+
private void scanDirectory(File directory, Set<ResourceInfo> result)
224+
throws IOException {
225+
Set<File> currentPath = new HashSet<>();
226+
currentPath.add(directory.getCanonicalFile());
227+
scanDirectory(directory, "", currentPath, result);
228+
}
229+
230+
private void scanDirectory(
231+
File directory,
232+
String packagePrefix,
233+
Set<File> currentPath,
234+
Set<ResourceInfo> builder
235+
) throws IOException {
236+
File[] files = directory.listFiles();
237+
if (files == null) {
238+
logger.warn("Cannot read directory " + directory);
239+
// IO error, just skip the directory
240+
return;
241+
}
242+
for (File f : files) {
243+
String name = f.getName();
244+
if (f.isDirectory()) {
245+
File deref = f.getCanonicalFile();
246+
if (currentPath.add(deref)) {
247+
scanDirectory(deref, packagePrefix + name + "/", currentPath, builder);
248+
currentPath.remove(deref);
249+
}
250+
} else {
251+
String resourceName = packagePrefix + name;
252+
if (!resourceName.equals(JarFile.MANIFEST_NAME)) {
253+
builder.add(ResourceInfo.of(f, resourceName, classloader));
254+
}
255+
}
256+
}
257+
}
258+
259+
@Override
260+
public boolean equals(Object obj) {
261+
if (obj instanceof LocationInfo that) {
262+
return home.equals(that.home) && classloader.equals(that.classloader);
263+
}
264+
return false;
265+
}
266+
267+
@Override
268+
public int hashCode() {
269+
return home.hashCode();
270+
}
271+
272+
@Override
273+
public String toString() {
274+
return home.toString();
275+
}
276+
}
277+
278+
static Set<File> getClassPathFromManifest(File jarFile, Manifest manifest) {
279+
if (manifest == null) {
280+
return Set.of();
281+
}
282+
Set<File> result = new LinkedHashSet<>();
283+
String classpathAttribute = manifest
284+
.getMainAttributes()
285+
.getValue(Attributes.Name.CLASS_PATH.toString());
286+
if (classpathAttribute != null) {
287+
for (String path : classpathAttribute.split(" ")) {
288+
if (path.isBlank()) {
289+
continue;
290+
}
291+
URL url;
292+
try {
293+
url = getClassPathEntry(jarFile, path);
294+
} catch (MalformedURLException e) {
295+
// Ignore bad entry
296+
logger.warn("Invalid Class-Path entry: " + path);
297+
continue;
298+
}
299+
if (url.getProtocol().equals("file")) {
300+
result.add(toFile(url));
301+
}
302+
}
303+
}
304+
return unmodifiableSet(result);
305+
}
306+
307+
static Map<File, ClassLoader> getClassPathEntries(ClassLoader classloader) {
308+
LinkedHashMap<File, ClassLoader> entries = new LinkedHashMap<>();
309+
// Search parent first, since it's the order ClassLoader#loadClass() uses.
310+
ClassLoader parent = classloader.getParent();
311+
if (parent != null) {
312+
entries.putAll(getClassPathEntries(parent));
313+
}
314+
for (URL url : getClassLoaderUrls(classloader)) {
315+
if (url.getProtocol().equals("file")) {
316+
File file = toFile(url);
317+
if (!entries.containsKey(file)) {
318+
entries.put(file, classloader);
319+
}
320+
}
321+
}
322+
return unmodifiableMap(entries);
323+
}
324+
325+
private static List<URL> getClassLoaderUrls(ClassLoader classloader) {
326+
if (classloader instanceof URLClassLoader) {
327+
return Arrays.asList(((URLClassLoader) classloader).getURLs());
328+
}
329+
if (classloader.equals(ClassLoader.getSystemClassLoader())) {
330+
return parseJavaClassPath();
331+
}
332+
return List.of();
333+
}
334+
335+
private static List<URL> parseJavaClassPath() {
336+
List<URL> urls = new ArrayList<>();
337+
for (String entry : JAVA_CLASS_PATH_SYS_PROP.split(PATH_SEPARATOR_SYS_PROP)) {
338+
try {
339+
try {
340+
urls.add(new File(entry).toURI().toURL());
341+
} catch (SecurityException e) { // File.toURI checks to see if the file is a directory
342+
urls.add(new URL("file", null, new File(entry).getAbsolutePath()));
343+
}
344+
} catch (MalformedURLException e) {
345+
logger.warn("Malformed classpath entry: " + entry, e);
346+
}
347+
}
348+
return unmodifiableList(urls);
349+
}
350+
351+
private static URL getClassPathEntry(File jarFile, String path) throws MalformedURLException {
352+
return new URL(jarFile.toURI().toURL(), path);
353+
}
354+
355+
private static String getClassName(String filename) {
356+
int classNameEnd = filename.length() - CLASS_FILE_NAME_EXTENSION.length();
357+
return filename.substring(0, classNameEnd).replace('/', '.');
358+
}
359+
360+
private static File toFile(URL url) {
361+
try {
362+
return new File(url.toURI()); // Accepts escaped characters like %20.
363+
} catch (URISyntaxException e) { // URL.toURI() doesn't escape chars.
364+
return new File(url.getPath()); // Accepts non-escaped chars like space.
365+
}
366+
}
367+
}

0 commit comments

Comments
 (0)