blob: d01c4c7b7329228cec58cd31b45e2b39e6685feb [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.netbeans.nbbuild;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.SortedMap;
import java.util.SortedSet;
import java.util.StringTokenizer;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.Manifest;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipException;
import java.util.zip.ZipFile;
import javax.xml.parsers.SAXParserFactory;
import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.DirectoryScanner;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.Task;
import org.apache.tools.ant.types.FileSet;
import org.apache.tools.ant.types.Resource;
import org.apache.tools.ant.types.ResourceCollection;
import org.apache.tools.ant.types.resources.ZipResource;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;
/**
* Task to scan all XML layers in a NB installation
* and report on which modules registers which files.
* @author Jesse Glick
*/
public class LayerIndex extends Task {
public LayerIndex() {}
List<FileSet> filesets = new ArrayList<>();
public void addConfiguredModules(FileSet fs) {
filesets.add(fs);
}
private File output;
public void setOutput(File f) {
output = f;
}
private File serviceOutput;
public void setServiceOutput(File f) {
serviceOutput = f;
}
private String resourceId;
private List<Resource> resources;
/** If this parameter is provided, then this tasks creates a resource
* composed from all the layerfiles and makes it accessible under this refId
* @param id the refId to associate the collection with
*/
public void setResourceId(String id) {
resourceId = id;
resources = new ZipArray();
}
@Override
public void execute() throws BuildException {
if (filesets.isEmpty()) {
throw new BuildException();
}
SortedMap<String,String> files = new TreeMap<>(); // layer path -> cnb
SortedMap<String,SortedMap<String,String>> labels = new TreeMap<>(); // layer path -> cnb -> label
final Map<String,Integer> positions = new TreeMap<>(); // layer path -> position
SortedMap<String,SortedMap<String,Set<String>>> serviceImpls = new TreeMap<>(); // path -> interface -> [impl]
Map<String,Integer> servicePositions = new HashMap<>(); // impl -> position
for (FileSet fs : filesets) {
DirectoryScanner ds = fs.getDirectoryScanner(getProject());
File basedir = ds.getBasedir();
for (String path : ds.getIncludedFiles()) {
File jar = new File(basedir, path);
try {
try (JarFile jf = new JarFile(jar)) {
Manifest mf = jf.getManifest();
if (mf == null) {
continue;
}
String modname = JarWithModuleAttributes.extractCodeName(mf.getMainAttributes());
if (modname == null) {
continue;
}
// XXX services.txt has e.g. "SERVICE org.openide.filesystems.MIMEResolver\n PROVIDER org.netbeans.modules.java.hints.test.Utilities$JavaMimeResolver"
// which is misleading since this pseudomodule is used only in unit tests
// maybe define Normally-Disabled: true in manifest.mf and skip from here (and disabledAutoloads)?
String cnb = modname.replaceFirst("/\\d+$", "");
String layer = mf.getMainAttributes().getValue("OpenIDE-Module-Layer");
if (layer != null) {
if (resources != null) {
ZipResource res = new LayerResource(jar, layer, layer.replaceFirst("/[^/]+$", "").replace('/', '.') + ".xml");
resources.add(res);
} else {
parse(jf.getInputStream(jf.getEntry(layer)), files, labels, positions, cnb, jf);
}
}
ZipEntry generatedLayer = jf.getEntry("META-INF/generated-layer.xml");
if (generatedLayer != null) {
if (resources != null) {
ZipResource res = new LayerResource(jar, generatedLayer.getName(), cnb + "-generated.xml");
resources.add(res);
} else {
parse(jf.getInputStream(generatedLayer), files, labels, positions, cnb + "@", jf);
}
}
if (serviceOutput != null) {
// Could remember CNBs too.
parseServices(jf, serviceImpls, servicePositions);
}
}
} catch (Exception x) {
throw new BuildException("Reading " + jar + ": " + x, x, getLocation());
}
}
}
if (resources != null) {
assignReferences();
return;
}
try {
writeLayerIndex(files, positions, labels);
if (serviceOutput != null) {
writeServiceIndex(serviceImpls, servicePositions);
}
} catch (IOException x) {
throw new BuildException(x, getLocation());
}
}
@SuppressWarnings("unchecked")
private void assignReferences() {
getProject().getReferences().put(resourceId, resources);
}
static String shortenCNB(String cnb) {
if (cnb != null) {
return cnb.replaceFirst("^org\\.netbeans\\.", "o.n.").replaceFirst("^org\\.openide\\.", "o.o.").replaceFirst("\\.modules\\.", ".m.");
} else {
return "";
}
}
private String shortenPath(String path) {
return path.replaceAll("(^|/)org-netbeans-", "$1o-n-").replaceAll("(^|/)org-openide-", "$1o-o-").replaceAll("-modules-", "-m-")
.replaceAll("(^|/)org\\.netbeans\\.", "$1o.n.").replaceAll("(^|/)org\\.openide\\.", "$1o.o.").replaceAll("\\.modules\\.", ".m.");
}
private void parse(InputStream is, final Map<String,String> files, final SortedMap<String,SortedMap<String,String>> labels,
final Map<String,Integer> positions, final String cnb, final JarFile jf) throws Exception {
SAXParserFactory f = SAXParserFactory.newInstance();
f.setValidating(false);
f.setNamespaceAware(false);
f.newSAXParser().parse(is, new DefaultHandler() {
String prefix = "";
void register(String path) {
if (!files.containsKey(path)) {
files.put(path, cnb);
} else if (!cnb.equals(files.get(path))) {
// Possibly >1 owner, but consider layer.xml vs. generated-layer.xml.
if (cnb.equals(files.get(path) + "@")) {
// leave alone
} else if ((cnb + "@").equals(files.get(path))) { // mark as defined in layer.xml
files.put(path, cnb);
} else { // different modules
files.put(path, null);
}
}
}
@Override
public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
if (qName.equals("folder")) {
String n = attributes.getValue("name");
prefix += n + "/";
register(prefix);
} else if (qName.equals("file")) {
String n = attributes.getValue("name");
prefix += n;
register(prefix);
} else if (qName.equals("attr") && attributes.getValue("name").equals("SystemFileSystem.localizingBundle")) {
String bundle = attributes.getValue("stringvalue");
if (bundle != null) {
loadDisplayName(bundle,prefix.replaceAll("/$", ""));
} else {
log("No stringvalue for SystemFileSystem.localizingBundle on " + prefix + " in " + cnb, Project.MSG_WARN);
}
} else if (qName.equals("attr") && attributes.getValue("name").equals("displayName")) {
String bundleKey = attributes.getValue("bundlevalue");
if (bundleKey != null) {
String[] bundlevalue = bundleKey.split("#", 2);
loadDisplayName(bundlevalue[0], bundlevalue[1]);
} else {
String literal = attributes.getValue("stringvalue");
if (literal != null) {
loadDisplayName(literal);
}
}
} else if (qName.equals("attr") && attributes.getValue("name").equals("position")) {
String intvalue = attributes.getValue("intvalue");
if (intvalue != null && /* #107550 */ !intvalue.equals("0")) {
try {
positions.put(prefix, Integer.parseInt(intvalue));
} catch (NumberFormatException x) {
throw new SAXException(x);
}
}
}
}
private void loadDisplayName(String bundle, String key) throws SAXException {
Properties props = new Properties();
try {
ZipEntry entry = jf.getEntry(bundle.replace('.', '/') + ".properties");
if (entry == null) {
/* Should be covered by ValidateLayerConsistencyTest.testLocalizingBundles:
log(bundle + " not found in reference from " + prefix + " in " + cnb, Project.MSG_WARN);
*/
return;
}
props.load(jf.getInputStream(entry));
} catch (IOException x) {
throw new SAXException(x);
}
String label = props.getProperty(key);
if (label == null) {
/* Should be covered by ValidateLayerConsistencyTest.testLocalizingBundles:
log("Key " + key + " not found in " + bundle + " from " + cnb, Project.MSG_WARN);
*/
return;
}
loadDisplayName(label);
}
private void loadDisplayName(String label) {
SortedMap<String,String> cnb2label = labels.get(prefix);
if (cnb2label == null) {
cnb2label = new TreeMap<>();
labels.put(prefix, cnb2label);
}
cnb2label.put(cnb, label);
}
@Override
public void endElement(String uri, String localName, String qName) throws SAXException {
if (qName.equals("folder")) {
prefix = prefix.replaceFirst("[^/]+/$", "");
} else if (qName.equals("file")) {
prefix = prefix.replaceFirst("[^/]+$", "");
}
}
@Override
public InputSource resolveEntity(String pub, String sys) throws IOException, SAXException {
return new InputSource(new StringReader(""));
}
});
}
private void parseServices(JarFile jf, SortedMap<String,SortedMap<String,Set<String>>> serviceImplsByPath,
Map<String,Integer> servicePositions) throws IOException {
Enumeration<JarEntry> entries = jf.entries();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
if (entry.isDirectory()) {
continue;
}
String name = entry.getName();
String path, xface;
if (name.startsWith("META-INF/services/")) {
path = "";
xface = name.substring("META-INF/services/".length());
} else if (name.startsWith("META-INF/namedservices/")) {
String rest = name.substring("META-INF/namedservices/".length());
int x = rest.lastIndexOf('/');
path = rest.substring(0, x);
xface = rest.substring(x + 1);
} else {
continue;
}
try (InputStream is = jf.getInputStream(entry)) {
BufferedReader r = new BufferedReader(new InputStreamReader(is, "UTF-8"));
String lastImpl = null;
String line;
while ((line = r.readLine()) != null) {
if (line.startsWith("#position=") && lastImpl != null) {
servicePositions.put(lastImpl, Integer.parseInt(line.substring("#position=".length())));
} else if (line.startsWith("#-") || (line.length() > 0 && !line.startsWith("#"))) {
lastImpl = line;
SortedMap<String,Set<String>> serviceImpls = serviceImplsByPath.get(path);
if (serviceImpls == null) {
serviceImpls = new TreeMap<>();
serviceImplsByPath.put(path, serviceImpls);
}
Set<String> impls = serviceImpls.get(xface);
if (impls == null) {
impls = new HashSet<>();
serviceImpls.put(xface, impls);
}
impls.add(lastImpl);
}
}
}
}
}
private static final class ZipArray extends ArrayList<Resource>
implements ResourceCollection {
public boolean isFilesystemOnly() {
return false;
}
@Override
public Stream<Resource> stream() {
return super.stream();
}
}
private static final class LayerResource extends ZipResource {
private final String name;
private LayerResource(
File jar, String path, String name) throws ZipException {
super(jar, "UTF-8", new org.apache.tools.zip.ZipEntry(path));
this.name = name;
}
@Override
public String getName() {
return name;
}
@Override
public InputStream getInputStream() throws IOException {
final ZipFile z = new ZipFile(getZipfile(), ZipFile.OPEN_READ);
ZipEntry ze = z.getEntry(super.getName());
if (ze == null) {
z.close();
throw new BuildException("no entry " + getName() + " in "
+ getArchive());
}
return z.getInputStream(ze);
}
}
private void writeLayerIndex(SortedMap<String,String> files, final Map<String,Integer> positions,
SortedMap<String,SortedMap<String,String>> labels) throws IOException {
int maxlength = 0;
for (String cnb : files.values()) {
maxlength = Math.max(maxlength, shortenCNB(cnb).length());
}
PrintWriter pw = output != null ? new PrintWriter(output, "UTF-8") : null;
Map<String,String> virtualEntries = computeMIMELookupEntries(files.keySet());
updateMap(files, virtualEntries);
updateMap(positions, virtualEntries);
updateMap(labels, virtualEntries);
SortedSet<String> layerPaths = new TreeSet<>(new LayerPathComparator(positions));
layerPaths.addAll(files.keySet());
SortedSet<String> remaining = new TreeSet<>(files.keySet());
remaining.removeAll(layerPaths);
assert remaining.isEmpty() : remaining;
for (String path : layerPaths) {
String cnb = files.get(path);
String line = String.format("%-" + maxlength + "s %s", shortenCNB(cnb), shortenPath(path));
if (virtualEntries.containsKey(path)) {
line += " (merged)";
}
Integer pos = positions.get(path);
if (pos != null) {
line += String.format(" @%d", pos);
}
SortedMap<String,String> cnb2Label = labels.get(path);
if (cnb2Label != null) {
if (cnb2Label.size() == 1 && cnb2Label.keySet().iterator().next().equals(cnb)) {
line += String.format(" (\"%s\")", cnb2Label.values().iterator().next());
} else {
for (Map.Entry<String,String> labelEntry : cnb2Label.entrySet()) {
line += String.format(" (%s: \"%s\")", shortenCNB(labelEntry.getKey()), labelEntry.getValue());
}
}
}
if (pw != null) {
pw.println(line);
} else {
log(line);
}
}
if (pw != null) {
pw.close();
}
if (output != null) {
log(output + ": layer index written");
}
}
/**
* Map from virtual file paths to original literal file path.
* E.g. Editors/text/html/Popup/foo.instance -> Editors/Popup/foo.instance
* See ValidateLayerConsistencyTest.testFolderOrdering for comparison.
*/
private Map<String,String> computeMIMELookupEntries(Set<String> files) {
Pattern editorFolderPattern = Pattern.compile("Editors/(application|text)/([^/]+)(.*/)");
Map<String,String> result = new HashMap<>();
for (String editorFolder : files) {
Matcher m = editorFolderPattern.matcher(editorFolder);
if (!m.matches()) {
continue;
}
// $0="Editors/text/html/Popup/" $1="text" $2="html" $3="/Popup/"
List<String> prefixen = new ArrayList<>(2);
prefixen.add("Editors" + m.group(3)); // "Editors/Popup/"
if (m.group(2).endsWith("+xml")) { // Editors/text/x-ant+xml/Popup/
prefixen.add("Editors/" + m.group(1) + "/xml" + m.group(3)); // Editors/text/xml/Popup/
}
for (String prefix : prefixen) {
for (String file : files) {
if (file.startsWith(prefix)) { // "Editors/Popup/foo.instance"
String basename = file.substring(prefix.length());
if (basename.contains("/")) {
// Would technically be correct to show, but usually irrelevant.
continue;
}
String virtual = editorFolder + basename; // Editors/text/html/Popup/foo.instance
if (!files.contains(virtual)) {
result.put(virtual, file);
}
}
}
}
}
return result;
}
private <T> void updateMap(Map<String,T> map, Map<String,String> virtualEntries) {
for (Map.Entry<String,String> entry : virtualEntries.entrySet()) {
String orig = entry.getValue();
if (map.containsKey(orig)) {
map.put(entry.getKey(), map.get(orig));
}
}
}
private static class LayerPathComparator implements Comparator<String> {
private final Map<String,Integer> positions;
public LayerPathComparator(Map<String,Integer> positions) {
this.positions = positions;
}
public int compare(String p1, String p2) {
StringTokenizer tok1 = new StringTokenizer(p1, "/");
StringTokenizer tok2 = new StringTokenizer(p2, "/");
String prefix = "";
while (tok1.hasMoreTokens()) {
String piece1 = tok1.nextToken();
if (tok2.hasMoreTokens()) {
String piece2 = tok2.nextToken();
if (piece1.equals(piece2)) {
prefix += piece1 + "/";
} else {
Integer pos1 = pos(prefix + piece1);
Integer pos2 = pos(prefix + piece2);
if (pos1 == null) {
if (pos2 == null) {
return piece1.compareTo(piece2);
} else {
return 1;
}
} else {
if (pos2 == null) {
return -1;
} else {
int diff = pos1 - pos2;
if (diff != 0) {
return diff;
} else {
return piece1.compareTo(piece2);
}
}
}
}
} else {
return 1;
}
}
if (tok2.hasMoreTokens()) {
return -1;
}
assert p1.equals(p2) : p1 + " vs. " + p2;
return 0;
}
Integer pos(String path) {
return positions.containsKey(path) ? positions.get(path) : positions.get(path + "/");
}
}
private void writeServiceIndex(SortedMap<String,SortedMap<String,Set<String>>> serviceImpls,
final Map<String,Integer> servicePositions) throws IOException {
try (PrintWriter pw = new PrintWriter(serviceOutput, "UTF-8")) {
for (Map.Entry<String,SortedMap<String,Set<String>>> mainEntry : serviceImpls.entrySet()) {
String path = mainEntry.getKey();
for (Map.Entry<String,Set<String>> entry : mainEntry.getValue().entrySet()) {
pw.print("SERVICE " + entry.getKey());
if (path.length() > 0) {
pw.println(" under " + path);
} else {
pw.println();
}
SortedSet<String> impls = new TreeSet<>(new ServiceComparator(servicePositions));
impls.addAll(entry.getValue());
Set<String> masked = new HashSet<>();
for (String impl : impls) {
if (impl.startsWith("#-")) {
masked.add(impl);
masked.add(impl.substring(2));
}
}
impls.removeAll(masked);
for (String impl : impls) {
if (servicePositions.containsKey(impl)) {
impl += " @" + servicePositions.get(impl);
}
pw.println(" PROVIDER " + impl);
}
}
}
}
log(serviceOutput + ": service index written");
}
private static class ServiceComparator implements Comparator<String> {
private final Map<String,Integer> servicePositions;
public ServiceComparator(Map<String,Integer> servicePositions) {
this.servicePositions = servicePositions;
}
public int compare(String i1, String i2) {
Integer pos1 = servicePositions.get(i1);
Integer pos2 = servicePositions.get(i2);
if (pos1 == null) {
if (pos2 == null) {
return i1.compareTo(i2);
} else {
return 1;
}
} else {
if (pos2 == null) {
return -1;
} else {
int diff = pos1 - pos2;
if (diff != 0) {
return diff;
} else {
return i1.compareTo(i2);
}
}
}
}
}
}