feature: Plugin model for TypeScript indexer (#3787)

* add plugin interface

* Adds tests for plugin model

* Use path to VName function instead of Map

* Remove compilation unit from plugin

* refactor: `Vistor` -> `Visitor`

* differentiate node kind/value in plugin test
diff --git a/kythe/typescript/indexer.ts b/kythe/typescript/indexer.ts
index 5e6a40e..74dc7b9 100644
--- a/kythe/typescript/indexer.ts
+++ b/kythe/typescript/indexer.ts
@@ -33,6 +33,17 @@
 }
 
 /**
+ * A indexer plugin adds extra functionality with the same inputs as the base
+ * indexer.
+ */
+export interface Plugin {
+  name: string;
+  index:
+      (pathToVName: (path: string) => VName, paths: string[],
+       program: ts.Program, emit?: (obj: {}) => void) => void;
+}
+
+/**
  * toArray converts an Iterator to an array of its values.
  * It's necessary when running in ES5 environments where for-of loops
  * don't iterate through Iterators.
@@ -54,6 +65,21 @@
 }
 
 /**
+ * getFileVName returns the VName for a given file path.
+ */
+function getFileVName(
+    path: string, cache: Map<string, VName>, compilationUnit: VName): VName {
+  const vname = cache.get(path);
+  return {
+    signature: '',
+    language: '',
+    corpus: vname && vname.corpus ? vname.corpus : compilationUnit.corpus,
+    root: vname && vname.corpus ? vname.root : compilationUnit.root,
+    path: vname && vname.path ? vname.path : path,
+  };
+}
+
+/**
  * TSNamespace represents the two namespaces of TypeScript: types and values.
  * A given symbol may be a type, it may be a value, and the two may even
  * be unrelated.
@@ -70,7 +96,7 @@
 }
 
 /** Visitor manages the indexing process for a single TypeScript SourceFile. */
