diff --git a/Jakefile.js b/Jakefile.js
index d327b5bb47b..17346e8bc1f 100644
--- a/Jakefile.js
+++ b/Jakefile.js
@@ -250,6 +250,7 @@ var harnessSources = harnessCoreSources.concat([
"convertToBase64.ts",
"transpile.ts",
"reuseProgramStructure.ts",
+ "textStorage.ts",
"cachingInServerLSHost.ts",
"moduleResolution.ts",
"tsconfigParsing.ts",
diff --git a/src/harness/unittests/textStorage.ts b/src/harness/unittests/textStorage.ts
new file mode 100644
index 00000000000..71ce83d9ee7
--- /dev/null
+++ b/src/harness/unittests/textStorage.ts
@@ -0,0 +1,71 @@
+///
+///
+///
+
+namespace ts.textStorage {
+ describe("Text storage", () => {
+ const f = {
+ path: "/a/app.ts",
+ content: `
+ let x = 1;
+ let y = 2;
+ function bar(a: number) {
+ return a + 1;
+ }`
+ };
+
+ it("text based storage should be have exactly the same as script version cache", () => {
+
+ debugger
+ const host = ts.projectSystem.createServerHost([f]);
+
+ const ts1 = new server.TextStorage(host, server.asNormalizedPath(f.path));
+ const ts2 = new server.TextStorage(host, server.asNormalizedPath(f.path));
+
+ ts1.useScriptVersionCache();
+ ts2.useText();
+
+ const lineMap = computeLineStarts(f.content);
+
+ for (let line = 0; line < lineMap.length; line++) {
+ const start = lineMap[line];
+ const end = line === lineMap.length - 1 ? f.path.length : lineMap[line + 1];
+
+ for (let offset = 0; offset < end - start; offset++) {
+ const pos1 = ts1.lineOffsetToPosition(line + 1, offset + 1);
+ const pos2 = ts2.lineOffsetToPosition(line + 1, offset + 1);
+ assert.isTrue(pos1 === pos2, `lineOffsetToPosition ${line + 1}-${offset + 1}: expected ${pos1} to equal ${pos2}`);
+ }
+
+ const {start: start1, length: length1 } = ts1.lineToTextSpan(line);
+ const {start: start2, length: length2 } = ts2.lineToTextSpan(line);
+ assert.isTrue(start1 === start2, `lineToTextSpan ${line}::start:: expected ${start1} to equal ${start2}`);
+ assert.isTrue(length1 === length2, `lineToTextSpan ${line}::length:: expected ${length1} to equal ${length2}`);
+ }
+
+ for (let pos = 0; pos < f.content.length; pos++) {
+ const { line: line1, offset: offset1 } = ts1.positionToLineOffset(pos);
+ const { line: line2, offset: offset2 } = ts2.positionToLineOffset(pos);
+ assert.isTrue(line1 === line2, `positionToLineOffset ${pos}::line:: expected ${line1} to equal ${line2}`);
+ assert.isTrue(offset1 === offset2, `positionToLineOffset ${pos}::offset:: expected ${offset1} to equal ${offset2}`);
+ }
+ });
+
+ it("should switch to script version cache if necessary", () => {
+ const host = ts.projectSystem.createServerHost([f]);
+ const ts1 = new server.TextStorage(host, server.asNormalizedPath(f.path));
+
+ ts1.getSnapshot();
+ assert.isTrue(!ts1.hasScriptVersionCache(), "should not have script version cache - 1");
+
+ ts1.edit(0, 5, " ");
+ assert.isTrue(ts1.hasScriptVersionCache(), "have script version cache - 1");
+
+ ts1.useText();
+ assert.isTrue(!ts1.hasScriptVersionCache(), "should not have script version cache - 2");
+
+ ts1.getLineInfo(0);
+ assert.isTrue(ts1.hasScriptVersionCache(), "have script version cache - 2");
+ })
+ });
+}
\ No newline at end of file
diff --git a/src/harness/unittests/tsserverProjectSystem.ts b/src/harness/unittests/tsserverProjectSystem.ts
index 77ebee6fadd..fa4d65af4ab 100644
--- a/src/harness/unittests/tsserverProjectSystem.ts
+++ b/src/harness/unittests/tsserverProjectSystem.ts
@@ -1574,7 +1574,7 @@ namespace ts.projectSystem {
const project = projectService.externalProjects[0];
const scriptInfo = project.getScriptInfo(file1.path);
- const snap = scriptInfo.snap();
+ const snap = scriptInfo.getSnapshot();
const actualText = snap.getText(0, snap.getLength());
assert.equal(actualText, "", `expected content to be empty string, got "${actualText}"`);
@@ -1587,7 +1587,7 @@ namespace ts.projectSystem {
projectService.closeClientFile(file1.path);
const scriptInfo2 = project.getScriptInfo(file1.path);
- const snap2 = scriptInfo2.snap();
+ const snap2 = scriptInfo2.getSnapshot();
const actualText2 = snap2.getText(0, snap.getLength());
assert.equal(actualText2, "", `expected content to be empty string, got "${actualText2}"`);
});
@@ -2285,13 +2285,13 @@ namespace ts.projectSystem {
p.updateGraph();
const scriptInfo = p.getScriptInfo(f.path);
- checkSnapLength(scriptInfo.snap(), f.content.length);
+ checkSnapLength(scriptInfo.getSnapshot(), f.content.length);
// open project and replace its content with empty string
projectService.openClientFile(f.path, "");
- checkSnapLength(scriptInfo.snap(), 0);
+ checkSnapLength(scriptInfo.getSnapshot(), 0);
});
- function checkSnapLength(snap: server.LineIndexSnapshot, expectedLength: number) {
+ function checkSnapLength(snap: IScriptSnapshot, expectedLength: number) {
assert.equal(snap.getLength(), expectedLength, "Incorrect snapshot size");
}
});
@@ -2444,7 +2444,6 @@ namespace ts.projectSystem {
const cwd = {
path: "/a/c"
};
- debugger;
const host = createServerHost([f1, config, node, cwd], { currentDirectory: cwd.path });
const projectService = createProjectService(host);
projectService.openClientFile(f1.path);
@@ -2715,7 +2714,7 @@ namespace ts.projectSystem {
// verify content
const projectServiice = session.getProjectService();
- const snap1 = projectServiice.getScriptInfo(f1.path).snap();
+ const snap1 = projectServiice.getScriptInfo(f1.path).getSnapshot();
assert.equal(snap1.getText(0, snap1.getLength()), tmp.content, "content should be equal to the content of temp file");
// reload from original file file
@@ -2727,7 +2726,7 @@ namespace ts.projectSystem {
});
// verify content
- const snap2 = projectServiice.getScriptInfo(f1.path).snap();
+ const snap2 = projectServiice.getScriptInfo(f1.path).getSnapshot();
assert.equal(snap2.getText(0, snap2.getLength()), f1.content, "content should be equal to the content of original file");
});
diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts
index 9e53b3cd526..7ed24ea71f8 100644
--- a/src/server/editorServices.ts
+++ b/src/server/editorServices.ts
@@ -424,7 +424,7 @@ namespace ts.server {
this.handleDeletedFile(info);
}
else {
- if (info && (!info.isOpen)) {
+ if (info && (!info.isScriptOpen())) {
// file has been changed which might affect the set of referenced files in projects that include
// this file and set of inferred projects
info.reloadFromFile();
@@ -440,7 +440,7 @@ namespace ts.server {
// TODO: handle isOpen = true case
- if (!info.isOpen) {
+ if (!info.isScriptOpen()) {
this.filenameToScriptInfo.remove(info.path);
this.lastDeletedFile = info;
@@ -634,10 +634,9 @@ namespace ts.server {
// Closing file should trigger re-reading the file content from disk. This is
// because the user may chose to discard the buffer content before saving
// to the disk, and the server's version of the file can be out of sync.
- info.reloadFromFile();
+ info.close();
removeItemFromSet(this.openFiles, info);
- info.isOpen = false;
// collect all projects that should be removed
let projectsToRemove: Project[];
@@ -989,7 +988,7 @@ namespace ts.server {
}
if (toAdd) {
for (const f of toAdd) {
- if (f.isOpen && isRootFileInInferredProject(f)) {
+ if (f.isScriptOpen() && isRootFileInInferredProject(f)) {
// if file is already root in some inferred project
// - remove the file from that project and delete the project if necessary
const inferredProject = f.containingProjects[0];
@@ -1089,32 +1088,31 @@ namespace ts.server {
getOrCreateScriptInfoForNormalizedPath(fileName: NormalizedPath, openedByClient: boolean, fileContent?: string, scriptKind?: ScriptKind, hasMixedContent?: boolean) {
let info = this.getScriptInfoForNormalizedPath(fileName);
if (!info) {
- let content: string;
- if (this.host.fileExists(fileName)) {
- // by default pick whatever content was supplied as the argument
- // if argument was not given - then for mixed content files assume that its content is empty string
- content = fileContent || (hasMixedContent ? "" : this.host.readFile(fileName));
- }
- if (!content) {
- if (openedByClient) {
- content = "";
- }
- }
- if (content !== undefined) {
- info = new ScriptInfo(this.host, fileName, content, scriptKind, openedByClient, hasMixedContent);
- // do not watch files with mixed content - server doesn't know how to interpret it
+ if (openedByClient || this.host.fileExists(fileName)) {
+ info = new ScriptInfo(this.host, fileName, scriptKind, hasMixedContent);
+
this.filenameToScriptInfo.set(info.path, info);
- if (!info.isOpen && !hasMixedContent) {
- info.setWatcher(this.host.watchFile(fileName, _ => this.onSourceFileChanged(fileName)));
+
+ if (openedByClient) {
+ if (fileContent === undefined) {
+ // if file is opened by client and its content is not specified - use file text
+ fileContent = this.host.readFile(fileName) || "";
+ }
+ }
+ else {
+ // do not watch files with mixed content - server doesn't know how to interpret it
+ if (!hasMixedContent) {
+ info.setWatcher(this.host.watchFile(fileName, _ => this.onSourceFileChanged(fileName)));
+ }
}
}
}
if (info) {
- if (fileContent !== undefined) {
- info.reload(fileContent);
+ if (openedByClient && !info.isScriptOpen()) {
+ info.open(fileContent);
}
- if (openedByClient) {
- info.isOpen = true;
+ else if (fileContent !== undefined) {
+ info.reload(fileContent);
}
}
return info;
@@ -1230,7 +1228,6 @@ namespace ts.server {
const info = this.getScriptInfoForNormalizedPath(toNormalizedPath(uncheckedFileName));
if (info) {
this.closeOpenFile(info);
- info.isOpen = false;
}
this.printProjects();
}
@@ -1255,7 +1252,7 @@ namespace ts.server {
if (openFiles) {
for (const file of openFiles) {
const scriptInfo = this.getScriptInfo(file.fileName);
- Debug.assert(!scriptInfo || !scriptInfo.isOpen);
+ Debug.assert(!scriptInfo || !scriptInfo.isScriptOpen());
const normalizedPath = scriptInfo ? scriptInfo.fileName : toNormalizedPath(file.fileName);
this.openClientFileWithNormalizedPath(normalizedPath, file.content, tryConvertScriptKindName(file.scriptKind), file.hasMixedContent);
}
diff --git a/src/server/lsHost.ts b/src/server/lsHost.ts
index aa37008ff66..d73a31933f4 100644
--- a/src/server/lsHost.ts
+++ b/src/server/lsHost.ts
@@ -169,7 +169,7 @@ namespace ts.server {
getScriptSnapshot(filename: string): ts.IScriptSnapshot {
const scriptInfo = this.project.getScriptInfoLSHost(filename);
if (scriptInfo) {
- return scriptInfo.snap();
+ return scriptInfo.getSnapshot();
}
}
diff --git a/src/server/project.ts b/src/server/project.ts
index 049f61269f8..ca117fb97cf 100644
--- a/src/server/project.ts
+++ b/src/server/project.ts
@@ -448,7 +448,7 @@ namespace ts.server {
containsFile(filename: NormalizedPath, requireOpen?: boolean) {
const info = this.projectService.getScriptInfoForNormalizedPath(filename);
- if (info && (info.isOpen || !requireOpen)) {
+ if (info && (info.isScriptOpen() || !requireOpen)) {
return this.containsScriptInfo(info);
}
}
diff --git a/src/server/scriptInfo.ts b/src/server/scriptInfo.ts
index 84649863a7b..e8acb676f1b 100644
--- a/src/server/scriptInfo.ts
+++ b/src/server/scriptInfo.ts
@@ -2,6 +2,161 @@
namespace ts.server {
+ /* @internal */
+ export class TextStorage {
+ private svc: ScriptVersionCache | undefined;
+ private svcVersion = 0;
+
+ private text: string;
+ private lineMap: number[];
+ private textVersion = 0;
+
+ constructor(private readonly host: ServerHost, private readonly fileName: NormalizedPath) {
+ }
+
+ public getVersion() {
+ return this.svc
+ ? `SVC-${this.svcVersion}-${this.svc.getSnapshot().version}`
+ : `Text-${this.textVersion}`;
+ }
+
+ public hasScriptVersionCache() {
+ return this.svc !== undefined;
+ }
+
+ public useScriptVersionCache(newText?: string) {
+ this.switchToScriptVersionCache(newText);
+ }
+
+ public useText() {
+ this.svc = undefined;
+ this.reloadFromFile();
+ }
+
+ public edit(start: number, end: number, newText: string) {
+ this.switchToScriptVersionCache().edit(start, end - start, newText);
+ }
+
+ public reload(text: string) {
+ if (this.svc) {
+ this.svc.reload(text);
+ }
+ else {
+ this.setText(text);
+ }
+ }
+
+ public reloadFromFile(tempFileName?: string) {
+ if (this.svc || (tempFileName !== this.fileName)) {
+ this.reload(this.getFileText(tempFileName))
+ }
+ else {
+ this.setText(undefined);
+ }
+ }
+
+ public getSnapshot(): IScriptSnapshot {
+ return this.svc
+ ? this.svc.getSnapshot()
+ : ScriptSnapshot.fromString(this.getOrLoadText());
+ }
+
+ public getLineInfo(line: number) {
+ return this.switchToScriptVersionCache().getSnapshot().index.lineNumberToInfo(line);
+ }
+ /**
+ * @param line 0 based index
+ */
+ lineToTextSpan(line: number) {
+ if (!this.svc) {
+ const lineMap = this.getLineMap();
+ const start = lineMap[line]; // -1 since line is 1-based
+ const end = line + 1 < lineMap.length ? lineMap[line + 1] : this.text.length;
+ return ts.createTextSpanFromBounds(start, end);
+ }
+ const index = this.svc.getSnapshot().index;
+ const lineInfo = index.lineNumberToInfo(line + 1);
+ let len: number;
+ if (lineInfo.leaf) {
+ len = lineInfo.leaf.text.length;
+ }
+ else {
+ const nextLineInfo = index.lineNumberToInfo(line + 2);
+ len = nextLineInfo.offset - lineInfo.offset;
+ }
+ return ts.createTextSpan(lineInfo.offset, len);
+ }
+
+ /**
+ * @param line 1 based index
+ * @param offset 1 based index
+ */
+ lineOffsetToPosition(line: number, offset: number): number {
+ if (!this.svc) {
+ return computePositionOfLineAndCharacter(this.getLineMap(), line - 1, offset - 1);
+ }
+ const index = this.svc.getSnapshot().index;
+
+ const lineInfo = index.lineNumberToInfo(line);
+ // TODO: assert this offset is actually on the line
+ return (lineInfo.offset + offset - 1);
+ }
+
+ /**
+ * @param line 1-based index
+ * @param offset 1-based index
+ */
+ positionToLineOffset(position: number): ILineInfo {
+ if (!this.svc) {
+ const { line, character } = computeLineAndCharacterOfPosition(this.getLineMap(), position);
+ return { line: line + 1, offset: character + 1 };
+ }
+ const index = this.svc.getSnapshot().index;
+ const lineOffset = index.charOffsetToLineNumberAndPos(position);
+ return { line: lineOffset.line, offset: lineOffset.offset + 1 };
+ }
+
+ private getFileText(tempFileName?: string) {
+ return this.host.readFile(tempFileName || this.fileName) || "";
+ }
+
+ private ensureNoScriptVersionCache() {
+ Debug.assert(!this.svc, "ScriptVersionCache should not be set");
+ }
+
+ private switchToScriptVersionCache(newText?: string): ScriptVersionCache {
+ if (!this.svc) {
+ this.svc = ScriptVersionCache.fromString(this.host, newText !== undefined ? newText : this.getOrLoadText());
+ this.svcVersion++;
+ this.text = undefined;
+ }
+ return this.svc;
+ }
+
+ private getOrLoadText() {
+ this.ensureNoScriptVersionCache();
+ if (this.text === undefined) {
+ this.setText(this.getFileText());
+ }
+ return this.text;
+ }
+
+ private getLineMap() {
+ this.ensureNoScriptVersionCache();
+ return this.lineMap || (this.lineMap = computeLineStarts(this.getOrLoadText()));
+ }
+
+ private setText(newText: string) {
+ this.ensureNoScriptVersionCache();
+ if (newText === undefined || this.text !== newText) {
+ this.text = newText;
+ this.lineMap = undefined;
+ this.textVersion++;
+ }
+ }
+ }
+
+
export class ScriptInfo {
/**
* All projects that include this file
@@ -11,24 +166,46 @@ namespace ts.server {
readonly path: Path;
private fileWatcher: FileWatcher;
- private svc: ScriptVersionCache;
+ private textStorage: TextStorage;
+
+ private isOpen: boolean;
// TODO: allow to update hasMixedContent from the outside
constructor(
private readonly host: ServerHost,
readonly fileName: NormalizedPath,
- content: string,
readonly scriptKind: ScriptKind,
- public isOpen = false,
public hasMixedContent = false) {
this.path = toPath(fileName, host.getCurrentDirectory(), createGetCanonicalFileName(host.useCaseSensitiveFileNames));
- this.svc = ScriptVersionCache.fromString(host, content);
+ this.textStorage = new TextStorage(host, fileName);
+ if (hasMixedContent) {
+ this.textStorage.reload("");
+ }
this.scriptKind = scriptKind
? scriptKind
: getScriptKindFromFileName(fileName);
}
+ public isScriptOpen() {
+ return this.isOpen;
+ }
+
+ public open(newText: string) {
+ this.isOpen = true;
+ this.textStorage.useScriptVersionCache(newText);
+ this.markContainingProjectsAsDirty();
+ }
+
+ public close() {
+ this.isOpen = false;
+ this.textStorage.useText();
+ }
+
+ public getSnapshot() {
+ return this.textStorage.getSnapshot();
+ }
+
getFormatCodeSettings() {
return this.formatCodeSettings;
}
@@ -112,16 +289,16 @@ namespace ts.server {
}
getLatestVersion() {
- return this.svc.latestVersion().toString();
+ return this.textStorage.getVersion();
}
reload(script: string) {
- this.svc.reload(script);
+ this.textStorage.reload(script);
this.markContainingProjectsAsDirty();
}
saveTo(fileName: string) {
- const snap = this.snap();
+ const snap = this.textStorage.getSnapshot();
this.host.writeFile(fileName, snap.getText(0, snap.getLength()));
}
@@ -130,22 +307,17 @@ namespace ts.server {
this.reload("");
}
else {
- this.svc.reloadFromFile(tempFileName || this.fileName);
+ this.textStorage.reloadFromFile(tempFileName);
this.markContainingProjectsAsDirty();
}
}
- snap() {
- return this.svc.getSnapshot();
- }
-
getLineInfo(line: number) {
- const snap = this.snap();
- return snap.index.lineNumberToInfo(line);
+ return this.textStorage.getLineInfo(line);
}
editContent(start: number, end: number, newText: string): void {
- this.svc.edit(start, end - start, newText);
+ this.textStorage.edit(start, end, newText);
this.markContainingProjectsAsDirty();
}
@@ -159,17 +331,7 @@ namespace ts.server {
* @param line 1 based index
*/
lineToTextSpan(line: number) {
- const index = this.snap().index;
- const lineInfo = index.lineNumberToInfo(line + 1);
- let len: number;
- if (lineInfo.leaf) {
- len = lineInfo.leaf.text.length;
- }
- else {
- const nextLineInfo = index.lineNumberToInfo(line + 2);
- len = nextLineInfo.offset - lineInfo.offset;
- }
- return ts.createTextSpan(lineInfo.offset, len);
+ return this.textStorage.lineToTextSpan(line);
}
/**
@@ -177,11 +339,7 @@ namespace ts.server {
* @param offset 1 based index
*/
lineOffsetToPosition(line: number, offset: number): number {
- const index = this.snap().index;
-
- const lineInfo = index.lineNumberToInfo(line);
- // TODO: assert this offset is actually on the line
- return (lineInfo.offset + offset - 1);
+ return this.textStorage.lineOffsetToPosition(line, offset);
}
/**
@@ -189,9 +347,7 @@ namespace ts.server {
* @param offset 1-based index
*/
positionToLineOffset(position: number): ILineInfo {
- const index = this.snap().index;
- const lineOffset = index.charOffsetToLineNumberAndPos(position);
- return { line: lineOffset.line, offset: lineOffset.offset + 1 };
+ return this.textStorage.positionToLineOffset(position);
}
}
}
\ No newline at end of file
diff --git a/src/server/scriptVersionCache.ts b/src/server/scriptVersionCache.ts
index f094a183610..aaf04d39a1f 100644
--- a/src/server/scriptVersionCache.ts
+++ b/src/server/scriptVersionCache.ts
@@ -438,8 +438,9 @@ namespace ts.server {
}
}
getChangeRange(oldSnapshot: ts.IScriptSnapshot): ts.TextChangeRange {
- const oldSnap = oldSnapshot;
- return this.getTextChangeRangeSinceVersion(oldSnap.version);
+ if (oldSnapshot instanceof LineIndexSnapshot) {
+ return this.getTextChangeRangeSinceVersion(oldSnapshot.version);
+ }
}
}
diff --git a/src/server/session.ts b/src/server/session.ts
index 9abc5a447bf..a1944e5e782 100644
--- a/src/server/session.ts
+++ b/src/server/session.ts
@@ -709,7 +709,7 @@ namespace ts.server {
const displayString = ts.displayPartsToString(nameInfo.displayParts);
const nameSpan = nameInfo.textSpan;
const nameColStart = scriptInfo.positionToLineOffset(nameSpan.start).offset;
- const nameText = scriptInfo.snap().getText(nameSpan.start, ts.textSpanEnd(nameSpan));
+ const nameText = scriptInfo.getSnapshot().getText(nameSpan.start, ts.textSpanEnd(nameSpan));
const refs = combineProjectOutput(
projects,
(project: Project) => {
@@ -722,7 +722,7 @@ namespace ts.server {
const refScriptInfo = project.getScriptInfo(ref.fileName);
const start = refScriptInfo.positionToLineOffset(ref.textSpan.start);
const refLineSpan = refScriptInfo.lineToTextSpan(start.line - 1);
- const lineText = refScriptInfo.snap().getText(refLineSpan.start, ts.textSpanEnd(refLineSpan)).replace(/\r|\n/g, "");
+ const lineText = refScriptInfo.getSnapshot().getText(refLineSpan.start, ts.textSpanEnd(refLineSpan)).replace(/\r|\n/g, "");
return {
file: ref.fileName,
start: start,
@@ -1326,7 +1326,7 @@ namespace ts.server {
highPriorityFiles.push(fileNameInProject);
else {
const info = this.projectService.getScriptInfo(fileNameInProject);
- if (!info.isOpen) {
+ if (!info.isScriptOpen()) {
if (fileNameInProject.indexOf(".d.ts") > 0)
veryLowPriorityFiles.push(fileNameInProject);
else
diff --git a/src/services/types.ts b/src/services/types.ts
index 6a0e6e886b5..3865fe7fac9 100644
--- a/src/services/types.ts
+++ b/src/services/types.ts
@@ -91,7 +91,9 @@ namespace ts {
}
public getText(start: number, end: number): string {
- return this.text.substring(start, end);
+ return start === 0 && end === this.text.length
+ ? this.text
+ : this.text.substring(start, end);
}
public getLength(): number {