/*******************************************************************************
* Copyright (c) 2013 GoPivotal, Inc.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* GoPivotal, Inc. - initial API and implementation
*******************************************************************************/
package org.springframework.ide.eclipse.boot.completions;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.Collection;
import java.util.HashMap;
import java.util.Set;
import java.util.Stack;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;
import org.eclipse.core.runtime.Assert;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.jdt.core.IJavaProject;
import org.eclipse.jface.dialogs.MessageDialog;
import org.eclipse.swt.widgets.Shell;
import org.eclipse.ui.PlatformUI;
import org.springframework.ide.eclipse.boot.core.BootActivator;
import org.springframework.ide.eclipse.boot.core.ChooseDependencyModel;
import org.springframework.ide.eclipse.boot.core.ISpringBootProject;
import org.springframework.ide.eclipse.boot.core.MavenCoordinates;
import org.springframework.ide.eclipse.boot.core.SpringBootCore;
import org.springframework.ide.eclipse.boot.ui.ChooseDependencyDialog;
import org.springframework.ide.eclipse.boot.util.Log;
import org.springsource.ide.eclipse.commons.completions.JDTContentAssistPrefsHelper;
import org.springsource.ide.eclipse.commons.completions.externaltype.AbstractExternalTypeSource;
import org.springsource.ide.eclipse.commons.completions.externaltype.ExternalType;
import org.springsource.ide.eclipse.commons.completions.externaltype.ExternalTypeDiscovery;
import org.springsource.ide.eclipse.commons.completions.externaltype.ExternalTypeEntry;
import org.springsource.ide.eclipse.commons.completions.util.Requestor;
import org.springsource.ide.eclipse.commons.core.preferences.StsProperties;
import org.springsource.ide.eclipse.commons.frameworks.core.downloadmanager.DownloadManager;
import org.springsource.ide.eclipse.commons.frameworks.core.downloadmanager.DownloadManager.DownloadRequestor;
import org.springsource.ide.eclipse.commons.frameworks.core.downloadmanager.DownloadableItem;
import org.springsource.ide.eclipse.commons.livexp.util.ExceptionUtil;
import org.xml.sax.Attributes;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;
/**
* This {@link ExternalTypeDiscovery} 'discovers' types by reading a large xml file.
* This xml file is created 'offline' and contains a dependency graph of maven artifacts and types.
*
* @author Kris De Volder
*/
public class SpringBootTypeDiscovery implements ExternalTypeDiscovery {
private static final long SECOND = 1000;
private static final long MINUTE = 60 * SECOND;
private static StsProperties stsProps = StsProperties.getInstance();
/**
* When requesting graph data from webservice we may have to retry... because
* the webservice may return a 'I am busy' result while it is computing the data.
* This constant specifies the 'retry interval'. I.e the time we wait in between
* retries.
*/
private static final long RETRY_INTERVAL = 15 * SECOND;
/**
* Retries are not unlimited. When this limit is reached we stop retrying.
*/
private static final int RETRIES = (int) ((5 * MINUTE)/RETRY_INTERVAL);
private static final boolean DEBUG = false;// (""+Platform.getLocation()).contains("kdvolder");
/**
* If this option is 'true' then when a dependency is added the managedVersion is
* never overridden (i.e. an explicit version is only inserted in the pom
* if there is no managed version).
*
* If this option is 'false' then a version dependency will be included in the
* pom if it does not match the managed version).
*/
private boolean preferManagedVersion = true;
/**
* If this option is selected transitive dependencies are considered. If is not
* selected then only a jar that directly provides a type will be suggested.
* <p>
* Note that even when this option deselected, spring-boot-starters will be suggested
* for some types because of the graph simplification algorithm that transforms the
* graph such that it appears as though those types are provided directly by
* the starter.
*/
private boolean transitive = false;
private String bootVersion;
public class DGraphTypeSource extends AbstractExternalTypeSource {
private DirectedGraph dgraph;
private ExternalType type;
public DGraphTypeSource(DirectedGraph dgraph, ExternalType type) {
this.dgraph = dgraph;
this.type = type;
}
@Override
public void addToClassPath(IJavaProject project, IProgressMonitor mon) {
try {
ISpringBootProject bootProject = SpringBootCore.create(project);
if (bootProject!=null) {
Collection<MavenCoordinates> sources;
sources = getProviders();
MavenCoordinates source = chooseSource(sources, bootProject);
if (source!=null) {
bootProject.addMavenDependency(source, preferManagedVersion);
}
}
} catch (Exception e) {
Log.log(e);
}
}
@SuppressWarnings("unchecked")
public Collection<MavenCoordinates> getProviders() {
Collection<MavenCoordinates> sources;
if (transitive) {
sources = dgraph.getDescendants(type);
} else {
sources = dgraph.getSuccessors(type);
}
return sources;
}
/**
* Open a dialog to let user choose one of the several ways a type can get added to
* the classpath.
* <p>
* If only one choice is available the dialog is skipped and that choice is returned immediately.
* <p>
* If the collection of choices is empty then the dialog is also skipped and null is returned.
*/
private MavenCoordinates chooseSource(Collection<MavenCoordinates> sources, ISpringBootProject project) {
if (sources!=null && !sources.isEmpty()) {
ChooseDependencyModel model = new ChooseDependencyModel(sources, project.getDependencyFileName(), type);
MavenCoordinates chosen = ChooseDependencyDialog.openOn(model);
if (model.disableJarTypeAssist.getValue()) {
disable();
}
return chosen;
}
return null;
}
/**
* Modify JDT content assist preferences to disable this
*/
private void disable() {
Shell shell = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getShell();
JDTContentAssistPrefsHelper.disableJarTypeSearch();
MessageDialog.openInformation(shell, "Jar Type Search Disabled",
"The content assist provider called 'Jar Type Search' has now been disabled. "+
"You can re-enable it via 'Preferences >> Java >> Editor >> Content Assist >> Advanced'"
);
}
@Override
public String getDescription() {
//The dgraph map actually contains inverted dependency edges so we have to
// get 'descendants' to actually get the 'ancestors' in the real dgraph.
Collection<MavenCoordinates> ancestors = getProviders();
if (!ancestors.isEmpty()) {
StringBuilder description = new StringBuilder();
description.append(
"Add type <b>"+type.getName()+"</b> from<br>"+
"package <b>"+type.getPackage()+"</b><br>"+
"to the classpath via one of the following:<p>");
description.append("<ul>");
for (Object object : ancestors) {
description.append(toHtml(object));
}
description.append("</ul>");
return description.toString();
}
return null;
}
private String toHtml(Object object) {
if (object instanceof MavenCoordinates) {
MavenCoordinates artifact = (MavenCoordinates) object;
StringBuilder html = new StringBuilder();
html.append("<li>");
html.append("<b>"+artifact.getArtifactId()+"</b><br>");
html.append("group: "+artifact.getGroupId()+"<br>");
html.append("version: "+artifact.getVersion());
html.append("</li>");
return html.toString();
}
return object.toString();
}
}
private static URI XML_DATA_LOCATION;
static {
try {
//Use data embedded in this plugin:
//XML_DATA_LOCATION = new URI("platform:/plugin/org.springframework.ide.eclipse.boot/resources/boot-completion-data.txt");
XML_DATA_LOCATION = new URI(stsProps.get("spring.boot.typegraph.url"));
} catch (URISyntaxException e) {
Log.log(e);
}
}
private static class MyHandler extends DefaultHandler {
Stack<Object> path = new Stack<Object>();
/**
* Map used to 'reuse' strings if they have the same content. We expect a packag name to be used
* many times (depending on the number of types in the package). So reusing the Stirng
* objects could save memory.
*/
private HashMap<String, String> strings = new HashMap<String, String>();
private DirectedGraph dgraph;
public MyHandler(DirectedGraph dgraph) {
this.dgraph = dgraph;
}
@Override
public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
Object parentElement = peek();
Object thisElement = null;
if (qName.equals("artifact")) {
MavenCoordinates artifact = MavenCoordinates_parse(attributes.getValue("id"));
if (artifact!=null) {
thisElement = artifact;
if (parentElement!=null) {
Assert.isLegal(parentElement instanceof MavenCoordinates, "parent of artifact should always be another artififact");
dgraph.addEdge(thisElement, parentElement);
}
}
} else if (qName.equals("type")) {
ExternalType type = ExternalType_parse(attributes.getValue("id"));
thisElement = type;
Assert.isLegal(parentElement instanceof MavenCoordinates, "parent of a type should be an artifact (that contains it) but it was: "+parentElement);
dgraph.addEdge(thisElement, parentElement);
}
//else { ... something we don't care about ... }
path.push(thisElement); //Yes we might push some null's but that makes it easier to ensure pops and pushes are
// 'balanced' as it keeps the 'when to push and pop' logic very simple (i.e. always push on startElement and
// always pop on endElement
}
private ExternalType ExternalType_parse(String fqName) {
int split = fqName.lastIndexOf('.');
if (split>0) {
String name = fqName.substring(split+1);
String packageName = fqName.substring(0, split);
return new ExternalType(intern(name),intern(packageName));
} else {
throw new IllegalArgumentException("Invalid fqName: "+fqName);
}
}
@Override
public void endElement(String uri, String localName, String qName) throws SAXException {
path.pop();
}
private Object peek() {
if (path.isEmpty()) {
return null;
}
return path.peek();
}
public void dispose() {
//Assert.isLegal(debugStack.isEmpty(), "Unpopped: "+debugStack);
Assert.isLegal(path.isEmpty(), "Bug: pops and pushes are out of whack!");
strings = null;
}
/**
* Parse from a string like:
* org.springframework:spring-core:4.0.0.RC1
* <p>
* We are really only interested in jars. So if the dependency is not a jar
* then returns null.
*/
public MavenCoordinates MavenCoordinates_parse(String artifact) {
String[] pieces = artifact.split(":");
if (pieces.length==3) {
//e.g: org.springframework:spring-core:4.0.0.RC1
return new MavenCoordinates(intern(pieces[0]), intern(pieces[1]), intern(pieces[2]));
} else if (pieces.length==4) {
//e.g: org.springframework:spring-core:jar:4.0.0.RC1
String type = pieces[2];
if ("jar".equals(type)) {
return new MavenCoordinates(intern(pieces[0]), intern(pieces[1]), intern(pieces[3]));
}
} else if (pieces.length==5) {
//e.g: net.java.dev.jna:jna:jar:platform:3.3.0
String type = pieces[2];
if ("jar".equals(type)) {
return new MavenCoordinates(intern(pieces[0]), intern(pieces[1]), intern(pieces[3]), intern(pieces[4]));
}
}
throw new IllegalArgumentException("Unsupported artifact string: '"+artifact+"'");
}
private String intern(String string) {
String existing = strings.get(string);
if (existing==null) {
strings.put(string, string);
existing = string;
}
return existing;
}
}
public SpringBootTypeDiscovery(String bootVersion) {
Assert.isNotNull(bootVersion);
this.bootVersion = bootVersion;
}
@SuppressWarnings("rawtypes")
@Override
public void getTypes(Requestor<ExternalTypeEntry> requestor) {
try {
DirectedGraph dgraph = createGraph();
Set nodes = dgraph.getNonLeafNodes();
//We are only interested in 'type' nodes. These should always have pointer to
// at least one maven artifact that contains them. Therefore type nodes are
// never leaf nodes.
for (Object node : nodes) {
//Not all non-leaf nodes in the graph represent types. Some (fewer) of them represent artifacts
// that were added to the graph because they are depended on by other artifacts.
if (node instanceof ExternalType) {
ExternalType type = (ExternalType) node;
requestor.receive(new ExternalTypeEntry(type, new DGraphTypeSource(dgraph, type)));
}
if (DEBUG) {
if (node instanceof MavenCoordinates) {
Set ancestors = dgraph.getDescendants(node);
if (!ancestors.isEmpty()) {
System.out.println(node+" has ancestors: ");
for (Object anc : ancestors) {
System.out.println(" "+anc);
}
}
}
}
}
} catch (Exception e) {
Log.log(e);
}
}
private DirectedGraph parseFrom(File xmlFile) throws Exception {
DirectedGraph dgraph = new DirectedGraph();
SAXParserFactory factory = SAXParserFactory.newInstance();
SAXParser saxParser = factory.newSAXParser();
MyHandler handler = new MyHandler(dgraph);
Throwable caught = null;
try {
saxParser.parse(xmlFile, handler);
} catch (Throwable e) {
caught = e;
} finally {
//This is a bit convoluted because if we already caught an
// exception we don't want to 'mask' it by the exception
// that will almost certainly be thrown here.
try {
handler.dispose();
} catch (Throwable e) {
if (caught==null) {
caught = e;
}
}
}
if (caught!=null) {
throw ExceptionUtil.exception(caught);
}
return dgraph;
}
private DownloadManager downloader = null;
private synchronized DownloadManager downloader() throws IOException {
if (downloader==null) {
File cacheFolder = new File(BootActivator.getDefault().getStateLocation().toFile(), "typegraphs");
if (cacheFolder.exists()) {
//Delete 'SNAPSHOT' data so it is downloaded again.
for (String name : cacheFolder.list()) {
try {
if (name.contains("SNAPSHOT")) {
new File(cacheFolder, name).delete();
}
} catch (Throwable e) {
Log.log(e);
}
}
}
downloader = new DownloadManager(null, cacheFolder);
downloader.setTries(RETRIES);
downloader.setRetryInterval(RETRY_INTERVAL);
}
return downloader;
}
/**
* DownloadablItem for a 'type graph' xml file. Overrides default 'getFileName' method
* to provide more readable/debugable name for the typegrpah files. In all other respects it
* the same as super class.
*/
private static class TypeGraphFile extends DownloadableItem {
private String bootVersion;
public TypeGraphFile(String bootVersion, DownloadManager downloader) throws Exception {
super(new URL(XML_DATA_LOCATION.toString()+"/"+bootVersion), downloader);
this.bootVersion = bootVersion;
}
@Override
protected String getFileName() {
return bootVersion;
}
}
private DirectedGraph createGraph() throws Exception {
DownloadManager downloader = downloader();
final DirectedGraph[] result = new DirectedGraph[]{null};
downloader.doWithDownload(new TypeGraphFile(bootVersion, downloader), new DownloadRequestor() {
@Override
public void exec(File downloadedFile) throws Exception {
result[0] = parseFrom(downloadedFile);
}
});
return result[0];
}
}