diff --git a/src/com/google/javascript/jscomp/Compiler.java b/src/com/google/javascript/jscomp/Compiler.java index 956fbc4fa50..b8d368e4ad8 100644 --- a/src/com/google/javascript/jscomp/Compiler.java +++ b/src/com/google/javascript/jscomp/Compiler.java @@ -29,6 +29,8 @@ import com.google.javascript.jscomp.CompilerOptions.DevMode; import com.google.javascript.jscomp.ReferenceCollectingCallback.ReferenceCollection; import com.google.javascript.jscomp.TypeValidator.TypeMismatch; +import com.google.javascript.jscomp.deps.DependencyInfo; +import com.google.javascript.jscomp.deps.JsFileParser; import com.google.javascript.jscomp.deps.ModuleLoader; import com.google.javascript.jscomp.deps.SortedDependencies.MissingProvideException; import com.google.javascript.jscomp.parsing.Config; @@ -1466,6 +1468,10 @@ Node parseInputs() { this.moduleLoader = new ModuleLoader(this, options.moduleRoots, inputs); + if (options.processCommonJSModules) { + this.moduleLoader.setPackageJsonMainEntries(processJsonInputs(inputs)); + } + if (options.lowerFromEs6()) { processEs6Modules(); } @@ -1677,6 +1683,34 @@ private void repartitionInputs() { rebuildInputsFromModules(); } + /** + * Transforms JSON files to a module export that closure compiler can + * process and keeps track of any "main" entries in package.json files. + */ + Map processJsonInputs(List inputsToProcess) { + RewriteJsonToModule rewriteJson = new RewriteJsonToModule(this); + for (CompilerInput input : inputsToProcess) { + if (!input.getSourceFile().getOriginalPath().endsWith(".json")) { + continue; + } + + input.setCompiler(this); + + try { + // JSON objects need wrapped in parens to parse properly + input.getSourceFile().setCode("(" + input.getSourceFile().getCode() + ")"); + } catch (IOException e) { + this.getErrorManager().report(CheckLevel.ERROR, + JSError.make(AbstractCompiler.READ_ERROR, input.getSourceFile().getOriginalPath())); + continue; + } + + Node root = input.getAstRoot(this); + rewriteJson.process(null, root); + } + return rewriteJson.getPackageJsonMainEntries(); + } + void processEs6Modules() { processEs6Modules(inputs, false); } diff --git a/src/com/google/javascript/jscomp/RewriteJsonToModule.java b/src/com/google/javascript/jscomp/RewriteJsonToModule.java new file mode 100644 index 00000000000..4e86629faf8 --- /dev/null +++ b/src/com/google/javascript/jscomp/RewriteJsonToModule.java @@ -0,0 +1,150 @@ +/* + * Copyright 2016 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; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableMap; +import com.google.javascript.rhino.IR; +import com.google.javascript.rhino.Node; +import java.util.HashMap; +import java.util.Map; + +/** + * Rewrites a JSON file to be a module export. So that the JSON file + * parses correctly, it is wrapped in an EXPR_RESULT. The pass makes + * only basic checks that the file provided is valid JSON. It is + * not a full JSON validator. + * + * Looks for JSON files named "package.json" so that + * the "main" property can be used as an alias in module + * name resolution. + */ +public class RewriteJsonToModule extends NodeTraversal.AbstractPostOrderCallback implements CompilerPass { + public static final DiagnosticType JSON_UNEXPECTED_TOKEN = DiagnosticType.error( + "JSC_JSON_UNEXPECTED_TOKEN", + "Unexpected JSON token"); + + private final Map packageJsonMainEntries; + private final Compiler compiler; + + /** + * Creates a new RewriteJsonToModule instance which can be used to + * rewrite JSON files to modules. + * + * @param compiler The compiler + */ + public RewriteJsonToModule(Compiler compiler) { + this.compiler = compiler; + this.packageJsonMainEntries = new HashMap<>(); + } + + public ImmutableMap getPackageJsonMainEntries() { + return ImmutableMap.copyOf(packageJsonMainEntries); + } + + /** + * Module rewriting is done a on per-file basis prior to main compilation. + * The root node for each file is a SCRIPT - not the typical jsRoot of other passes. + */ + @Override + public void process(Node externs, Node root) { + Preconditions.checkState(root.isScript()); + NodeTraversal.traverseEs6(compiler, root, this); + } + + @Override + public void visit(NodeTraversal t, Node n, Node parent) { + switch (n.getToken()) { + case SCRIPT: + if (n.getChildCount() != 1) { + compiler.report(t.makeError(n, JSON_UNEXPECTED_TOKEN)); + } else { + visitScript(t, n, parent); + } + return; + + case OBJECTLIT: + case ARRAYLIT: + case NUMBER: + case TRUE: + case FALSE: + case NULL: + case STRING: + break; + + case STRING_KEY: + if (!n.isQuotedString() || n.getChildCount() != 1) { + compiler.report(t.makeError(n, JSON_UNEXPECTED_TOKEN)); + } + break; + + case EXPR_RESULT: + if (!parent.isScript()) { + compiler.report(t.makeError(n, JSON_UNEXPECTED_TOKEN)); + } + break; + + default: + compiler.report(t.makeError(n, JSON_UNEXPECTED_TOKEN)); + break; + } + + if (n.getLineno() == 1) { + // We wrapped the expression in parens so our first-line columns are off by one. + // We need to correct for this. + n.setCharno(n.getCharno() - 1); + compiler.reportCodeChange(); + } + } + + /** + * For script nodes of JSON objects, add a module variable assignment + * so the result is exported. + * + * If the file path ends with "/package.json", look for a "main" + * key in the object literal and track it as a module alias. + */ + private void visitScript(NodeTraversal t, Node n, Node parent) { + if (n.getChildCount() != 1 || !n.getFirstChild().isExprResult()) { + compiler.report(t.makeError(n, JSON_UNEXPECTED_TOKEN)); + return; + } + + Node jsonObject = n.getFirstFirstChild().detach(); + n.removeFirstChild(); + + String moduleName = t.getInput().getPath().toModuleName(); + + n.addChildToFront(IR.var(IR.name(moduleName).useSourceInfoFrom(jsonObject), + jsonObject).useSourceInfoFrom(jsonObject)); + + n.addChildToFront( + IR.exprResult( + IR.call(IR.getprop(IR.name("goog"), IR.string("provide")), IR.string(moduleName))) + .useSourceInfoIfMissingFromForTree(n)); + + String inputPath = t.getInput().getSourceFile().getOriginalPath(); + if (inputPath.endsWith("/package.json") && jsonObject.isObjectLit()) { + Node main = NodeUtil.getFirstPropMatchingKey(jsonObject, "main"); + if (main != null && main.isString()) { + String dirName = inputPath.substring(0, inputPath.length() - "package.json".length()); + packageJsonMainEntries.put(inputPath, dirName + main.getString()); + } + } + + compiler.reportCodeChange(); + } +} diff --git a/src/com/google/javascript/jscomp/deps/ModuleLoader.java b/src/com/google/javascript/jscomp/deps/ModuleLoader.java index 37be3bbda39..f48c1ed1ee2 100644 --- a/src/com/google/javascript/jscomp/deps/ModuleLoader.java +++ b/src/com/google/javascript/jscomp/deps/ModuleLoader.java @@ -21,14 +21,21 @@ import com.google.common.annotations.GwtIncompatible; import com.google.common.base.Function; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ImmutableSortedMap; import com.google.common.collect.Iterables; import com.google.javascript.jscomp.CheckLevel; import com.google.javascript.jscomp.DiagnosticType; import com.google.javascript.jscomp.ErrorHandler; import com.google.javascript.jscomp.JSError; import java.nio.file.Paths; +import java.util.Comparator; import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; import javax.annotation.Nullable; /** @@ -59,6 +66,12 @@ public final class ModuleLoader { /** The set of all known input module URIs (including trailing .js), after normalization. */ private final ImmutableSet modulePaths; + /** Named modules found in node_modules folders */ + private final ImmutableSortedMap> nodeModulesRegistry; + + /** Named modules found in node_modules folders */ + private ImmutableMap packageJsonMainEntries; + /** Used to canonicalize paths before resolution. */ private final PathResolver pathResolver; @@ -82,6 +95,10 @@ public ModuleLoader( resolvePaths( Iterables.transform(Iterables.transform(inputs, UNWRAP_DEPENDENCY_INFO), pathResolver), moduleRootPaths); + + this.packageJsonMainEntries = ImmutableMap.of(); + + this.nodeModulesRegistry = buildRegistry(this.modulePaths); } public ModuleLoader(@Nullable ErrorHandler errorHandler, @@ -89,6 +106,13 @@ public ModuleLoader(@Nullable ErrorHandler errorHandler, this(errorHandler, moduleRoots, inputs, PathResolver.RELATIVE); } + public Map getPackageJsonMainEntries() { + return this.packageJsonMainEntries; + } + + public void setPackageJsonMainEntries(Map packageJsonMainEntries) { + this.packageJsonMainEntries = ImmutableMap.copyOf(packageJsonMainEntries); + } /** * A path to a module. Provides access to the module's closurized name @@ -125,21 +149,89 @@ public String toModuleName() { } /** - * Find a CommonJS module {@code requireName} relative to {@code context}. + * Find a CommonJS module {@code requireName}. See + * https://nodejs.org/api/modules.html#modules_all_together * @return The normalized module URI, or {@code null} if not found. */ public ModulePath resolveCommonJsModule(String requireName) { + String loadAddress; + // * the immediate name require'd - String loadAddress = locate(requireName); - if (loadAddress == null) { - // * the require'd name + /index.js - loadAddress = locate(requireName + MODULE_SLASH + "index.js"); + if (isAbsoluteIdentifier(requireName) || isRelativeIdentifier(requireName)) { + loadAddress = resolveCommonJsModuleFileOrDirectory(requireName); + } else { + loadAddress = resolveCommonJsModuleFromRegistry(requireName); + } + if (loadAddress != null) { + return new ModulePath(loadAddress); } + return null; + } + + private String resolveCommonJsModuleFile(String requireName) { + String[] extensions = {"", ".js", ".json"}; + + // Load as a file + for (int i = 0; i < extensions.length; i++) { + String loadAddress = locate(requireName + extensions[i]); + if (loadAddress != null) { + return loadAddress; + } + } + + return null; + } + + private String resolveCommonJsModuleFileOrDirectory(String requireName) { + String loadAddress = resolveCommonJsModuleFile(requireName); if (loadAddress == null) { - // * the require'd name with a potential trailing ".js" - loadAddress = locate(requireName + ".js"); + loadAddress = resolveCommonJsModuleDirectory(requireName); + } + return loadAddress; + } + + private String resolveCommonJsModuleDirectory(String requireName) { + String[] extensions = {MODULE_SLASH + "package.json", + MODULE_SLASH + "index.js", MODULE_SLASH + "index.json"}; + + // Load as a file + for (int i = 0; i < extensions.length; i++) { + String loadAddress = locate(requireName + extensions[i]); + if (loadAddress != null) { + if (i == 0) { + if (packageJsonMainEntries.containsKey(loadAddress)) { + return resolveCommonJsModuleFile(packageJsonMainEntries.get(loadAddress)); + } + } else { + return loadAddress; + } + } } - return loadAddress != null ? new ModulePath(loadAddress) : null; // could be null. + + return null; + } + + private String resolveCommonJsModuleFromRegistry(String requireName) { + for (Map.Entry> nodeModulesFolder : nodeModulesRegistry.entrySet()) { + if (!this.path.startsWith(nodeModulesFolder.getKey())) { + continue; + } + + // Load as a file + String fullModulePath = nodeModulesFolder.getKey() + "node_modules" + MODULE_SLASH + requireName; + String loadAddress = resolveCommonJsModuleFile(fullModulePath); + if (loadAddress != null) { + return loadAddress; + } + + // Load as a directory + loadAddress = resolveCommonJsModuleDirectory(fullModulePath); + if (loadAddress != null) { + return loadAddress; + } + } + + return null; } /** @@ -243,6 +335,67 @@ private static String normalize(String path, Iterable moduleRootPaths) { return path; } + /** + * Build the module registry from the set of module paths + */ + private static ImmutableSortedMap> buildRegistry( + ImmutableSet modulePaths) { + SortedMap> registry = new TreeMap<>(new Comparator() { + @Override + public int compare(String a, String b) { + // Order longest path first + int comparison = Integer.compare(b.length(), a.length()); + if (comparison != 0) { + return comparison; + } + + return a.compareTo(b); + } + }); + + // For each modulePath, find all the node_modules folders + // There might be more than one: + // /foo/node_modules/bar/node_modules/baz/foo_bar_baz.js + // Should add: + // /foo/ -> bar/node_modules/baz/foo_bar_baz.js + // /foo/node_modules/bar/ -> baz/foo_bar_baz.js + for (String modulePath : modulePaths) { + String[] nodeModulesDirs = modulePath.split(MODULE_SLASH + "node_modules" + MODULE_SLASH); + String parentPath = ""; + for (int i = 0; i < nodeModulesDirs.length - 1; i++) { + if (i + 1 < nodeModulesDirs.length) { + parentPath += nodeModulesDirs[i] + MODULE_SLASH; + } + String subPath = modulePath.substring(parentPath.length() + "node_modules/".length()); + + if (!registry.containsKey(parentPath)) { + registry.put(parentPath, new HashSet()); + } + registry.get(parentPath).add(subPath); + + parentPath += "node_modules" + MODULE_SLASH; + } + } + + + SortedMap> immutableRegistry = new TreeMap<>(new Comparator() { + @Override + public int compare(String a, String b) { + // Order longest path first + int comparison = Integer.compare(b.length(), a.length()); + if (comparison != 0) { + return comparison; + } + + return a.compareTo(b); + } + }); + for (Map.Entry> entry : registry.entrySet()) { + immutableRegistry.put(entry.getKey(), ImmutableSet.copyOf(entry.getValue())); + } + return ImmutableSortedMap.copyOfSorted(immutableRegistry); + } + /** An enum indicating whether to absolutize paths. */ public enum PathResolver implements Function { RELATIVE { diff --git a/test/com/google/javascript/jscomp/CompilerTest.java b/test/com/google/javascript/jscomp/CompilerTest.java index 2052c72f5f7..29146cdbbbe 100644 --- a/test/com/google/javascript/jscomp/CompilerTest.java +++ b/test/com/google/javascript/jscomp/CompilerTest.java @@ -139,6 +139,22 @@ public void testCommonJSMissingRequire() throws Exception { assertEquals(1, manager.getErrorCount()); } + public void testCommonJSInvalidJson() throws Exception { + List inputs = ImmutableList.of( + SourceFile.fromCode("/gin.js", "require('missing')"), + SourceFile.fromCode("/bar.json", "invalid json")); + Compiler compiler = initCompilerForCommonJS( + inputs, ImmutableList.of(ModuleIdentifier.forFile("/gin"))); + + ErrorManager manager = compiler.getErrorManager(); + if (manager.getErrorCount() > 0) { + String error = manager.getErrors()[0].toString(); + assertTrue( + "Unexpected error: " + error, + error.contains("Unexpected JSON token at /bar.js")); + } + } + private static String normalize(String path) { return path.replace(File.separator, "/"); } diff --git a/test/com/google/javascript/jscomp/ProcessCommonJSModulesTest.java b/test/com/google/javascript/jscomp/ProcessCommonJSModulesTest.java index 42161839c52..723e48df674 100644 --- a/test/com/google/javascript/jscomp/ProcessCommonJSModulesTest.java +++ b/test/com/google/javascript/jscomp/ProcessCommonJSModulesTest.java @@ -55,7 +55,7 @@ void testModules(String input, String expected) { public void testWithoutExports() { setFilename("test"); testModules( - LINE_JOINER.join("var name = require('other');", "name()"), + LINE_JOINER.join("var name = require('./other');", "name()"), LINE_JOINER.join( "goog.require('module$other');", "var name = module$other;", "module$other();")); test( @@ -64,7 +64,7 @@ public void testWithoutExports() { SourceFile.fromCode( Compiler.joinPathParts("test", "sub.js"), LINE_JOINER.join( - "var name = require('mod/name');", "(function() { module$mod$name(); })();"))), + "var name = require('../mod/name');", "(function() { module$mod$name(); })();"))), ImmutableList.of( SourceFile.fromCode( Compiler.joinPathParts("mod", "name.js"), @@ -84,7 +84,7 @@ public void testWithoutExports() { public void testExports() { setFilename("test"); testModules( - LINE_JOINER.join("var name = require('other');", "exports.foo = 1;"), + LINE_JOINER.join("var name = require('./other');", "exports.foo = 1;"), LINE_JOINER.join( "goog.provide('module$test');", "goog.require('module$other');", @@ -93,7 +93,7 @@ public void testExports() { "module$test.foo = 1;")); testModules( - LINE_JOINER.join("var name = require('other');", "module.exports = function() {};"), + LINE_JOINER.join("var name = require('./other');", "module.exports = function() {};"), LINE_JOINER.join( "goog.provide('module$test');", "goog.require('module$other');", @@ -105,7 +105,7 @@ public void testExportsInExpression() { setFilename("test"); testModules( LINE_JOINER.join( - "var name = require('other');", "var e;", "e = module.exports = function() {};"), + "var name = require('./other');", "var e;", "e = module.exports = function() {};"), LINE_JOINER.join( "goog.provide('module$test');", "goog.require('module$other');", @@ -115,7 +115,7 @@ public void testExportsInExpression() { "e$$module$test = module$test = function () {};")); testModules( - LINE_JOINER.join("var name = require('other');", "var e = module.exports = function() {};"), + LINE_JOINER.join("var name = require('./other');", "var e = module.exports = function() {};"), LINE_JOINER.join( "goog.provide('module$test');", "goog.require('module$other');", @@ -124,7 +124,7 @@ public void testExportsInExpression() { "var e$$module$test = module$test = function () {};")); testModules( - LINE_JOINER.join("var name = require('other');", "(module.exports = function() {})();"), + LINE_JOINER.join("var name = require('./other');", "(module.exports = function() {})();"), LINE_JOINER.join( "goog.provide('module$test');", "goog.require('module$other');", @@ -176,7 +176,7 @@ public void testVarRenaming() { public void testDash() { setFilename("test-test"); testModules( - LINE_JOINER.join("var name = require('other');", "exports.foo = 1;"), + LINE_JOINER.join("var name = require('./other');", "exports.foo = 1;"), LINE_JOINER.join( "goog.provide('module$test_test');", "goog.require('module$other');", @@ -200,7 +200,7 @@ public void testIndex() { public void testModuleName() { setFilename("foo/bar"); testModules( - LINE_JOINER.join("var name = require('other');", "module.exports = name;"), + LINE_JOINER.join("var name = require('../other');", "module.exports = name;"), LINE_JOINER.join( "goog.provide('module$foo$bar');", "goog.require('module$other');", @@ -341,7 +341,7 @@ public void testEs6ObjectShorthand() { "};")); testModules( - LINE_JOINER.join("var a = require('other');", "module.exports = {a: a};"), + LINE_JOINER.join("var a = require('./other');", "module.exports = {a: a};"), LINE_JOINER.join( "goog.provide('module$test');", "goog.require('module$other');", @@ -350,7 +350,7 @@ public void testEs6ObjectShorthand() { "module$test.a = module$other;")); testModules( - LINE_JOINER.join("var a = require('other');", "module.exports = {a};"), + LINE_JOINER.join("var a = require('./other');", "module.exports = {a};"), LINE_JOINER.join( "goog.provide('module$test');", "goog.require('module$other');", @@ -375,8 +375,8 @@ public void testRequireEnsure() { setFilename("test"); testModules( LINE_JOINER.join( - "require.ensure(['other'], function(require) {", - " var other = require('other');", + "require.ensure(['./other'], function(require) {", + " var other = require('./other');", " var bar = other;", "});"), LINE_JOINER.join( diff --git a/test/com/google/javascript/jscomp/RewriteJsonToModuleTest.java b/test/com/google/javascript/jscomp/RewriteJsonToModuleTest.java new file mode 100644 index 00000000000..c0526ce1113 --- /dev/null +++ b/test/com/google/javascript/jscomp/RewriteJsonToModuleTest.java @@ -0,0 +1,86 @@ +/* + * Copyright 2016 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; + +import com.google.javascript.rhino.Node; + +/** + * Unit tests for {@link RewriteJsonToModule} + */ + +public final class RewriteJsonToModuleTest extends CompilerTestCase { + + @Override + protected CompilerPass getProcessor(Compiler compiler) { + return new CompilerPass() { + @Override + public void process(Node externs, Node root) { + // No-op, RewriteJsonToModule handling is done directly after parsing. + } + }; + } + + @Override + protected CompilerOptions getOptions() { + CompilerOptions options = super.getOptions(); + // Trigger module processing after parsing. + options.setProcessCommonJSModules(true); + return options; + } + + @Override + protected int getNumRepetitions() { + return 1; + } + + public void testJsonFile() { + setFilename("/test.json"); + test( + "{ \"foo\": \"bar\"}", + LINE_JOINER.join( + "goog.provide('module$test_json')", + "var module$test_json = { \"foo\": \"bar\"};")); + + assertEquals(getLastCompiler().getModuleLoader().getPackageJsonMainEntries().size(), 0); + } + + public void testPackageJsonFile() { + setFilename("/package.json"); + test( + "{ \"main\": \"foo/bar/baz.js\"}", + LINE_JOINER.join( + "goog.provide('module$package_json')", + "var module$package_json = {\"main\": \"foo/bar/baz.js\"};")); + + assertEquals(getLastCompiler().getModuleLoader().getPackageJsonMainEntries().size(), 1); + assert(getLastCompiler().getModuleLoader().getPackageJsonMainEntries() + .containsKey("/package.json")); + assertEquals(getLastCompiler().getModuleLoader().getPackageJsonMainEntries() + .get("/package.json"), "/foo/bar/baz.js"); + } + + public void testPackageJsonWithoutMain() { + setFilename("/package.json"); + test( + "{\"other\": { \"main\": \"foo/bar/baz.js\"}}", + LINE_JOINER.join( + "goog.provide('module$package_json')", + "var module$package_json = {\"other\": { \"main\": \"foo/bar/baz.js\"}};")); + + assertEquals(getLastCompiler().getModuleLoader().getPackageJsonMainEntries().size(), 0); + } +} diff --git a/test/com/google/javascript/jscomp/deps/ModuleLoaderTest.java b/test/com/google/javascript/jscomp/deps/ModuleLoaderTest.java index 66b4d243a14..f28b9833e41 100644 --- a/test/com/google/javascript/jscomp/deps/ModuleLoaderTest.java +++ b/test/com/google/javascript/jscomp/deps/ModuleLoaderTest.java @@ -19,6 +19,7 @@ import static com.google.common.truth.Truth.assertThat; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.javascript.jscomp.CompilerInput; import com.google.javascript.jscomp.SourceFile; import junit.framework.TestCase; @@ -51,7 +52,6 @@ public void testLocateCommonJs() throws Exception { assertUri("A/index.js", loader.resolve("A/index.js")); assertUri("A/index.js", loader.resolve("B/index.js").resolveCommonJsModule("../A")); assertUri("A/index.js", loader.resolve("app.js").resolveCommonJsModule("./A")); - assertUri("A/index.js", loader.resolve("app.js").resolveCommonJsModule("A")); } public void testNormalizeUris() throws Exception { @@ -84,6 +84,41 @@ public void testCanonicalizePath() throws Exception { assertEquals("/", ModuleNames.canonicalizePath("/a/..")); } + public void testLocateCommonNodeModules() throws Exception { + ImmutableList compilerInputs = inputs( + "/A/index.js", + "/A/index.json", + "/node_modules/A/index.js", + "/node_modules/A/foo.js", + "/node_modules/A/node_modules/A/index.json", + "/B/package.json", + "/B/lib/b.js", + "/node_modules/B/package.json", + "/node_modules/B/lib/b.js"); + + ImmutableMap packageJsonMainEntries = ImmutableMap.of( + "/B/package.json", "/B/lib/b", + "/node_modules/B/package.json", "/node_modules/B/lib/b.js"); + + ModuleLoader loader = new ModuleLoader( + null, (new ImmutableList.Builder()).build(), compilerInputs); + loader.setPackageJsonMainEntries(packageJsonMainEntries); + + assertUri("/A/index.js", loader.resolve("/foo.js").resolveCommonJsModule("/A")); + assertUri("/A/index.js", loader.resolve("/foo.js").resolveCommonJsModule("./A")); + assertUri("/A/index.json", loader.resolve("/foo.js").resolveCommonJsModule("/A/index.json")); + + assertUri("/node_modules/A/index.js", loader.resolve("/foo.js").resolveCommonJsModule("A")); + assertUri("/node_modules/A/node_modules/A/index.json", + loader.resolve("/node_modules/A/foo.js").resolveCommonJsModule("A")); + assertUri("/node_modules/A/foo.js", + loader.resolve("/node_modules/A/index.js").resolveCommonJsModule("./foo")); + + assertUri("/B/lib/b.js", loader.resolve("/app.js").resolveCommonJsModule("/B")); + + assertUri("/node_modules/B/lib/b.js", loader.resolve("/app.js").resolveCommonJsModule("B")); + } + ImmutableList inputs(String... names) { ImmutableList.Builder builder = ImmutableList.builder(); for (String name : names) {