/*
* Copyright 2015 The Closure Compiler 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 com.google.javascript.jscomp.gwt.client;
import com.google.common.base.Function;
import com.google.common.base.Joiner;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Multiset;
import com.google.common.collect.Ordering;
import com.google.common.collect.TreeMultiset;
import com.google.gwt.core.client.EntryPoint;
import com.google.javascript.jscomp.Compiler;
import com.google.javascript.jscomp.CompilerOptions;
import com.google.javascript.jscomp.NodeTraversal;
import com.google.javascript.jscomp.NodeTraversal.AbstractPostOrderCallback;
import com.google.javascript.jscomp.SourceFile;
import com.google.javascript.jscomp.Var;
import com.google.javascript.jscomp.gwt.client.Util.JsArray;
import com.google.javascript.jscomp.gwt.client.Util.JsObject;
import com.google.javascript.jscomp.gwt.client.Util.JsRegExp;
import com.google.javascript.jscomp.parsing.Config;
import com.google.javascript.jscomp.parsing.ParserRunner;
import com.google.javascript.jscomp.parsing.parser.trees.Comment;
import com.google.javascript.rhino.ErrorReporter;
import com.google.javascript.rhino.InputId;
import com.google.javascript.rhino.Node;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import javax.annotation.Nullable;
import jsinterop.annotations.JsFunction;
import jsinterop.annotations.JsMethod;
/**
* GWT module to parse files for dependency and
* {@literal @}{@code fileoverview} annotation
* information.
*/
public class JsfileParser implements EntryPoint {
/**
* All the information parsed out of a single file.
* Exported as a JSON object:
* <pre> {@code {
* "custom_annotations": {?Array<[string, string]>}, @.*
* "goog": {?bool}, whether 'goog' is implicitly required
* "has_soy_delcalls": {?Array<string>}, @fileoverview @hassoydelcall {.*}
* "has_soy_deltemplates": {?Array<string>}, @fileoverview @hassoydeltemplate {.*}
* "imported_modules": {?Array<string>}, import ... from .*
* "is_config": {?bool}, @fileoverview @config
* "is_externs": {?bool}, @fileoverview @externs
* "load_flags": {?Array<[string, string]>},
* "mod_name": {?Array<string>}, @fileoverview @modName .*, @modName {.*}
* "mods": {?Array<string>}, @fileoverview @mods {.*}
* "provide_goog": {?bool}, @fileoverview @provideGoog
* "provides": {?Array<string>},
* "requires": {?Array<string>}, note: look for goog.* for 'goog'
* "requires_css": {?Array<string>}, @fileoverview @requirecss {.*}
* "testonly": {?bool}, goog.setTestOnly
* "visibility: {?Array<string>}, @fileoverview @visibility {.*}
* }}</pre>
* Any trivial values are omitted.
*/
static final class FileInfo {
final ErrorReporter reporter;
boolean goog = false;
boolean isConfig = false;
boolean isExterns = false;
boolean provideGoog = false;
boolean testonly = false;
final Set<String> hasSoyDelcalls = new TreeSet<>();
final Set<String> hasSoyDeltemplates = new TreeSet<>();
final Set<String> importedModules = new TreeSet<>();
final List<String> modName = new ArrayList<>();
final List<String> mods = new ArrayList<>();
// Note: multiple copies doesn't make much sense, but we report
// each copy so that calling code can choose how to handle it
final Multiset<String> provides = TreeMultiset.create();
final Multiset<String> requires = TreeMultiset.create();
final Multiset<String> requiresCss = TreeMultiset.create();
final Multiset<String> visibility = TreeMultiset.create();
final Set<JsArray<String>> customAnnotations = assoc();
final Set<JsArray<String>> loadFlags = assoc();
FileInfo(ErrorReporter reporter) {
this.reporter = reporter;
}
private void handleGoog() {
if (provideGoog) {
provides.add("goog");
} else if (goog) {
requires.add("goog");
}
}
/** Exports the file info as a JSON object. */
JsObject<Object> full() {
handleGoog();
return new SparseObject()
.set("custom_annotations", customAnnotations)
.set("goog", goog)
.set("has_soy_delcalls", hasSoyDelcalls)
.set("has_soy_deltemplates", hasSoyDeltemplates)
.set("imported_modules", importedModules)
.set("is_config", isConfig)
.set("is_externs", isExterns)
.set("load_flags", loadFlags)
.set("modName", modName)
.set("mods", mods)
.set("provide_goog", provideGoog)
.set("provides", provides)
.set("requires", requires)
.set("requiresCss", requiresCss)
.set("testonly", testonly)
.set("visibility", visibility)
.object;
}
}
/** Represents a single JSDoc annotation, with an optional argument. */
private static class CommentAnnotation {
/** Annotation name, e.g. "@fileoverview" or "@externs". */
final String name;
/**
* Annotation value: either the bare identifier immediately after the
* annotation, or else string in braces.
*/
final String value;
CommentAnnotation(String name, String value) {
this.name = name;
this.value = value;
}
/** Returns all the annotations in a given comment string. */
static List<CommentAnnotation> parse(String comment) {
// TODO(sdh): This is reinventing a large part of JSDocInfoParser. We should
// try to consolidate as much as possible. This requires several steps:
// 1. Make all the annotations we look for first-class in JSDocInfo
// 2. Support custom annotations (may already be done?)
// 3. Fix up existing code so that all these annotations are in @fileoverview
// 4. Change this code to simply inspect the script's JSDocInfo instead of re-parsing
JsRegExp re = new JsRegExp(
ANNOTATION_RE,
"g");
JsRegExp.Match match;
List<CommentAnnotation> out = new ArrayList<>();
while ((match = re.exec(comment)) != null) {
boolean modName = match.get(OTHER_ANNOTATION_GROUP) == null;
String name = modName ? "@modName" : match.get(OTHER_ANNOTATION_GROUP);
String value =
Strings.nullToEmpty(match.get(modName ? MODNAME_VALUE_GROUP : OTHER_VALUE_GROUP));
out.add(new CommentAnnotation(name, value));
}
return out;
}
private static final String ANNOTATION_RE =
Joiner.on("").join(
// Don't match "@" in the middle of a word
"(?:[^a-zA-Z0-9_$]|^)",
"(?:",
// Case 1: @modName with a single identifier and no braces
"@modName[\\t\\v\\f ]*([^{\\t\\n\\v\\f\\r ][^\\t\\n\\v\\f\\r ]*)",
"|",
// Case 2: Everything else, with an optional brace-delimited argument
"(@[a-zA-Z]+)(?:\\s*\\{\\s*([^}\\t\\n\\v\\f\\r ]+)\\s*\\})?",
")");
private static final int MODNAME_VALUE_GROUP = 1;
private static final int OTHER_ANNOTATION_GROUP = 2;
private static final int OTHER_VALUE_GROUP = 3;
}
/** Method exported to JS to parse a file for dependencies and annotations. */
@JsMethod(name = "gjd", namespace = "jscomp")
public static JsObject<Object> gjd(String code, String filename, @Nullable Reporter reporter) {
return parse(code, filename, reporter).full();
}
/** Internal implementation to produce the {@link FileInfo} object. */
private static FileInfo parse(String code, String filename, @Nullable Reporter reporter) {
ErrorReporter errorReporter = new DelegatingReporter(reporter);
Compiler compiler = new Compiler();
compiler.init(
ImmutableList.<SourceFile>of(), ImmutableList.<SourceFile>of(), new CompilerOptions());
Config config =
ParserRunner.createConfig(
// TODO(sdh): ES6 STRICT, with a non-strict fallback - then give warnings.
Config.LanguageMode.ECMASCRIPT6,
Config.JsDocParsing.INCLUDE_DESCRIPTIONS_NO_WHITESPACE,
Config.RunMode.KEEP_GOING,
/* extraAnnotationNames */ ImmutableSet.<String>of(),
/* parseInlineSourceMaps */ true,
Config.StrictMode.SLOPPY);
SourceFile source = SourceFile.fromCode(filename, code);
FileInfo info = new FileInfo(errorReporter);
ParserRunner.ParseResult parsed = ParserRunner.parse(source, code, config, errorReporter);
parsed.ast.setInputId(new InputId(filename));
String version = parsed.features.version();
if (!version.equals("es3")) {
info.loadFlags.add(JsArray.of("lang", version));
}
for (Comment comment : parsed.comments) {
if (comment.type == Comment.Type.JSDOC) {
parseComment(comment, info);
}
}
NodeTraversal.traverseEs6(compiler, parsed.ast, new Traverser(info));
return info;
}
/** Mutates {@code info} with information from the given {@code comment}. */
private static void parseComment(Comment comment, FileInfo info) {
boolean fileOverview = comment.value.contains("@fileoverview");
for (CommentAnnotation annotation : CommentAnnotation.parse(comment.value)) {
switch (annotation.name) {
case "@fileoverview":
case "@author":
case "@see":
case "@link":
break;
case "@mods":
if (!annotation.value.isEmpty()) {
info.mods.add(annotation.value);
}
break;
case "@visibility":
if (!annotation.value.isEmpty()) {
info.visibility.add(annotation.value);
}
break;
case "@modName":
if (!annotation.value.isEmpty()) {
info.modName.add(annotation.value);
}
break;
case "@config":
info.isConfig = true;
break;
case "@provideGoog":
info.provideGoog = true;
break;
case "@requirecss":
if (!annotation.value.isEmpty()) {
info.requiresCss.add(annotation.value);
}
break;
case "@hassoydeltemplate":
if (!annotation.value.isEmpty()) {
info.hasSoyDeltemplates.add(annotation.value);
}
break;
case "@hassoydelcall":
if (!annotation.value.isEmpty()) {
info.hasSoyDelcalls.add(annotation.value);
}
break;
case "@externs":
info.isExterns = true;
break;
case "@enhanceable":
case "@pintomodule":
info.customAnnotations.add(
JsArray.of(annotation.name.substring(1), annotation.value));
break;
case "@enhance":
if (!annotation.value.isEmpty()) {
info.customAnnotations.add(
JsArray.of(annotation.name.substring(1), annotation.value));
}
break;
default:
if (fileOverview) {
info.customAnnotations.add(
JsArray.of(annotation.name.substring(1), annotation.value));
}
}
}
}
/** Traverser that mutates {@code #info} with information from the AST. */
private static class Traverser extends AbstractPostOrderCallback {
final FileInfo info;
Traverser(FileInfo info) {
this.info = info;
}
@Override
public void visit(NodeTraversal traversal, Node node, Node parent) {
// Look for goog.* calls
if (node.isGetProp() && node.getFirstChild().isName()
&& node.getFirstChild().getString().equals("goog")) {
Var root = traversal.getScope().getVar("goog");
if (root == null) {
info.goog = true;
if (parent.isCall() && parent.getChildCount() < 3) {
Node arg;
switch (node.getLastChild().getString()) {
case "module":
info.loadFlags.add(JsArray.of("module", "goog"));
// fall through
case "provide":
arg = parent.getSecondChild();
if (arg.isString()) {
info.provides.add(arg.getString());
} // TODO(sdh): else warning?
break;
case "require":
arg = parent.getSecondChild();
if (arg.isString()) {
info.requires.add(arg.getString());
} // TODO(sdh): else warning?
break;
case "setTestOnly":
info.testonly = true;
break;
default:
// Do nothing
}
}
}
}
// Look for ES6 import statements
if (node.isImport()) {
Node moduleSpecifier = node.getChildAtIndex(2);
// NOTE: previous tool was more forgiving here.
Preconditions.checkState(moduleSpecifier.isString());
info.loadFlags.add(JsArray.of("module", "es6"));
info.importedModules.add(moduleSpecifier.getString());
} else if (node.isExport()) {
info.loadFlags.add(JsArray.of("module", "es6"));
}
}
}
/** JS function interface for reporting errors. */
@JsFunction
public interface Reporter {
void report(boolean fatal, String message, String sourceName, int line, int lineOffset);
}
private static final class DelegatingReporter implements ErrorReporter {
final Reporter delegate;
DelegatingReporter(Reporter delegate) {
this.delegate = delegate != null ? delegate : NULL_REPORTER;
}
@Override
public void warning(String message, String sourceName, int line, int lineOffset) {
delegate.report(false, message, sourceName, line, lineOffset);
}
@Override
public void error(String message, String sourceName, int line, int lineOffset) {
delegate.report(true, message, sourceName, line, lineOffset);
}
}
private static final Reporter NULL_REPORTER = new Reporter() {
@Override
public void report(
boolean fatal, String message, String sourceName, int line, int lineOffset) {}
};
@Override
public void onModuleLoad() {}
/** Returns an associative multimap. */
private static Set<JsArray<String>> assoc() {
return new TreeSet<>(
Ordering.<String>natural()
.lexicographical()
.onResultOf(
new Function<JsArray<String>, List<String>>() {
@Override public List<String> apply(JsArray<String> arg) {
return arg.asList();
}
}));
}
/** Sparse object helper class: only adds non-trivial values. */
private static class SparseObject {
final JsObject<Object> object = new JsObject<>();
SparseObject set(String key, Iterable<?> iterable) {
JsArray<?> array = JsArray.copyOf(iterable);
if (array.getLength() > 0) {
object.set(key, array);
}
return this;
}
SparseObject set(String key, String value) {
if (value != null && !value.isEmpty()) {
object.set(key, value);
}
return this;
}
SparseObject set(String key, boolean value) {
if (value) {
object.set(key, value);
}
return this;
}
}
}