diff --git a/Jakefile.js b/Jakefile.js
index 2f0ce4aa399cd..c4f56c559f364 100644
--- a/Jakefile.js
+++ b/Jakefile.js
@@ -153,7 +153,8 @@ var harnessSources = harnessCoreSources.concat([
"tsconfigParsing.ts",
"commandLineParsing.ts",
"convertCompilerOptionsFromJson.ts",
- "convertTypingOptionsFromJson.ts"
+ "convertTypingOptionsFromJson.ts",
+ "tsserverProjectSystem.ts"
].map(function (f) {
return path.join(unittestsDirectory, f);
})).concat([
diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts
index 5502f74408e03..8eae43b503d3f 100644
--- a/src/server/editorServices.ts
+++ b/src/server/editorServices.ts
@@ -1138,7 +1138,7 @@ namespace ts.server {
else {
this.log("No config files found.");
}
- return {};
+ return configFileName ? { configFileName } : {};
}
/**
diff --git a/tests/cases/unittests/tsserverProjectSystem.ts b/tests/cases/unittests/tsserverProjectSystem.ts
new file mode 100644
index 0000000000000..c69821ced5f98
--- /dev/null
+++ b/tests/cases/unittests/tsserverProjectSystem.ts
@@ -0,0 +1,294 @@
+///
+
+namespace ts {
+ function notImplemented(): any {
+ throw new Error("Not yet implemented");
+ }
+
+ const nullLogger: server.Logger = {
+ close: () => void 0,
+ isVerbose: () => void 0,
+ loggingEnabled: () => false,
+ perftrc: () => void 0,
+ info: () => void 0,
+ startGroup: () => void 0,
+ endGroup: () => void 0,
+ msg: () => void 0
+ };
+
+ const { content: libFileContent } = Harness.getDefaultLibraryFile(Harness.IO);
+
+ function getExecutingFilePathFromLibFile(libFile: FileOrFolder): string {
+ return combinePaths(getDirectoryPath(libFile.path), "tsc.js");
+ }
+
+ interface FileOrFolder {
+ path: string;
+ content?: string;
+ }
+
+ interface FSEntry {
+ path: Path;
+ fullPath: string;
+ }
+
+ interface File extends FSEntry {
+ content: string;
+ }
+
+ interface Folder extends FSEntry {
+ entries: FSEntry[];
+ }
+
+ function isFolder(s: FSEntry): s is Folder {
+ return isArray((s).entries);
+ }
+
+ function isFile(s: FSEntry): s is File {
+ return typeof (s).content === "string";
+ }
+
+ function addFolder(fullPath: string, toPath: (s: string) => Path, fs: FileMap): Folder {
+ const path = toPath(fullPath);
+ if (fs.contains(path)) {
+ Debug.assert(isFolder(fs.get(path)));
+ return (fs.get(path));
+ }
+
+ const entry: Folder = { path, entries: [], fullPath };
+ fs.set(path, entry);
+
+ const baseFullPath = getDirectoryPath(fullPath);
+ if (fullPath !== baseFullPath) {
+ addFolder(baseFullPath, toPath, fs).entries.push(entry);
+ }
+
+ return entry;
+ }
+
+ function sizeOfMap(map: Map): number {
+ let n = 0;
+ for (const name in map) {
+ if (hasProperty(map, name)) {
+ n++;
+ }
+ }
+ return n;
+ }
+
+ function checkMapKeys(caption: string, map: Map, expectedKeys: string[]) {
+ assert.equal(sizeOfMap(map), expectedKeys.length, `${caption}: incorrect size of map`);
+ for (const name of expectedKeys) {
+ assert.isTrue(hasProperty(map, name), `${caption} is expected to contain ${name}, actual keys: ${getKeys(map)}`);
+ }
+ }
+
+ function checkFileNames(caption: string, actualFileNames: string[], expectedFileNames: string[]) {
+ assert.equal(actualFileNames.length, expectedFileNames.length, `${caption}: incorrect actual number of files, expected ${JSON.stringify(expectedFileNames)}, got ${actualFileNames}`);
+ for (const f of expectedFileNames) {
+ assert.isTrue(contains(actualFileNames, f), `${caption}: expected to find ${f} in ${JSON.stringify(actualFileNames)}`);
+ }
+ }
+
+ function readDirectory(folder: FSEntry, ext: string, excludes: Path[], result: string[]): void {
+ if (!folder || !isFolder(folder) || contains(excludes, folder.path)) {
+ return;
+ }
+ for (const entry of folder.entries) {
+ if (contains(excludes, entry.path)) {
+ continue;
+ }
+ if (isFolder(entry)) {
+ readDirectory(entry, ext, excludes, result);
+ }
+ else if (fileExtensionIs(entry.path, ext)) {
+ result.push(entry.fullPath);
+ }
+ }
+ }
+
+ class TestServerHost implements server.ServerHost {
+ args: string[] = [];
+ newLine: "\n";
+
+ private fs: ts.FileMap;
+ private getCanonicalFileName: (s: string) => string;
+ private toPath: (f: string) => Path;
+ readonly watchedDirectories: Map<{ cb: DirectoryWatcherCallback, recursive: boolean }[]> = {};
+ readonly watchedFiles: Map = {};
+
+ constructor(public useCaseSensitiveFileNames: boolean, private executingFilePath: string, private currentDirectory: string, fileOrFolderList: FileOrFolder[]) {
+ this.getCanonicalFileName = createGetCanonicalFileName(useCaseSensitiveFileNames);
+ this.toPath = s => toPath(s, currentDirectory, this.getCanonicalFileName);
+
+ this.reloadFS(fileOrFolderList);
+ }
+
+ reloadFS(filesOrFolders: FileOrFolder[]) {
+ this.fs = createFileMap();
+ for (const fileOrFolder of filesOrFolders) {
+ const path = this.toPath(fileOrFolder.path);
+ const fullPath = getNormalizedAbsolutePath(fileOrFolder.path, this.currentDirectory);
+ if (typeof fileOrFolder.content === "string") {
+ const entry = { path, content: fileOrFolder.content, fullPath };
+ this.fs.set(path, entry);
+ addFolder(getDirectoryPath(fullPath), this.toPath, this.fs).entries.push(entry);
+ }
+ else {
+ addFolder(fullPath, this.toPath, this.fs);
+ }
+ }
+ }
+
+ fileExists(s: string) {
+ const path = this.toPath(s);
+ return this.fs.contains(path) && isFile(this.fs.get(path));
+ };
+
+ directoryExists(s: string) {
+ const path = this.toPath(s);
+ return this.fs.contains(path) && isFolder(this.fs.get(path));
+ }
+
+ getDirectories(s: string) {
+ const path = this.toPath(s);
+ if (!this.fs.contains(path)) {
+ return [];
+ }
+ else {
+ const entry = this.fs.get(path);
+ return isFolder(entry) ? map(entry.entries, x => getBaseFileName(x.fullPath)) : [];
+ }
+ }
+
+ readDirectory(path: string, ext: string, excludes: string[]): string[] {
+ const result: string[] = [];
+ readDirectory(this.fs.get(this.toPath(path)), ext, map(excludes, e => toPath(e, path, this.getCanonicalFileName)), result);
+ return result;
+ }
+
+ watchDirectory(directoryName: string, callback: DirectoryWatcherCallback, recursive: boolean): DirectoryWatcher {
+ const path = this.toPath(directoryName);
+ const callbacks = lookUp(this.watchedDirectories, path) || (this.watchedDirectories[path] = []);
+ callbacks.push({ cb: callback, recursive });
+ return {
+ referenceCount: 0,
+ directoryName,
+ close: () => {
+ for (let i = 0; i < callbacks.length; i++) {
+ if (callbacks[i].cb === callback) {
+ callbacks.splice(i, 1);
+ break;
+ }
+ }
+ if (!callbacks.length) {
+ delete this.watchedDirectories[path];
+ }
+ }
+ };
+ }
+
+ watchFile(fileName: string, callback: FileWatcherCallback) {
+ const path = this.toPath(fileName);
+ const callbacks = lookUp(this.watchedFiles, path) || (this.watchedFiles[path] = []);
+ callbacks.push(callback);
+ return {
+ close: () => {
+ const i = callbacks.indexOf(callback);
+ callbacks.splice(i, 1);
+ if (!callbacks.length) {
+ delete this.watchedFiles[path];
+ }
+ }
+ };
+ }
+
+ // TOOD: record and invoke callbacks to simulate timer events
+ readonly setTimeout = (callback: (...args: any[]) => void, ms: number, ...args: any[]): any => void 0;
+ readonly clearTimeout = (timeoutId: any): void => void 0;
+ readonly readFile = (s: string) => (this.fs.get(this.toPath(s))).content;
+ readonly resolvePath = (s: string) => s;
+ readonly getExecutingFilePath = () => this.executingFilePath;
+ readonly getCurrentDirectory = () => this.currentDirectory;
+ readonly writeFile = (path: string, content: string) => notImplemented();
+ readonly write = (s: string) => notImplemented();
+ readonly createDirectory = (s: string) => notImplemented();
+ readonly exit = () => notImplemented();
+ }
+
+ describe("tsserver project system:", () => {
+ it("create inferred project", () => {
+ const appFile: FileOrFolder = {
+ path: "/a/b/c/app.ts",
+ content: `
+ import {f} from "./module"
+ console.log(f)
+ `
+ };
+ const libFile: FileOrFolder = {
+ path: "/a/lib/lib.d.ts",
+ content: libFileContent
+ };
+ const moduleFile: FileOrFolder = {
+ path: "/a/b/c/module.d.ts",
+ content: `export let x: number`
+ };
+ const host = new TestServerHost(/*useCaseSensitiveFileNames*/ false, getExecutingFilePathFromLibFile(libFile), "/", [appFile, moduleFile, libFile]);
+ const projectService = new server.ProjectService(host, nullLogger);
+ const { configFileName } = projectService.openClientFile(appFile.path);
+
+ assert(!configFileName, `should not find config, got: '${configFileName}`);
+ assert.equal(projectService.inferredProjects.length, 1, "expected one inferred project");
+ assert.equal(projectService.configuredProjects.length, 0, "expected no configured project");
+
+ const project = projectService.inferredProjects[0];
+
+ checkFileNames("inferred project", project.getFileNames(), [appFile.path, libFile.path, moduleFile.path]);
+ checkMapKeys("watchedDirectories", host.watchedDirectories, ["/a/b/c", "/a/b", "/a"]);
+ });
+
+ it("create configured project without file list", () => {
+ const configFile: FileOrFolder = {
+ path: "/a/b/tsconfig.json",
+ content: `
+ {
+ "compilerOptions": {},
+ "exclude": [
+ "e"
+ ]
+ }`
+ };
+ const libFile: FileOrFolder = {
+ path: "/a/lib/lib.d.ts",
+ content: libFileContent
+ };
+ const file1: FileOrFolder = {
+ path: "/a/b/c/f1.ts",
+ content: "let x = 1"
+ };
+ const file2: FileOrFolder = {
+ path: "/a/b/d/f2.ts",
+ content: "let y = 1"
+ };
+ const file3: FileOrFolder = {
+ path: "/a/b/e/f3.ts",
+ content: "let z = 1"
+ };
+ const host = new TestServerHost(/*useCaseSensitiveFileNames*/ false, getExecutingFilePathFromLibFile(libFile), "/", [ configFile, libFile, file1, file2, file3 ]);
+ const projectService = new server.ProjectService(host, nullLogger);
+ const { configFileName, configFileErrors } = projectService.openClientFile(file1.path);
+
+ assert(configFileName, "should find config file");
+ assert.isTrue(!configFileErrors, `expect no errors in config file, got ${JSON.stringify(configFileErrors)}`);
+ assert.equal(projectService.inferredProjects.length, 0, "expected no inferred project");
+ assert.equal(projectService.configuredProjects.length, 1, "expected one configured project");
+
+ const project = projectService.configuredProjects[0];
+ checkFileNames("configuredProjects project, actualFileNames", project.getFileNames(), [file1.path, libFile.path, file2.path]);
+ checkFileNames("configuredProjects project, rootFileNames", project.getRootFiles(), [file1.path, file2.path]);
+
+ checkMapKeys("watchedFiles", host.watchedFiles, [configFile.path, file2.path, libFile.path]); // watching all files except one that was open
+ checkMapKeys("watchedDirectories", host.watchedDirectories, [getDirectoryPath(configFile.path)]);
+ });
+ });
+}
\ No newline at end of file