Polish CLI Jar generation
This commit is contained in:
parent
d648603634
commit
208bf8fc96
|
@ -103,12 +103,6 @@
|
||||||
<artifactId>aether-util</artifactId>
|
<artifactId>aether-util</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- Provided -->
|
<!-- Provided -->
|
||||||
<dependency>
|
|
||||||
<groupId>${project.groupId}</groupId>
|
|
||||||
<artifactId>spring-boot-loader</artifactId>
|
|
||||||
<version>${project.version}</version>
|
|
||||||
<scope>provided</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.codehaus.groovy</groupId>
|
<groupId>org.codehaus.groovy</groupId>
|
||||||
<artifactId>groovy-templates</artifactId>
|
<artifactId>groovy-templates</artifactId>
|
||||||
|
|
|
@ -19,13 +19,20 @@ package org.springframework.boot.cli;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
import org.springframework.boot.cli.command.jar.JarCommand;
|
||||||
import org.springframework.boot.cli.infrastructure.CommandLineInvoker;
|
import org.springframework.boot.cli.infrastructure.CommandLineInvoker;
|
||||||
import org.springframework.boot.cli.infrastructure.CommandLineInvoker.Invocation;
|
import org.springframework.boot.cli.infrastructure.CommandLineInvoker.Invocation;
|
||||||
|
import org.springframework.boot.cli.util.JavaExecutable;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.containsString;
|
||||||
|
import static org.hamcrest.Matchers.equalTo;
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertThat;
|
||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Integration test for {@link JarCommand}.
|
||||||
|
*
|
||||||
* @author Andy Wilkinson
|
* @author Andy Wilkinson
|
||||||
*/
|
*/
|
||||||
public class JarCommandIT {
|
public class JarCommandIT {
|
||||||
|
@ -37,20 +44,18 @@ public class JarCommandIT {
|
||||||
public void noArguments() throws Exception {
|
public void noArguments() throws Exception {
|
||||||
Invocation invocation = this.cli.invoke("jar");
|
Invocation invocation = this.cli.invoke("jar");
|
||||||
invocation.await();
|
invocation.await();
|
||||||
assertEquals(0, invocation.getStandardOutput().length());
|
assertThat(invocation.getStandardOutput(), equalTo(""));
|
||||||
assertEquals(
|
assertThat(invocation.getErrorOutput(), containsString("The name of the "
|
||||||
"The name of the resulting jar and at least one source file must be specified",
|
+ "resulting jar and at least one source file must be specified"));
|
||||||
invocation.getErrorOutput().trim());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void noSources() throws Exception {
|
public void noSources() throws Exception {
|
||||||
Invocation invocation = this.cli.invoke("jar", "test-app.jar");
|
Invocation invocation = this.cli.invoke("jar", "test-app.jar");
|
||||||
invocation.await();
|
invocation.await();
|
||||||
assertEquals(0, invocation.getStandardOutput().length());
|
assertThat(invocation.getStandardOutput(), equalTo(""));
|
||||||
assertEquals(
|
assertThat(invocation.getErrorOutput(), containsString("The name of the "
|
||||||
"The name of the resulting jar and at least one source file must be specified",
|
+ "resulting jar and at least one source file must be specified"));
|
||||||
invocation.getErrorOutput().trim());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -62,14 +67,13 @@ public class JarCommandIT {
|
||||||
assertEquals(0, invocation.getErrorOutput().length());
|
assertEquals(0, invocation.getErrorOutput().length());
|
||||||
assertTrue(jar.exists());
|
assertTrue(jar.exists());
|
||||||
|
|
||||||
ProcessBuilder builder = new ProcessBuilder(System.getProperty("java.home")
|
Process process = new JavaExecutable().processBuilder("-jar",
|
||||||
+ "/bin/java", "-jar", jar.getAbsolutePath());
|
jar.getAbsolutePath()).start();
|
||||||
Process process = builder.start();
|
invocation = new Invocation(process);
|
||||||
Invocation appInvocation = new Invocation(process);
|
invocation.await();
|
||||||
appInvocation.await();
|
|
||||||
|
|
||||||
assertEquals(0, appInvocation.getErrorOutput().length());
|
assertThat(invocation.getErrorOutput(), equalTo(""));
|
||||||
assertTrue(appInvocation.getStandardOutput().contains("Hello World!"));
|
assertThat(invocation.getStandardOutput(), containsString("Hello World!"));
|
||||||
assertTrue(appInvocation.getStandardOutput().contains("/static/test.txt"));
|
assertThat(invocation.getStandardOutput(), containsString("/static/test.txt"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,20 +18,17 @@ package org.springframework.boot.cli.command.jar;
|
||||||
|
|
||||||
import groovy.lang.Grab;
|
import groovy.lang.Grab;
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileInputStream;
|
import java.io.FileInputStream;
|
||||||
import java.io.FileNotFoundException;
|
import java.io.FileNotFoundException;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URISyntaxException;
|
import java.net.URISyntaxException;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Map.Entry;
|
|
||||||
import java.util.jar.Manifest;
|
import java.util.jar.Manifest;
|
||||||
|
|
||||||
import joptsimple.OptionSet;
|
import joptsimple.OptionSet;
|
||||||
|
@ -50,7 +47,6 @@ import org.springframework.boot.cli.command.OptionParsingCommand;
|
||||||
import org.springframework.boot.cli.command.SourceOptions;
|
import org.springframework.boot.cli.command.SourceOptions;
|
||||||
import org.springframework.boot.cli.command.jar.ResourceMatcher.MatchedResource;
|
import org.springframework.boot.cli.command.jar.ResourceMatcher.MatchedResource;
|
||||||
import org.springframework.boot.cli.compiler.GroovyCompiler;
|
import org.springframework.boot.cli.compiler.GroovyCompiler;
|
||||||
import org.springframework.boot.cli.compiler.GroovyCompiler.CompilationCallback;
|
|
||||||
import org.springframework.boot.cli.compiler.GroovyCompilerConfiguration;
|
import org.springframework.boot.cli.compiler.GroovyCompilerConfiguration;
|
||||||
import org.springframework.boot.cli.compiler.GroovyCompilerConfigurationAdapter;
|
import org.springframework.boot.cli.compiler.GroovyCompilerConfigurationAdapter;
|
||||||
import org.springframework.boot.cli.compiler.RepositoryConfigurationFactory;
|
import org.springframework.boot.cli.compiler.RepositoryConfigurationFactory;
|
||||||
|
@ -59,12 +55,13 @@ import org.springframework.boot.cli.jar.PackagedSpringApplicationLauncher;
|
||||||
import org.springframework.boot.loader.tools.JarWriter;
|
import org.springframework.boot.loader.tools.JarWriter;
|
||||||
import org.springframework.boot.loader.tools.Layout;
|
import org.springframework.boot.loader.tools.Layout;
|
||||||
import org.springframework.boot.loader.tools.Layouts;
|
import org.springframework.boot.loader.tools.Layouts;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.Assert;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@link Command} to create a self-contained executable jar file from a CLI application
|
* {@link Command} to create a self-contained executable jar file from a CLI application
|
||||||
*
|
*
|
||||||
* @author Andy Wilkinson
|
* @author Andy Wilkinson
|
||||||
|
* @author Phillip Webb
|
||||||
*/
|
*/
|
||||||
public class JarCommand extends OptionParsingCommand {
|
public class JarCommand extends OptionParsingCommand {
|
||||||
|
|
||||||
|
@ -77,9 +74,8 @@ public class JarCommand extends OptionParsingCommand {
|
||||||
private static final Layout LAYOUT = new Layouts.Jar();
|
private static final Layout LAYOUT = new Layouts.Jar();
|
||||||
|
|
||||||
public JarCommand() {
|
public JarCommand() {
|
||||||
super(
|
super("jar", "Create a self-contained "
|
||||||
"jar",
|
+ "executable jar file from a Spring Groovy script",
|
||||||
"Create a self-contained executable jar file from a Spring Groovy script",
|
|
||||||
new JarOptionHandler());
|
new JarOptionHandler());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -110,156 +106,163 @@ public class JarCommand extends OptionParsingCommand {
|
||||||
protected void run(OptionSet options) throws Exception {
|
protected void run(OptionSet options) throws Exception {
|
||||||
List<?> nonOptionArguments = new ArrayList<Object>(
|
List<?> nonOptionArguments = new ArrayList<Object>(
|
||||||
options.nonOptionArguments());
|
options.nonOptionArguments());
|
||||||
if (nonOptionArguments.size() < 2) {
|
Assert.isTrue(nonOptionArguments.size() >= 2,
|
||||||
throw new IllegalStateException(
|
"The name of the resulting jar and at least one source file must be specified");
|
||||||
"The name of the resulting jar and at least one source file must be specified");
|
|
||||||
}
|
|
||||||
|
|
||||||
File output = new File((String) nonOptionArguments.remove(0));
|
File output = new File((String) nonOptionArguments.remove(0));
|
||||||
if (output.exists() && !output.delete()) {
|
deleteIfExists(output);
|
||||||
throw new IllegalStateException(
|
|
||||||
"Failed to delete existing application jar file "
|
|
||||||
+ output.getPath());
|
|
||||||
}
|
|
||||||
|
|
||||||
GroovyCompiler groovyCompiler = createCompiler(options);
|
GroovyCompiler compiler = createCompiler(options);
|
||||||
|
|
||||||
List<URL> classpathUrls = Arrays.asList(groovyCompiler.getLoader().getURLs());
|
List<URL> classpath = getClassPathUrls(compiler);
|
||||||
List<MatchedResource> classpathEntries = findClasspathEntries(classpathUrls,
|
List<MatchedResource> classpathEntries = findMatchingClasspathEntries(
|
||||||
options);
|
classpath, options);
|
||||||
|
|
||||||
final Map<String, byte[]> compiledClasses = new HashMap<String, byte[]>();
|
String[] sources = new SourceOptions(nonOptionArguments).getSourcesArray();
|
||||||
groovyCompiler.compile(new CompilationCallback() {
|
Class<?>[] compiledClasses = compiler.compile(sources);
|
||||||
|
|
||||||
@Override
|
List<URL> dependencies = getClassPathUrls(compiler);
|
||||||
public void byteCodeGenerated(byte[] byteCode, ClassNode classNode)
|
dependencies.removeAll(classpath);
|
||||||
throws IOException {
|
|
||||||
String className = classNode.getName();
|
|
||||||
compiledClasses.put(className, byteCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
}, new SourceOptions(nonOptionArguments).getSourcesArray());
|
writeJar(output, compiledClasses, classpathEntries, dependencies);
|
||||||
|
}
|
||||||
|
|
||||||
List<URL> dependencyUrls = new ArrayList<URL>(Arrays.asList(groovyCompiler
|
private void deleteIfExists(File file) {
|
||||||
.getLoader().getURLs()));
|
if (file.exists() && !file.delete()) {
|
||||||
dependencyUrls.removeAll(classpathUrls);
|
throw new IllegalStateException("Failed to delete existing file "
|
||||||
|
+ file.getPath());
|
||||||
JarWriter jarWriter = new JarWriter(output);
|
|
||||||
|
|
||||||
try {
|
|
||||||
jarWriter.writeManifest(createManifest(compiledClasses));
|
|
||||||
addDependencies(jarWriter, dependencyUrls);
|
|
||||||
addClasspathEntries(jarWriter, classpathEntries);
|
|
||||||
addApplicationClasses(jarWriter, compiledClasses);
|
|
||||||
String runnerClassName = getClassFile(PackagedSpringApplicationLauncher.class
|
|
||||||
.getName());
|
|
||||||
jarWriter.writeEntry(runnerClassName,
|
|
||||||
getClass().getResourceAsStream("/" + runnerClassName));
|
|
||||||
jarWriter.writeLoaderClasses();
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
jarWriter.close();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private GroovyCompiler createCompiler(OptionSet options) {
|
private GroovyCompiler createCompiler(OptionSet options) {
|
||||||
List<RepositoryConfiguration> repositoryConfiguration = RepositoryConfigurationFactory
|
List<RepositoryConfiguration> repositoryConfiguration = RepositoryConfigurationFactory
|
||||||
.createDefaultRepositoryConfiguration();
|
.createDefaultRepositoryConfiguration();
|
||||||
|
|
||||||
GroovyCompilerConfiguration configuration = new GroovyCompilerConfigurationAdapter(
|
GroovyCompilerConfiguration configuration = new GroovyCompilerConfigurationAdapter(
|
||||||
options, this, repositoryConfiguration);
|
options, this, repositoryConfiguration);
|
||||||
|
|
||||||
GroovyCompiler groovyCompiler = new GroovyCompiler(configuration);
|
GroovyCompiler groovyCompiler = new GroovyCompiler(configuration);
|
||||||
groovyCompiler.getAstTransformations().add(0, new ASTTransformation() {
|
groovyCompiler.getAstTransformations().add(0, new GrabAnnotationTransform());
|
||||||
|
|
||||||
@Override
|
|
||||||
public void visit(ASTNode[] nodes, SourceUnit source) {
|
|
||||||
for (ASTNode node : nodes) {
|
|
||||||
if (node instanceof ModuleNode) {
|
|
||||||
ModuleNode module = (ModuleNode) node;
|
|
||||||
for (ClassNode classNode : module.getClasses()) {
|
|
||||||
AnnotationNode annotation = new AnnotationNode(
|
|
||||||
new ClassNode(Grab.class));
|
|
||||||
annotation.addMember("value", new ConstantExpression(
|
|
||||||
"groovy"));
|
|
||||||
classNode.addAnnotation(annotation);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return groovyCompiler;
|
return groovyCompiler;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<MatchedResource> findClasspathEntries(List<URL> classpath,
|
private List<URL> getClassPathUrls(GroovyCompiler compiler) {
|
||||||
|
return new ArrayList<URL>(Arrays.asList(compiler.getLoader().getURLs()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<MatchedResource> findMatchingClasspathEntries(List<URL> classpath,
|
||||||
OptionSet options) throws IOException {
|
OptionSet options) throws IOException {
|
||||||
ResourceMatcher resourceCollector = new ResourceMatcher(
|
ResourceMatcher matcher = new ResourceMatcher(
|
||||||
options.valuesOf(this.includeOption),
|
options.valuesOf(this.includeOption),
|
||||||
options.valuesOf(this.excludeOption));
|
options.valuesOf(this.excludeOption));
|
||||||
|
|
||||||
List<File> roots = new ArrayList<File>();
|
List<File> roots = new ArrayList<File>();
|
||||||
|
|
||||||
for (URL classpathEntry : classpath) {
|
for (URL classpathEntry : classpath) {
|
||||||
roots.add(new File(URI.create(classpathEntry.toString())));
|
roots.add(new File(URI.create(classpathEntry.toString())));
|
||||||
}
|
}
|
||||||
|
return matcher.find(roots);
|
||||||
return resourceCollector.matchResources(roots);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Manifest createManifest(final Map<String, byte[]> compiledClasses) {
|
private void writeJar(File file, Class<?>[] compiledClasses,
|
||||||
|
List<MatchedResource> classpathEntries, List<URL> dependencies)
|
||||||
|
throws FileNotFoundException, IOException, URISyntaxException {
|
||||||
|
JarWriter writer = new JarWriter(file);
|
||||||
|
try {
|
||||||
|
addManifest(writer, compiledClasses);
|
||||||
|
addCliClasses(writer);
|
||||||
|
for (Class<?> compiledClass : compiledClasses) {
|
||||||
|
addClass(writer, compiledClass);
|
||||||
|
}
|
||||||
|
addClasspathEntries(writer, classpathEntries);
|
||||||
|
addDependencies(writer, dependencies);
|
||||||
|
writer.writeLoaderClasses();
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
writer.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addManifest(JarWriter writer, Class<?>[] compiledClasses)
|
||||||
|
throws IOException {
|
||||||
Manifest manifest = new Manifest();
|
Manifest manifest = new Manifest();
|
||||||
manifest.getMainAttributes().putValue("Manifest-Version", "1.0");
|
manifest.getMainAttributes().putValue("Manifest-Version", "1.0");
|
||||||
manifest.getMainAttributes()
|
|
||||||
.putValue(
|
|
||||||
"Application-Classes",
|
|
||||||
StringUtils.collectionToCommaDelimitedString(compiledClasses
|
|
||||||
.keySet()));
|
|
||||||
manifest.getMainAttributes().putValue("Main-Class",
|
manifest.getMainAttributes().putValue("Main-Class",
|
||||||
LAYOUT.getLauncherClassName());
|
LAYOUT.getLauncherClassName());
|
||||||
manifest.getMainAttributes().putValue("Start-Class",
|
manifest.getMainAttributes().putValue("Start-Class",
|
||||||
PackagedSpringApplicationLauncher.class.getName());
|
PackagedSpringApplicationLauncher.class.getName());
|
||||||
return manifest;
|
manifest.getMainAttributes().putValue(
|
||||||
|
PackagedSpringApplicationLauncher.SOURCE_MANIFEST_ENTRY,
|
||||||
|
commaDelimitedClassNames(compiledClasses));
|
||||||
|
writer.writeManifest(manifest);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void addDependencies(JarWriter jarWriter, List<URL> urls)
|
private String commaDelimitedClassNames(Class<?>[] classes) {
|
||||||
throws IOException, URISyntaxException, FileNotFoundException {
|
StringBuilder builder = new StringBuilder();
|
||||||
for (URL url : urls) {
|
for (int i = 0; i < classes.length; i++) {
|
||||||
addDependency(jarWriter, new File(url.toURI()));
|
builder.append(i == 0 ? "" : ",");
|
||||||
|
builder.append(classes[i].getName());
|
||||||
}
|
}
|
||||||
|
return builder.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void addDependency(JarWriter jarWriter, File dependency)
|
private void addCliClasses(JarWriter writer) throws IOException {
|
||||||
throws FileNotFoundException, IOException {
|
addClass(writer, PackagedSpringApplicationLauncher.class);
|
||||||
if (dependency.isFile()) {
|
|
||||||
jarWriter.writeNestedLibrary("lib/", dependency);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void addClasspathEntries(JarWriter jarWriter,
|
private void addClass(JarWriter writer, Class<?> sourceClass) throws IOException {
|
||||||
List<MatchedResource> classpathEntries) throws IOException {
|
String name = sourceClass.getName().replace(".", "/") + ".class";
|
||||||
for (MatchedResource classpathEntry : classpathEntries) {
|
InputStream stream = sourceClass.getResourceAsStream("/" + name);
|
||||||
if (classpathEntry.isRoot()) {
|
writer.writeEntry(name, stream);
|
||||||
addDependency(jarWriter, classpathEntry.getFile());
|
}
|
||||||
|
|
||||||
|
private void addClasspathEntries(JarWriter writer, List<MatchedResource> entries)
|
||||||
|
throws IOException {
|
||||||
|
for (MatchedResource entry : entries) {
|
||||||
|
if (entry.isRoot()) {
|
||||||
|
addDependency(writer, entry.getFile());
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
jarWriter.writeEntry(classpathEntry.getPath(), new FileInputStream(
|
writer.writeEntry(entry.getName(),
|
||||||
classpathEntry.getFile()));
|
new FileInputStream(entry.getFile()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void addApplicationClasses(JarWriter jarWriter,
|
private void addDependencies(JarWriter writer, List<URL> urls)
|
||||||
final Map<String, byte[]> compiledClasses) throws IOException {
|
throws IOException, URISyntaxException, FileNotFoundException {
|
||||||
|
for (URL url : urls) {
|
||||||
for (Entry<String, byte[]> entry : compiledClasses.entrySet()) {
|
addDependency(writer, new File(url.toURI()));
|
||||||
jarWriter.writeEntry(getClassFile(entry.getKey()),
|
|
||||||
new ByteArrayInputStream(entry.getValue()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getClassFile(String className) {
|
private void addDependency(JarWriter writer, File dependency)
|
||||||
return className.replace(".", "/") + ".class";
|
throws FileNotFoundException, IOException {
|
||||||
|
if (dependency.isFile()) {
|
||||||
|
writer.writeNestedLibrary("lib/", dependency);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link ASTTransformation} to change {@code @Grab} annotation values.
|
||||||
|
*/
|
||||||
|
private static class GrabAnnotationTransform implements ASTTransformation {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void visit(ASTNode[] nodes, SourceUnit source) {
|
||||||
|
for (ASTNode node : nodes) {
|
||||||
|
if (node instanceof ModuleNode) {
|
||||||
|
visitModule((ModuleNode) node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void visitModule(ModuleNode module) {
|
||||||
|
for (ClassNode classNode : module.getClasses()) {
|
||||||
|
AnnotationNode annotation = new AnnotationNode(new ClassNode(Grab.class));
|
||||||
|
annotation.addMember("value", new ConstantExpression("groovy"));
|
||||||
|
classNode.addAnnotation(annotation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,6 +28,7 @@ import java.util.List;
|
||||||
import org.springframework.core.io.DefaultResourceLoader;
|
import org.springframework.core.io.DefaultResourceLoader;
|
||||||
import org.springframework.core.io.FileSystemResource;
|
import org.springframework.core.io.FileSystemResource;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.core.io.ResourceLoader;
|
||||||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||||
import org.springframework.util.AntPathMatcher;
|
import org.springframework.util.AntPathMatcher;
|
||||||
|
|
||||||
|
@ -36,7 +37,7 @@ import org.springframework.util.AntPathMatcher;
|
||||||
*
|
*
|
||||||
* @author Andy Wilkinson
|
* @author Andy Wilkinson
|
||||||
*/
|
*/
|
||||||
final class ResourceMatcher {
|
class ResourceMatcher {
|
||||||
|
|
||||||
private final AntPathMatcher pathMatcher = new AntPathMatcher();
|
private final AntPathMatcher pathMatcher = new AntPathMatcher();
|
||||||
|
|
||||||
|
@ -49,110 +50,128 @@ final class ResourceMatcher {
|
||||||
this.excludes = excludes;
|
this.excludes = excludes;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<MatchedResource> matchResources(List<File> roots) throws IOException {
|
public List<MatchedResource> find(List<File> roots) throws IOException {
|
||||||
List<MatchedResource> matchedResources = new ArrayList<MatchedResource>();
|
List<MatchedResource> matchedResources = new ArrayList<MatchedResource>();
|
||||||
|
|
||||||
for (File root : roots) {
|
for (File root : roots) {
|
||||||
if (root.isFile()) {
|
if (root.isFile()) {
|
||||||
matchedResources.add(new MatchedResource(root));
|
matchedResources.add(new MatchedResource(root));
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
matchedResources.addAll(matchResources(root));
|
matchedResources.addAll(findInFolder(root));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return matchedResources;
|
return matchedResources;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<MatchedResource> matchResources(File root) throws IOException {
|
private List<MatchedResource> findInFolder(File folder) throws IOException {
|
||||||
List<MatchedResource> resources = new ArrayList<MatchedResource>();
|
List<MatchedResource> matchedResources = new ArrayList<MatchedResource>();
|
||||||
|
|
||||||
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(
|
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(
|
||||||
new ResourceCollectionResourceLoader(root));
|
new FolderResourceLoader(folder));
|
||||||
|
|
||||||
for (String include : this.includes) {
|
for (String include : this.includes) {
|
||||||
Resource[] candidates = resolver.getResources(include);
|
for (Resource candidate : resolver.getResources(include)) {
|
||||||
for (Resource candidate : candidates) {
|
|
||||||
File file = candidate.getFile();
|
File file = candidate.getFile();
|
||||||
if (file.isFile()) {
|
if (file.isFile()) {
|
||||||
MatchedResource matchedResource = new MatchedResource(root, file);
|
MatchedResource matchedResource = new MatchedResource(folder, file);
|
||||||
if (!isExcluded(matchedResource)) {
|
if (!isExcluded(matchedResource)) {
|
||||||
resources.add(matchedResource);
|
matchedResources.add(matchedResource);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return resources;
|
return matchedResources;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isExcluded(MatchedResource matchedResource) {
|
private boolean isExcluded(MatchedResource matchedResource) {
|
||||||
for (String exclude : this.excludes) {
|
for (String exclude : this.excludes) {
|
||||||
if (this.pathMatcher.match(exclude, matchedResource.getPath())) {
|
if (this.pathMatcher.match(exclude, matchedResource.getName())) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class ResourceCollectionResourceLoader extends
|
/**
|
||||||
DefaultResourceLoader {
|
* {@link ResourceLoader} to get load resource from a folder.
|
||||||
|
*/
|
||||||
|
private static class FolderResourceLoader extends DefaultResourceLoader {
|
||||||
|
|
||||||
private final File root;
|
private final File rootFolder;
|
||||||
|
|
||||||
ResourceCollectionResourceLoader(File root) throws MalformedURLException {
|
public FolderResourceLoader(File root) throws MalformedURLException {
|
||||||
super(new URLClassLoader(new URL[] { root.toURI().toURL() }) {
|
super(new FolderClassLoader(root));
|
||||||
@Override
|
this.rootFolder = root;
|
||||||
public Enumeration<URL> getResources(String name) throws IOException {
|
|
||||||
return findResources(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public URL getResource(String name) {
|
|
||||||
return findResource(name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.root = root;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Resource getResourceByPath(String path) {
|
protected Resource getResourceByPath(String path) {
|
||||||
return new FileSystemResource(new File(this.root, path));
|
return new FileSystemResource(new File(this.rootFolder, path));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static final class MatchedResource {
|
/**
|
||||||
|
* {@link ClassLoader} backed by a folder.
|
||||||
|
*/
|
||||||
|
private static class FolderClassLoader extends URLClassLoader {
|
||||||
|
|
||||||
|
public FolderClassLoader(File rootFolder) throws MalformedURLException {
|
||||||
|
super(new URL[] { rootFolder.toURI().toURL() });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Enumeration<URL> getResources(String name) throws IOException {
|
||||||
|
return findResources(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public URL getResource(String name) {
|
||||||
|
return findResource(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single matched resource.
|
||||||
|
*/
|
||||||
|
public static final class MatchedResource {
|
||||||
|
|
||||||
private final File file;
|
private final File file;
|
||||||
|
|
||||||
private final String path;
|
private final String name;
|
||||||
|
|
||||||
private final boolean root;
|
private final boolean root;
|
||||||
|
|
||||||
private MatchedResource(File resourceFile) {
|
private MatchedResource(File file) {
|
||||||
this(resourceFile, resourceFile.getName(), true);
|
this.name = file.getName();
|
||||||
|
this.file = file;
|
||||||
|
this.root = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private MatchedResource(File root, File resourceFile) {
|
private MatchedResource(File rootFolder, File file) {
|
||||||
this(resourceFile, resourceFile.getAbsolutePath().substring(
|
this.name = file.getAbsolutePath().substring(
|
||||||
root.getAbsolutePath().length() + 1), false);
|
rootFolder.getAbsolutePath().length() + 1);
|
||||||
|
this.file = file;
|
||||||
|
this.root = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private MatchedResource(File resourceFile, String path, boolean root) {
|
private MatchedResource(File resourceFile, String path, boolean root) {
|
||||||
this.file = resourceFile;
|
this.file = resourceFile;
|
||||||
this.path = path;
|
this.name = path;
|
||||||
this.root = root;
|
this.root = root;
|
||||||
}
|
}
|
||||||
|
|
||||||
File getFile() {
|
public String getName() {
|
||||||
|
return this.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public File getFile() {
|
||||||
return this.file;
|
return this.file;
|
||||||
}
|
}
|
||||||
|
|
||||||
String getPath() {
|
public boolean isRoot() {
|
||||||
return this.path;
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean isRoot() {
|
|
||||||
return this.root;
|
return this.root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -160,6 +179,7 @@ final class ResourceMatcher {
|
||||||
public String toString() {
|
public String toString() {
|
||||||
return this.file.getAbsolutePath();
|
return this.file.getAbsolutePath();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,8 +16,6 @@
|
||||||
|
|
||||||
package org.springframework.boot.cli.command.shell;
|
package org.springframework.boot.cli.command.shell;
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
@ -25,8 +23,7 @@ import java.util.List;
|
||||||
|
|
||||||
import org.springframework.boot.cli.command.Command;
|
import org.springframework.boot.cli.command.Command;
|
||||||
import org.springframework.boot.cli.command.OptionHelp;
|
import org.springframework.boot.cli.command.OptionHelp;
|
||||||
import org.springframework.util.Assert;
|
import org.springframework.boot.cli.util.JavaExecutable;
|
||||||
import org.springframework.util.StringUtils;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decorate an existing command to run it by forking the current java process.
|
* Decorate an existing command to run it by forking the current java process.
|
||||||
|
@ -40,7 +37,7 @@ class ForkProcessCommand extends RunProcessCommand {
|
||||||
private final Command command;
|
private final Command command;
|
||||||
|
|
||||||
public ForkProcessCommand(Command command) {
|
public ForkProcessCommand(Command command) {
|
||||||
super(getJavaCommand());
|
super(new JavaExecutable().toString());
|
||||||
this.command = command;
|
this.command = command;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,24 +77,4 @@ class ForkProcessCommand extends RunProcessCommand {
|
||||||
run(fullArgs);
|
run(fullArgs);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String getJavaCommand() {
|
|
||||||
String javaHome = System.getProperty("java.home");
|
|
||||||
Assert.state(StringUtils.hasLength(javaHome),
|
|
||||||
"Unable to find java command to fork process");
|
|
||||||
try {
|
|
||||||
return getJavaCommand(javaHome).getCanonicalPath();
|
|
||||||
}
|
|
||||||
catch (IOException ex) {
|
|
||||||
throw new IllegalStateException(ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static File getJavaCommand(String javaHome) {
|
|
||||||
File bin = new File(new File(javaHome), "bin");
|
|
||||||
File command = new File(bin, "java.exe");
|
|
||||||
command = (command.exists() ? command : new File(bin, "java"));
|
|
||||||
Assert.state(command.exists(), "Unable to find java in " + javaHome);
|
|
||||||
return command;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,7 +34,6 @@ import org.codehaus.groovy.ast.ClassNode;
|
||||||
import org.codehaus.groovy.classgen.GeneratorContext;
|
import org.codehaus.groovy.classgen.GeneratorContext;
|
||||||
import org.codehaus.groovy.control.CompilationFailedException;
|
import org.codehaus.groovy.control.CompilationFailedException;
|
||||||
import org.codehaus.groovy.control.CompilationUnit;
|
import org.codehaus.groovy.control.CompilationUnit;
|
||||||
import org.codehaus.groovy.control.CompilationUnit.ClassgenCallback;
|
|
||||||
import org.codehaus.groovy.control.CompilePhase;
|
import org.codehaus.groovy.control.CompilePhase;
|
||||||
import org.codehaus.groovy.control.CompilerConfiguration;
|
import org.codehaus.groovy.control.CompilerConfiguration;
|
||||||
import org.codehaus.groovy.control.Phases;
|
import org.codehaus.groovy.control.Phases;
|
||||||
|
@ -43,8 +42,6 @@ import org.codehaus.groovy.control.customizers.CompilationCustomizer;
|
||||||
import org.codehaus.groovy.control.customizers.ImportCustomizer;
|
import org.codehaus.groovy.control.customizers.ImportCustomizer;
|
||||||
import org.codehaus.groovy.transform.ASTTransformation;
|
import org.codehaus.groovy.transform.ASTTransformation;
|
||||||
import org.codehaus.groovy.transform.ASTTransformationVisitor;
|
import org.codehaus.groovy.transform.ASTTransformationVisitor;
|
||||||
import org.objectweb.asm.ClassVisitor;
|
|
||||||
import org.objectweb.asm.ClassWriter;
|
|
||||||
import org.springframework.boot.cli.compiler.grape.AetherGrapeEngine;
|
import org.springframework.boot.cli.compiler.grape.AetherGrapeEngine;
|
||||||
import org.springframework.boot.cli.compiler.grape.AetherGrapeEngineFactory;
|
import org.springframework.boot.cli.compiler.grape.AetherGrapeEngineFactory;
|
||||||
import org.springframework.boot.cli.compiler.grape.GrapeEngineInstaller;
|
import org.springframework.boot.cli.compiler.grape.GrapeEngineInstaller;
|
||||||
|
@ -120,6 +117,10 @@ public class GroovyCompiler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a mutable list of the {@link ASTTransformation}s to be applied during
|
||||||
|
* {@link #compile(String...)}.
|
||||||
|
*/
|
||||||
public List<ASTTransformation> getAstTransformations() {
|
public List<ASTTransformation> getAstTransformations() {
|
||||||
return this.transformations;
|
return this.transformations;
|
||||||
}
|
}
|
||||||
|
@ -210,42 +211,6 @@ public class GroovyCompiler {
|
||||||
return classes.toArray(new Class<?>[classes.size()]);
|
return classes.toArray(new Class<?>[classes.size()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void compile(final CompilationCallback callback, String... sources)
|
|
||||||
throws CompilationFailedException, IOException {
|
|
||||||
this.loader.clearCache();
|
|
||||||
|
|
||||||
CompilerConfiguration configuration = this.loader.getConfiguration();
|
|
||||||
|
|
||||||
final CompilationUnit compilationUnit = new CompilationUnit(configuration, null,
|
|
||||||
this.loader);
|
|
||||||
ClassgenCallback classgenCallback = new ClassgenCallback() {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void call(ClassVisitor writer, ClassNode node)
|
|
||||||
throws CompilationFailedException {
|
|
||||||
try {
|
|
||||||
callback.byteCodeGenerated(((ClassWriter) writer).toByteArray(), node);
|
|
||||||
}
|
|
||||||
catch (IOException ioe) {
|
|
||||||
throw new CompilationFailedException(Phases.CLASS_GENERATION,
|
|
||||||
compilationUnit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
compilationUnit.setClassgenCallback(classgenCallback);
|
|
||||||
|
|
||||||
for (String source : sources) {
|
|
||||||
List<String> paths = ResourceUtils.getUrls(source, this.loader);
|
|
||||||
for (String path : paths) {
|
|
||||||
compilationUnit.addSource(new URL(path));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addAstTransformations(compilationUnit);
|
|
||||||
|
|
||||||
compilationUnit.compile(Phases.CLASS_GENERATION);
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("rawtypes")
|
@SuppressWarnings("rawtypes")
|
||||||
private void addAstTransformations(CompilationUnit compilationUnit) {
|
private void addAstTransformations(CompilationUnit compilationUnit) {
|
||||||
LinkedList[] phaseOperations = getPhaseOperations(compilationUnit);
|
LinkedList[] phaseOperations = getPhaseOperations(compilationUnit);
|
||||||
|
@ -329,10 +294,4 @@ public class GroovyCompiler {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static interface CompilationCallback {
|
|
||||||
|
|
||||||
public void byteCodeGenerated(byte[] byteCode, ClassNode classNode)
|
|
||||||
throws IOException;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,6 +29,8 @@ import java.util.jar.Manifest;
|
||||||
*/
|
*/
|
||||||
public class PackagedSpringApplicationLauncher {
|
public class PackagedSpringApplicationLauncher {
|
||||||
|
|
||||||
|
public static final String SOURCE_MANIFEST_ENTRY = "Spring-Application-Source-Classes";
|
||||||
|
|
||||||
private static final String SPRING_APPLICATION_CLASS = "org.springframework.boot.SpringApplication";
|
private static final String SPRING_APPLICATION_CLASS = "org.springframework.boot.SpringApplication";
|
||||||
|
|
||||||
private void run(String[] args) throws Exception {
|
private void run(String[] args) throws Exception {
|
||||||
|
@ -42,7 +44,7 @@ public class PackagedSpringApplicationLauncher {
|
||||||
private Object[] getSources(URLClassLoader classLoader) throws Exception {
|
private Object[] getSources(URLClassLoader classLoader) throws Exception {
|
||||||
URL url = classLoader.findResource("META-INF/MANIFEST.MF");
|
URL url = classLoader.findResource("META-INF/MANIFEST.MF");
|
||||||
Manifest manifest = new Manifest(url.openStream());
|
Manifest manifest = new Manifest(url.openStream());
|
||||||
String attribute = manifest.getMainAttributes().getValue("Application-Classes");
|
String attribute = manifest.getMainAttributes().getValue(SOURCE_MANIFEST_ENTRY);
|
||||||
return loadClasses(classLoader, attribute.split(","));
|
return loadClasses(classLoader, attribute.split(","));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2012-2014 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
|
||||||
|
*
|
||||||
|
* 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 org.springframework.boot.cli.util;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides access to the java binary executable, regardless of OS.
|
||||||
|
*
|
||||||
|
* @author Phillip Webb
|
||||||
|
*/
|
||||||
|
public class JavaExecutable {
|
||||||
|
|
||||||
|
private File file;
|
||||||
|
|
||||||
|
public JavaExecutable() {
|
||||||
|
String javaHome = System.getProperty("java.home");
|
||||||
|
Assert.state(StringUtils.hasLength(javaHome),
|
||||||
|
"Unable to find java executable due to missing 'java.home'");
|
||||||
|
this.file = findInJavaHome(javaHome);
|
||||||
|
}
|
||||||
|
|
||||||
|
private File findInJavaHome(String javaHome) {
|
||||||
|
File bin = new File(new File(javaHome), "bin");
|
||||||
|
File command = new File(bin, "java.exe");
|
||||||
|
command = (command.exists() ? command : new File(bin, "java"));
|
||||||
|
Assert.state(command.exists(), "Unable to find java in " + javaHome);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProcessBuilder processBuilder(String... arguments) {
|
||||||
|
ProcessBuilder processBuilder = new ProcessBuilder(toString());
|
||||||
|
processBuilder.command().addAll(Arrays.asList(arguments));
|
||||||
|
return processBuilder;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
try {
|
||||||
|
return this.file.getCanonicalPath();
|
||||||
|
}
|
||||||
|
catch (IOException ex) {
|
||||||
|
throw new IllegalStateException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -29,7 +29,9 @@ import static org.junit.Assert.assertEquals;
|
||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author awilkinson
|
* Tests for {@link ResourceMatcher}.
|
||||||
|
*
|
||||||
|
* @author Andy Wilkinson
|
||||||
*/
|
*/
|
||||||
public class ResourceMatcherTests {
|
public class ResourceMatcherTests {
|
||||||
|
|
||||||
|
@ -38,22 +40,21 @@ public class ResourceMatcherTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void nonExistentRoot() throws IOException {
|
public void nonExistentRoot() throws IOException {
|
||||||
List<MatchedResource> matchedResources = this.resourceMatcher
|
List<MatchedResource> matchedResources = this.resourceMatcher.find(Arrays
|
||||||
.matchResources(Arrays.asList(new File("does-not-exist")));
|
.asList(new File("does-not-exist")));
|
||||||
assertEquals(0, matchedResources.size());
|
assertEquals(0, matchedResources.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void resourceMatching() throws IOException {
|
public void resourceMatching() throws IOException {
|
||||||
List<MatchedResource> matchedResources = this.resourceMatcher
|
List<MatchedResource> matchedResources = this.resourceMatcher.find(Arrays.asList(
|
||||||
.matchResources(Arrays.asList(new File(
|
new File("src/test/resources/resource-matcher/one"), new File(
|
||||||
"src/test/resources/resource-matcher/one"), new File(
|
|
||||||
"src/test/resources/resource-matcher/two"), new File(
|
"src/test/resources/resource-matcher/two"), new File(
|
||||||
"src/test/resources/resource-matcher/three")));
|
"src/test/resources/resource-matcher/three")));
|
||||||
System.out.println(matchedResources);
|
System.out.println(matchedResources);
|
||||||
List<String> paths = new ArrayList<String>();
|
List<String> paths = new ArrayList<String>();
|
||||||
for (MatchedResource resource : matchedResources) {
|
for (MatchedResource resource : matchedResources) {
|
||||||
paths.add(resource.getPath());
|
paths.add(resource.getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
assertEquals(6, paths.size());
|
assertEquals(6, paths.size());
|
||||||
|
|
Loading…
Reference in New Issue