Created
October 31, 2019 15:28
-
-
Save pierre-ernst/0af6a28f46eca6f5c0e831fd3bfc00e8 to your computer and use it in GitHub Desktop.
Builds a DOT-notation dependency graph from a yarn.lock file. Can be used to produce SVG or PNG images. $ sfdp -Gsize=50! -Goverlap=prism -Tsvg tree.dot > tree.svg
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.github.pierre_ernst; | |
import org.json.JSONObject; | |
import org.json.JSONTokener; | |
import java.io.File; | |
import java.io.FileNotFoundException; | |
import java.io.FileReader; | |
import java.io.PrintStream; | |
import java.util.*; | |
import java.util.regex.Matcher; | |
import java.util.regex.Pattern; | |
/** | |
* Builds a DOT-notation dependency graph from a yarn.lock file | |
* Can be used to produce SVG or PNG images | |
* $ sfdp -Gsize=50! -Goverlap=prism -Tsvg tree.dot > tree.svg | |
* | |
* https://en.wikipedia.org/wiki/DOT_language | |
*/ | |
public class YarnLock2Dot { | |
private static final int MAX_NODES_PER_CLUSTER = 50; | |
private static final String DEP_NAME_UNESCAPED_REGEX = "[:a-zA-Z0-9_\\./-]+"; | |
/** | |
* if the name is enclosed within double-quotes, then it can also contain '@' | |
*/ | |
private static final String DEP_NAME_ESCAPED_REGEX = "\"[@:a-zA-Z0-9_\\./-]+\""; | |
private static final String DEP_NAME_REGEX = "("+DEP_NAME_UNESCAPED_REGEX+"|"+DEP_NAME_ESCAPED_REGEX+")"; | |
private static class Package implements Comparable<Package> { | |
private Package parent; | |
private String name; | |
private String version; | |
public Package(Package parent, String name, String version) { | |
this.parent=parent; | |
this.name = name; | |
this.version = version; | |
} | |
public Package(Package parent, String name) { | |
this(parent, name, null); | |
} | |
public Package getParent(){ | |
return parent; | |
} | |
public String getName() { | |
return name; | |
} | |
public String getVersion() { | |
return version; | |
} | |
public void setVersion(String version) { | |
this.version = version; | |
} | |
@Override | |
public String toString() { | |
if (version == null) { | |
return name; | |
} | |
return name + ":" + version; | |
} | |
@Override | |
public int compareTo(Package aPackage) { | |
return this.toString().toLowerCase().compareTo(aPackage.toString().toLowerCase()); | |
} | |
public int getDepth(){ | |
int depth = 0; | |
Package current = this; | |
while (current.getParent()!=null){ | |
current = current.getParent(); | |
depth++; | |
} | |
return depth; | |
} | |
} | |
private Package rootPackage; | |
private String installFolderPath; | |
private Set<Package> transitivesDependencies ; | |
private YarnLock2Dot(Package rootPackage, String installFolderPath){ | |
this.installFolderPath=installFolderPath; | |
this.rootPackage=rootPackage; | |
transitivesDependencies = new HashSet<>(); | |
} | |
private static String unescape(String s){ | |
if (s.startsWith("\"") && s.endsWith("\"")){ | |
return s.substring(1,s.length()-2); | |
} | |
return s; | |
} | |
private static String escape(String s){ | |
return "\""+s+"\""; | |
} | |
private void format(PrintStream ps, File lockedYarn, int depth) throws FileNotFoundException { | |
Scanner scanner = new Scanner(lockedYarn); | |
Pattern headerPattern = Pattern.compile(DEP_NAME_REGEX+"@.+"); | |
Pattern versionPattern = Pattern.compile("version\\s+\"([^\"]+)\""); | |
Pattern dependencyPattern = Pattern.compile(DEP_NAME_REGEX+"\\s+\"[^\"]+\""); | |
String currentHeader = null; | |
Map<String, Package> bom = new HashMap<>(); | |
transitivesDependencies.clear(); | |
boolean withinDependenciesBlock = false; | |
while (scanner.hasNext()) { | |
String line = scanner.nextLine().trim(); | |
Matcher headerMatcher = headerPattern.matcher(line); | |
if (headerMatcher.matches()) { | |
currentHeader = line; | |
String packageName = unescape(headerMatcher.group(1)); | |
bom.put(currentHeader, new Package(rootPackage, packageName)); | |
//System.out.println("+pck "+packageName); | |
withinDependenciesBlock = false; | |
} else if (currentHeader != null) { | |
Matcher versionMatcher = versionPattern.matcher(line); | |
if (versionMatcher.matches()) { | |
bom.get(currentHeader).setVersion(versionMatcher.group(1)); | |
// System.out.println("+ver "+versionMatcher.group(1)); | |
withinDependenciesBlock = false; | |
} else if (line.equals("dependencies:")){ | |
withinDependenciesBlock = true; | |
} else{ | |
Matcher dependencyMatcher = dependencyPattern.matcher(line); | |
if (withinDependenciesBlock){ | |
if (dependencyMatcher.matches()){ | |
String dependencyName = unescape(dependencyMatcher.group(1)); | |
Package resolved = resolveDependencyFromInstalledPath(bom.get(currentHeader), dependencyName); | |
if (resolved!=null) { | |
transitivesDependencies.add(resolved); | |
} | |
//System.out.println("+dep "+currentHeader+"\t"+dependencyName+"\t"+resolved); | |
} else { | |
withinDependenciesBlock = false; | |
//System.out.println("--- ("+(currentHeader != null)+","+withinDependenciesBlock+") "+line); | |
} | |
} else { | |
//System.out.println("--- ("+(currentHeader != null)+","+withinDependenciesBlock+") "+line); | |
} | |
} | |
} | |
} | |
// Group by depth | |
Map<Integer, SortedSet<Package>> grouped = new HashMap<>(); | |
// direct dependencies | |
grouped.put(1, new TreeSet<>(bom.values())); | |
// transitives dependencies | |
for (Package transitiveDependency : transitivesDependencies) { | |
int d = transitiveDependency.getDepth(); | |
if (!grouped.containsKey(d)){ | |
grouped.put(d, new TreeSet<>()); | |
} | |
grouped.get(d).add(transitiveDependency); | |
} | |
ps.println("digraph "+escape(rootPackage.toString())+" {"); | |
int counter=0; | |
for (int d:grouped.keySet()){ | |
if (d<=depth){ | |
// int counter=0; | |
for (Package transitiveDependency : grouped.get(d)) { | |
/* | |
if (((counter) % MAX_NODES_PER_CLUSTER) == 0 ){ | |
if (counter>0){ | |
ps.println("}"); | |
} | |
ps.println("subgraph cluster"+d+"_"+(counter / MAX_NODES_PER_CLUSTER)+" {"); | |
} | |
*/ | |
ps.println(escape(transitiveDependency.getParent().toString()) + " -> " + escape(transitiveDependency.toString())+" // "+counter); | |
counter++; | |
} | |
//ps.println("}"); | |
} | |
} | |
ps.println("}"); | |
} | |
private Package resolveDependencyFromInstalledPath(Package parent, String name) throws FileNotFoundException { | |
File dependencyFolder = new File(installFolderPath,name); | |
if (!dependencyFolder.isDirectory()){ | |
//System.err.println("Dependency install directory "+installFolderPath+File.separator+name+" does not exist"); | |
} else { | |
File packageJson = new File(dependencyFolder,"package.json"); | |
if (packageJson.canRead()){ | |
JSONObject json = new JSONObject(new JSONTokener(new FileReader(packageJson))); | |
if (json.has("version")){ | |
Package pack = new Package(parent,name,json.getString("version")); | |
if (json.has("dependencies")) { | |
JSONObject dependencies = json.getJSONObject("dependencies"); | |
for (String key : dependencies.keySet()) { | |
Package child = resolveDependencyFromInstalledPath(pack, key); | |
if (child!=null){ | |
transitivesDependencies.add(child); | |
} | |
} | |
} | |
return pack; | |
} | |
} else { | |
// System.err.println("Dependency package.json file "+packageJson.getAbsolutePath()+" does not exist"); | |
} | |
} | |
return null; | |
} | |
public static void main(String... args) { | |
if (args.length != 5) { | |
System.err.println("Usage: java " + YarnLock2Dot.class.getName() + " <root-package-name> <root-package-version> <path-to-yarn.lock> <path-to-node-modules> <depth>"); | |
} else { | |
try { | |
Package rootPackage = new Package(null, args[0], args[1]); | |
new YarnLock2Dot(new Package(null, args[0], args[1]), args[3]).format(System.out, new File(args[2]), Integer.valueOf(args[4])); | |
} catch (Exception ex) { | |
ex.printStackTrace(System.err); | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment