/*
* Sonatype Nexus (TM) Open Source Version
* Copyright (c) 2007-2014 Sonatype, Inc.
* All rights reserved. Includes the third-party code listed at http://links.sonatype.com/products/nexus/oss/attributions.
*
* This program and the accompanying materials are made available under the terms of the Eclipse Public License Version 1.0,
* which accompanies this distribution and is available at http://www.eclipse.org/legal/epl-v10.html.
*
* Sonatype Nexus (TM) Professional Version is available from Sonatype, Inc. "Sonatype" and "Sonatype Nexus" are trademarks
* of Sonatype, Inc. Apache Maven is a trademark of the Apache Software Foundation. M2eclipse is a trademark of the
* Eclipse Foundation. All other trademarks are the property of their respective owners.
*/
package org.sonatype.nexus.extjs;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.io.Reader;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.annotation.Nullable;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import org.apache.maven.plugin.logging.Log;
import org.codehaus.plexus.util.Scanner;
import org.codehaus.plexus.util.dag.DAG;
import org.codehaus.plexus.util.dag.TopologicalSorter;
import org.codehaus.plexus.util.dag.Vertex;
import org.mozilla.javascript.CompilerEnvirons;
import org.mozilla.javascript.Context;
import org.mozilla.javascript.ErrorReporter;
import org.mozilla.javascript.Parser;
import org.mozilla.javascript.ast.ArrayLiteral;
import org.mozilla.javascript.ast.AstNode;
import org.mozilla.javascript.ast.AstRoot;
import org.mozilla.javascript.ast.FunctionCall;
import org.mozilla.javascript.ast.Name;
import org.mozilla.javascript.ast.NewExpression;
import org.mozilla.javascript.ast.NodeVisitor;
import org.mozilla.javascript.ast.ObjectLiteral;
import org.mozilla.javascript.ast.ObjectProperty;
import org.mozilla.javascript.ast.PropertyGet;
import org.mozilla.javascript.ast.StringLiteral;
import static com.google.common.base.Preconditions.checkNotNull;
import static org.mozilla.javascript.Token.CALL;
import static org.mozilla.javascript.Token.NEW;
/**
* ExtJS 4+ class definition scanner.
*
* @since 3.0
*/
public class ClassDefScanner
{
private final Map<String, ClassDef> classes = Maps.newHashMap();
private final Log log;
private boolean warnings = false;
private String namespace;
public ClassDefScanner(final Log log) {
this.log = checkNotNull(log);
}
public boolean isWarnings() {
return warnings;
}
public void setWarnings(final boolean warnings) {
this.warnings = warnings;
}
@Nullable
public String getNamespace() {
return namespace;
}
public void setNamespace(final @Nullable String namespace) {
this.namespace = namespace;
}
/**
* Scan a set of files and return a list of class definitions in dependency order.
* ie. Foo extends Bar, Bar will be before Foo.
*/
public List<ClassDef> scan(final Scanner files) throws Exception {
checkNotNull(files);
final boolean debug = log.isDebugEnabled();
// reset and scan for files
classes.clear();
log.debug("Finding files to scan...");
files.scan();
// scan each file
String[] included = files.getIncludedFiles();
log.debug("Scanning " + included.length + " files...");
for (String path : included) {
File file = new File(files.getBasedir(), path);
scan(file);
}
// TODO: Sort out how/if we want to resolve aliases, ATM this data is collected by not used
// Resolve all references and build map with all class defs (including aliases/alts)
Map<String,ClassDef> allClasses = Maps.newHashMap();
for (ClassDef def : classes.values()) {
allClasses.put(def.getName(), def);
for (String alt : def.getAlternateClassName()) {
allClasses.put(alt, def);
}
resolve(def);
}
if (debug) {
log.debug("All classes:");
for (String className : allClasses.keySet()) {
log.debug(" " + className);
}
}
// build the graph
DAG graph = new DAG();
for (ClassDef def : allClasses.values()) {
graph.addVertex(def.getName());
for (String name : def.getDependencies()) {
// resolve dependencies which have class defs to primary class name
ClassDef dep = allClasses.get(name);
if (dep != null) {
name = dep.getName();
}
graph.addEdge(def.getName(), name);
}
}
// display some debug information about the graph
if (debug) {
log.debug("Vertices:");
for (Vertex v : graph.getVerticies()) {
log.debug(" " + v.getLabel());
if (!v.getParents().isEmpty()) {
log.debug(" parents:");
for (Vertex parent : v.getParents()) {
log.debug(" " + parent.getLabel());
}
}
if (!v.getChildren().isEmpty()) {
log.debug(" children:");
for (Vertex child : v.getChildren()) {
log.debug(" " + child.getLabel());
}
}
}
}
// result sorted list of class definitions
Set<ClassDef> results = Sets.newLinkedHashSet();
log.debug("Ordered classes:");
// the graph contains many references, only include those which are class defs
for (String className : TopologicalSorter.sort(graph)) {
ClassDef def = allClasses.get(className);
// skip duplicates (due to alt/aliases)
if (def != null && !results.contains(def)) {
log.debug(" " + def.getName());
results.add(def);
}
}
log.debug("Found " + results.size() + " classes");
return Lists.newArrayList(results);
}
/**
* Resolve all references.
*/
private void resolve(final ClassDef def) {
// resolve mvc references if namespace is set
if (namespace != null) {
if (def.getMvcControllers() != null) {
for (String name : def.getMvcControllers()) {
appendMvcDependency(def, "controller", name);
}
}
if (def.getMvcModels() != null) {
for (String name : def.getMvcModels()) {
appendMvcDependency(def, "model", name);
}
}
if (def.getMvcStores() != null) {
for (String name : def.getMvcStores()) {
appendMvcDependency(def, "store", name);
}
}
if (def.getMvcViews() != null) {
for (String name : def.getMvcViews()) {
appendMvcDependency(def, "view", name);
}
}
}
}
private void appendMvcDependency(final ClassDef def, final String type, final String name) {
if (classes.containsKey(name)) {
// already qualified name
def.getDependencies().add(name);
}
else {
if (name.contains("@")) {
// namespace is embedded: <name>@<namespace>
String[] parts = name.split("@", 2);
def.getDependencies().add(String.format("%s.%s", parts[1], parts[0]));
}
else {
def.getDependencies().add(String.format("%s.%s.%s", namespace, type, name));
}
}
}
/**
* Scan the given file for class definitions and accumulate dependencies.
*/
private void scan(final File source) throws IOException {
log.debug("Scanning: " + source);
ErrorReporter errorReporter = new LogErrorReporter(log);
CompilerEnvirons env = new CompilerEnvirons();
env.setErrorReporter(errorReporter);
Parser parser = new Parser(env, errorReporter);
Reader reader = new BufferedReader(new FileReader(source));
try {
AstRoot root = parser.parse(reader, source.getAbsolutePath(), 0);
DependencyAccumulator visitor = new DependencyAccumulator(source);
root.visit(visitor);
// complain if no def was found in this source
if (visitor.current == null) {
log.warn("No class definition was found while processing: " + source);
}
}
finally {
reader.close();
}
}
private class DependencyAccumulator
implements NodeVisitor
{
private final File source;
private ClassDef current;
private DependencyAccumulator(final File source) {
this.source = source;
}
/**
* Helper to report error for given node position in ast tree.
*/
private RuntimeException reportError(final AstNode node, final String message, Object... params) {
throw Context.reportRuntimeError(String.format(message, params),
source.getAbsolutePath(),
node.getLineno(), // seems to always be the line before?
node.debugPrint(),
0 // line-offset is unknown?
);
}
/**
* Helper to check state and report error for given node position in ast tree.
*/
private void checkState(boolean expression, final AstNode node, final String message, Object... params) {
if (!expression) {
throw reportError(node, message, params);
}
}
@Override
public boolean visit(final AstNode node) {
int type = node.getType();
switch (type) {
case CALL: {
FunctionCall call = (FunctionCall) node;
String name = nameOf(call.getTarget());
// if we can not determine the function name, skip
if (name == null) {
break;
}
if (name.equals("Ext.define")) {
// complain if we found more than one class
if (current != null) {
log.warn("Found duplicate class definition in source: " + source);
}
processClassDef(call);
return true; // process children
}
else if (name.equals("Ext.create")) {
// complain if we have references to classes with Ext.create() and missing requires
if (!call.getArguments().isEmpty() && call.getArguments().get(0) instanceof StringLiteral) {
String className = nameOf(call.getArguments().get(0));
maybeWarnMissingRequires(className, "Ext.create");
}
return false; // ignore children
}
break;
}
case NEW: {
// complain if we have references to classes with 'new' and missing requires
NewExpression expr = (NewExpression) node;
String className = nameOf(expr.getTarget());
maybeWarnMissingRequires(className, "new");
break;
}
}
return true; // process children
}
/**
* Find the textual name of the given node.
*/
@Nullable
private String nameOf(final AstNode node) {
if (node instanceof Name) {
return ((Name) node).getIdentifier();
}
else if (node instanceof PropertyGet) {
PropertyGet prop = (PropertyGet) node;
return String.format("%s.%s", nameOf(prop.getTarget()), nameOf(prop.getProperty()));
}
else if (node instanceof StringLiteral) {
return ((StringLiteral) node).getValue();
}
return null;
}
/**
* Return string literal value.
*/
private String stringLiteral(final AstNode node) {
checkState(node instanceof StringLiteral, node, "Expected string literal only");
//noinspection ConstantConditions
StringLiteral string = (StringLiteral) node;
return string.getValue();
}
/**
* Returns string array literal values.
*/
private List<String> arrayStringLiteral(final AstNode node) {
checkState(node instanceof ArrayLiteral, node, "Expected array literal only");
List<String> result = Lists.newArrayList();
//noinspection ConstantConditions
ArrayLiteral array = (ArrayLiteral) node;
for (AstNode element : array.getElements()) {
result.add(stringLiteral(element));
}
return result;
}
/**
* Returns string literal or array of string literals.
*
* @see #stringLiteral(AstNode)
* @see #arrayStringLiteral(AstNode)
*/
private List<String> stringLiterals(final AstNode node) {
// string literal or array of string literals
if (node instanceof StringLiteral) {
return Lists.newArrayList(stringLiteral(node));
}
else if (node instanceof ArrayLiteral) {
return arrayStringLiteral(node);
}
else {
throw reportError(node, "Expected string literal or array of string literal only");
}
}
/**
* Maybe warn if a needed class has not been required.
*/
private void maybeWarnMissingRequires(final String className, final String usage) {
if (warnings) {
if (!current.getDependencies().contains(className)) {
log.warn(String.format("Class '%s' missing requires for '%s' usage of: %s",
current.getName(), usage, className));
}
}
}
/**
* Process an {@code Ext.define} class definition.
*/
private void processClassDef(final FunctionCall node) {
List<AstNode> args = node.getArguments();
checkState(args.size() >= 2, node,
"Invalid number of arguments for Ext.define"); // simple def or def with callback
// class-name
checkState(args.get(0) instanceof StringLiteral, node, "Ext.define arg[0] must be string");
String className = nameOf(args.get(0));
// try and avoid file-name/class-name mismatches early
sanityCheckClassName(className);
current = new ClassDef(className, source);
classes.put(className, current);
// class def object
checkState(args.get(1) instanceof ObjectLiteral, node, "Ext.define arg[1] must be object");
ObjectLiteral obj = (ObjectLiteral) args.get(1);
for (ObjectProperty prop : obj.getElements()) {
String name = nameOf(prop.getLeft());
switch (name) {
// ExtJS core class def
case "extend":
// string literal only
current.setExtend(stringLiteral(prop.getRight()));
break;
case "override":
// string literal only
current.setOverride(stringLiteral(prop.getRight()));
break;
case "requires":
// array of string literals only
current.setRequires(arrayStringLiteral(prop.getRight()));
break;
case "require":
// complain if we found 'require' this almost certainly should be 'requires'
log.warn(String.format("Found 'require' and probably should be 'requires' in: %s#%s", source, prop.getLineno()));
break;
case "uses":
// array of string literals only
current.setUses(arrayStringLiteral(prop.getRight()));
break;
case "alternateClassName": {
// string literal or array of string literals
current.getAlternateClassName().addAll(stringLiterals(prop.getRight()));
break;
}
case "alias": {
// string literal or array of string literals
current.getAlias().addAll(stringLiterals(prop.getRight()));
break;
}
case "xtype": {
// string literal only
current.getAlias().add("widget." + stringLiteral(prop.getRight()));
break;
}
case "mixins": {
// array of strings, or object
List<String> mixins = Lists.newArrayList();
if (prop.getRight() instanceof ArrayLiteral) {
mixins.addAll(arrayStringLiteral(prop.getRight()));
}
else if (prop.getRight() instanceof ObjectLiteral) {
ObjectLiteral child = (ObjectLiteral) prop.getRight();
for (ObjectProperty element : child.getElements()) {
mixins.add(stringLiteral(element.getRight()));
}
}
else {
throw reportError(prop.getRight(), "Expected array or object literal only");
}
current.setMixins(mixins);
break;
}
}
// Additional stuff we have to detect for ExtJS MVC support
if (isExtends("Ext.app.Application")) {
// class looks like an application, process more fields
switch (name) {
case "controllers":
// array of string literals only
current.setMvcControllers(arrayStringLiteral(prop.getRight()));
break;
case "models":
// array of string literals only
current.setMvcModels(arrayStringLiteral(prop.getRight()));
break;
case "stores":
// array of string literals only
current.setMvcStores(arrayStringLiteral(prop.getRight()));
break;
case "views":
// array of string literals only
current.setMvcViews(arrayStringLiteral(prop.getRight()));
break;
}
}
else if (isMvcClass("controller")) {
// class looks like a controller, process more fields
switch (name) {
case "models":
// array of string literals only
current.setMvcModels(arrayStringLiteral(prop.getRight()));
break;
case "stores":
// array of string literals only
current.setMvcStores(arrayStringLiteral(prop.getRight()));
break;
case "views":
// array of string literals only
current.setMvcViews(arrayStringLiteral(prop.getRight()));
break;
}
}
else if (isMvcClass("store")) {
// class looks like a store, process more fields
switch (name) {
case "model":
// string literal only
current.getDependencies().add(stringLiteral(prop.getRight()));
break;
}
}
}
}
/**
* Complain if classes are defined with wrong names, or in wrong files.
*/
private void sanityCheckClassName(final String className) {
String found = source.toURI().getPath();
String expected = className.replace('.', '/') + ".js";
if (!found.endsWith(expected)) {
log.warn(String.format("Expected class '%s' to be defined in filename '%s', but was defined in: %s",
className, expected, found));
}
}
/**
* Highly hackish means to determine given class-name is a MVC type.
* Requires namespace to be configured.
*/
private boolean isMvcClass(final String type) {
return namespace != null && current.getName().startsWith(String.format("%s.%s.", namespace, type));
}
/**
* Check if current class extends (directly) given class-name.
*/
private boolean isExtends(final String className) {
String superClass = current.getExtend();
return superClass != null && superClass.equals(className);
}
}
}