-class Vistor {
+class Visitor {
   /** kFile is the VName for the 'file' node representing the source file. */
   kFile: VName;
 
@@ -166,15 +192,7 @@
    * newFileVName returns a new VName for the given file path.
    */
   newFileVName(path: string): VName {
-    const vname = this.pathVNames.get(path);
-    return {
-      signature: '',
-      language: '',
-      corpus: vname && vname.corpus ? vname.corpus :
-                                      this.compilationUnit.corpus,
-      root: vname && vname.corpus ? vname.root : this.compilationUnit.root,
-      path: vname && vname.path ? vname.path : path,
-    };
+    return getFileVName(path, this.pathVNames, this.compilationUnit);
   }
 
   /**
@@ -790,7 +808,7 @@
       kFunc = this.newVName('TODO', 'TODOPath');
     }
     if (kFunc) {
-        this.emitEdge(this.newAnchor(decl), 'defines', kFunc);
+      this.emitEdge(this.newAnchor(decl), 'defines', kFunc);
     }
 
     if (kFunc && decl.parent) {
@@ -1030,6 +1048,8 @@
  * @param pathVNames A map of file path to path-specific VName.
  * @param emit If provided, a function that receives objects as they are
  *     emitted; otherwise, they are printed to stdout.
+ * @param plugins If provided, a list of plugin indexers to run after the
+ *     TypeScript program has been indexed.
  * @param readFile If provided, a function that reads a file as bytes to a
  *     Node Buffer.  It'd be nice to just reuse program.getSourceFile but
  *     unfortunately that returns a (Unicode) string and we need to get at
@@ -1037,7 +1057,7 @@
  */
 export function index(
     vname: VName, pathVNames: Map<string, VName>, paths: string[],
-    program: ts.Program, emit?: (obj: {}) => void,
+    program: ts.Program, emit?: (obj: {}) => void, plugins?: Plugin[],
     readFile: (path: string) => Buffer = fs.readFileSync) {
   // Note: we only call getPreEmitDiagnostics (which causes type checking to
   // happen) on the input paths as provided in paths.  This means we don't
@@ -1081,12 +1101,20 @@
       throw new Error(`requested indexing ${path} not found in program`);
     }
     const visitor =
-        new Vistor(vname, pathVNames, program, sourceFile, getOffsetTable);
+        new Visitor(vname, pathVNames, program, sourceFile, getOffsetTable);
     if (emit != null) {
       visitor.emit = emit;
     }
     visitor.index();
   }
+
+  if (plugins) {
+    for (const plugin of plugins) {
+      plugin.index(
+          (path: string) => getFileVName(path, pathVNames, vname), paths,
+          program, emit);
+    }
+  }
 }
 
 /**
diff --git a/kythe/typescript/test.ts b/kythe/typescript/test.ts
index cfc1636..950861e 100644
--- a/kythe/typescript/test.ts
+++ b/kythe/typescript/test.ts
@@ -60,8 +60,8 @@
  * be run async; if there's an error, it will reject the promise.
  */
 function verify(
-    host: ts.CompilerHost, options: ts.CompilerOptions,
-    test: string): Promise<void> {
+    host: ts.CompilerHost, options: ts.CompilerOptions, test: string,
+    plugins?: indexer.Plugin[]): Promise<void> {
   const compilationUnit: indexer.VName = {
     corpus: 'testcorpus',
     root: '',
@@ -81,7 +81,7 @@
 
   indexer.index(compilationUnit, new Map(), [test], program, (obj: {}) => {
     verifier.stdin.write(JSON.stringify(obj) + '\n');
-  });
+  }, plugins);
   verifier.stdin.end();
 
   return new Promise<void>((resolve, reject) => {
@@ -102,7 +102,7 @@
   assert.deepEqual(config.fileNames, [path.resolve('testdata/alt.ts')]);
 }
 
-async function testIndexer(args: string[]) {
+async function testIndexer(args: string[], plugins?: indexer.Plugin[]) {
   const config = indexer.loadTsConfig('testdata/tsconfig.json', 'testdata');
   let testPaths = args.map(arg => path.resolve(arg));
   if (args.length === 0) {
@@ -117,7 +117,7 @@
     const start = new Date().valueOf();
     process.stdout.write(`${testName}: `);
     try {
-      await verify(host, config.options, test);
+      await verify(host, config.options, test, plugins);
     } catch (e) {
       console.log('FAIL');
       throw e;
@@ -128,9 +128,38 @@
   return 0;
 }
 
+async function testPlugin() {
+  const plugin: indexer.Plugin = {
+    name: 'TestPlugin',
+    index(
+        pathToVName: (path: string) => indexer.VName, paths: string[],
+        program: ts.Program, emit?: (obj: {}) => void) {
+      for (const testPath of paths) {
+        const relPath = path.relative(
+                                program.getCompilerOptions().rootDir!,
+                                program.getSourceFile(testPath)!.fileName)
+                            .replace(/\.(d\.)?ts$/, '');
+
+        const pluginMod = {
+          ...pathToVName(relPath),
+          signature: 'plugin-module',
+          language: 'plugin-language',
+        };
+        emit!({
+          source: pluginMod,
+          fact_name: '/kythe/node/pluginKind',
+          fact_value: Buffer.from('pluginRecord').toString('base64'),
+        });
+      }
+    },
+  };
+  return testIndexer(['testdata/plugin.ts'], [plugin]);
+}
+
 async function testMain(args: string[]) {
   testLoadTsConfig();
   await testIndexer(args);
+  await testPlugin();
 }
 
 testMain(process.argv.slice(2))
diff --git a/kythe/typescript/testdata/plugin.ts b/kythe/typescript/testdata/plugin.ts
new file mode 100644
index 0000000..7639306
--- /dev/null
+++ b/kythe/typescript/testdata/plugin.ts
@@ -0,0 +1,5 @@
+// Tests that a plugin emits entries on top of the base indexer.
+
+// From the TypeScript indexer
+//- _Mod=vname("module", _, _, "testdata/plugin", "typescript").node/kind record
+//- _PluginMod=vname("plugin-module", _, _, "testdata/plugin", "plugin-language").node/pluginKind pluginRecord
diff --git a/kythe/typescript/testdata/tsconfig.json b/kythe/typescript/testdata/tsconfig.json
index 2da85c9..9a059fb 100644
--- a/kythe/typescript/testdata/tsconfig.json
+++ b/kythe/typescript/testdata/tsconfig.json
@@ -28,6 +28,7 @@
         ]
     },
     "exclude": [
-        "compilefail.ts"
+        "compilefail.ts",
+	"plugin.ts"
     ]
 }