alm
Version:
The best IDE for TypeScript
457 lines (397 loc) • 15.5 kB
text/typescript
import liner = require('./liner');
import {TypedEvent} from "../common/events";
let toPath = ts.toPath;
import Path = ts.Path;
import LineIndex = liner.LineIndex;
let unorderedRemoveItem = ts.unorderedRemoveItem;
interface ILineInfo extends liner.ILineInfo { }
/** BAS : a function I added, useful as we are working without true fs host */
const toSimplePath = (fileName: string): Path => toPath(fileName, '', (x) => x);
/** our compiler settings for simple tokenization */
const defaultCompilerOptions: ts.CompilerOptions = {
jsx: ts.JsxEmit.React,
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.Latest,
experimentalDecorators: true,
noLib: true,
}
/**
* These classes are modified version of session.ts
* Same names but modified to not use `fs` / `sys` / `host`
*/
export class ScriptInfo {
svc: ScriptVersionCache;
children: ScriptInfo[] = []; // files referenced by this file
path: Path;
constructor(public fileName: string, public content: string, public isOpen = false) {
this.path = toSimplePath(fileName);
this.svc = ScriptVersionCache.fromString(content);
}
close() {
this.isOpen = false;
}
addChild(childInfo: ScriptInfo) {
this.children.push(childInfo);
}
snap() {
return this.svc.getSnapshot();
}
getText() {
const snap = this.snap();
return snap.getText(0, snap.getLength());
}
getLineInfo(line: number) {
const snap = this.snap();
return snap.index.lineNumberToInfo(line);
}
editContent(start: number, end: number, newText: string): void {
this.svc.edit(start, end - start, newText);
}
getTextChangeRangeBetweenVersions(startVersion: number, endVersion: number): ts.TextChangeRange {
return this.svc.getTextChangesBetweenVersions(startVersion, endVersion);
}
getChangeRange(oldSnapshot: ts.IScriptSnapshot): ts.TextChangeRange {
return this.snap().getChangeRange(oldSnapshot);
}
}
export class ScriptVersionCache {
changes: TextChange[] = [];
versions: LineIndexSnapshot[] = [];
minVersion = 0; // no versions earlier than min version will maintain change history
private currentVersion = 0;
static changeNumberThreshold = 8;
static changeLengthThreshold = 256;
static maxVersions = 8;
// REVIEW: can optimize by coalescing simple edits
edit(pos: number, deleteLen: number, insertedText?: string) {
this.changes[this.changes.length] = new TextChange(pos, deleteLen, insertedText);
if ((this.changes.length > ScriptVersionCache.changeNumberThreshold) ||
(deleteLen > ScriptVersionCache.changeLengthThreshold) ||
(insertedText && (insertedText.length > ScriptVersionCache.changeLengthThreshold))) {
this.getSnapshot();
}
}
latest() {
return this.versions[this.currentVersion];
}
latestVersion() {
if (this.changes.length > 0) {
this.getSnapshot();
}
return this.currentVersion;
}
// reload whole script, leaving no change history behind reload
reload(script: string) {
this.currentVersion++;
this.changes = []; // history wiped out by reload
const snap = new LineIndexSnapshot(this.currentVersion, this);
this.versions[this.currentVersion] = snap;
snap.index = new LineIndex();
const lm = LineIndex.linesFromText(script);
snap.index.load(lm.lines);
// REVIEW: could use linked list
for (let i = this.minVersion; i < this.currentVersion; i++) {
this.versions[i] = undefined;
}
this.minVersion = this.currentVersion;
}
getSnapshot() {
let snap = this.versions[this.currentVersion];
if (this.changes.length > 0) {
let snapIndex = this.latest().index;
for (let i = 0, len = this.changes.length; i < len; i++) {
const change = this.changes[i];
snapIndex = snapIndex.edit(change.pos, change.deleteLen, change.insertedText);
}
snap = new LineIndexSnapshot(this.currentVersion + 1, this);
snap.index = snapIndex;
snap.changesSincePreviousVersion = this.changes;
this.currentVersion = snap.version;
this.versions[snap.version] = snap;
this.changes = [];
if ((this.currentVersion - this.minVersion) >= ScriptVersionCache.maxVersions) {
const oldMin = this.minVersion;
this.minVersion = (this.currentVersion - ScriptVersionCache.maxVersions) + 1;
for (let j = oldMin; j < this.minVersion; j++) {
this.versions[j] = undefined;
}
}
}
return snap;
}
getTextChangesBetweenVersions(oldVersion: number, newVersion: number) {
if (oldVersion < newVersion) {
if (oldVersion >= this.minVersion) {
const textChangeRanges: ts.TextChangeRange[] = [];
for (let i = oldVersion + 1; i <= newVersion; i++) {
const snap = this.versions[i];
for (let j = 0, len = snap.changesSincePreviousVersion.length; j < len; j++) {
const textChange = snap.changesSincePreviousVersion[j];
textChangeRanges[textChangeRanges.length] = textChange.getTextChangeRange();
}
}
return ts.collapseTextChangeRangesAcrossMultipleVersions(textChangeRanges);
}
else {
return undefined;
}
}
else {
return ts.unchangedTextChangeRange;
}
}
static fromString(script: string) {
const svc = new ScriptVersionCache();
const snap = new LineIndexSnapshot(0, svc);
svc.versions[svc.currentVersion] = snap;
snap.index = new LineIndex();
const lm = LineIndex.linesFromText(script);
snap.index.load(lm.lines);
return svc;
}
}
export class LineIndexSnapshot implements ts.IScriptSnapshot {
index: LineIndex;
changesSincePreviousVersion: TextChange[] = [];
constructor(public version: number, public cache: ScriptVersionCache) {
}
getText(rangeStart: number, rangeEnd: number) {
return this.index.getText(rangeStart, rangeEnd - rangeStart);
}
getLength() {
return this.index.root.charCount();
}
// this requires linear space so don't hold on to these
getLineStartPositions(): number[] {
const starts: number[] = [-1];
let count = 1;
let pos = 0;
this.index.every((ll, s, len) => {
starts[count++] = pos;
pos += ll.text.length;
return true;
}, 0);
return starts;
}
getLineMapper() {
return ((line: number) => {
return this.index.lineNumberToInfo(line).offset;
});
}
getTextChangeRangeSinceVersion(scriptVersion: number) {
if (this.version <= scriptVersion) {
return ts.unchangedTextChangeRange;
}
else {
return this.cache.getTextChangesBetweenVersions(scriptVersion, this.version);
}
}
getChangeRange(oldSnapshot: ts.IScriptSnapshot): ts.TextChangeRange {
const oldSnap = <LineIndexSnapshot>oldSnapshot;
return this.getTextChangeRangeSinceVersion(oldSnap.version);
}
}
// text change information
export class TextChange {
constructor(public pos: number, public deleteLen: number, public insertedText?: string) {
}
getTextChangeRange() {
return ts.createTextChangeRange(ts.createTextSpan(this.pos, this.deleteLen),
this.insertedText ? this.insertedText.length : 0);
}
}
export class LSHost implements ts.LanguageServiceHost {
ls: ts.LanguageService;
filenameToScript: ts.Map<ScriptInfo>;
roots: ScriptInfo[] = [];
/**
* BAS: add configuration arguments
* - projectDirectory,
* - compilerOptions
*/
constructor(public projectDirectory: string | undefined, public compilerOptions = defaultCompilerOptions) {
this.filenameToScript = ts.createMap<ScriptInfo>();
}
getDefaultLibFileName = () => null;
getScriptSnapshot(filename: string): ts.IScriptSnapshot {
const scriptInfo = this.getScriptInfo(filename);
if (scriptInfo) {
return scriptInfo.snap();
}
}
lineAffectsRefs(filename: string, line: number) {
const info = this.getScriptInfo(filename);
const lineInfo = info.getLineInfo(line);
if (lineInfo && lineInfo.text) {
const regex = /reference|import|\/\*|\*\//;
return regex.test(lineInfo.text);
}
}
// BAS change this to return active project settings for file
getCompilationSettings() {
return this.compilerOptions;
}
getScriptFileNames() {
return this.roots.map(root => root.fileName);
}
getScriptVersion(filename: string) {
return this.getScriptInfo(filename).svc.latestVersion().toString();
}
// BAS : wired to config. Needed for proper `@types` expansion.
// See the child class `LanguageServiceHost` implementation below (with more comments).
getCurrentDirectory = () => this.projectDirectory;
getScriptIsOpen(filename: string) {
return this.getScriptInfo(filename).isOpen;
}
getScriptInfo(filename: string): ScriptInfo {
const path = toSimplePath(filename);
let scriptInfo = this.filenameToScript.get(path);
return scriptInfo;
}
addRoot(info: ScriptInfo) {
if (!this.filenameToScript.has(info.path)) {
this.filenameToScript.set(info.path, info);
this.roots.push(info);
}
}
removeRoot(info: ScriptInfo) {
if (!this.filenameToScript.has(info.path)) {
this.filenameToScript.delete(info.path);
unorderedRemoveItem(this.roots, info)
}
}
editScript(filename: string, start: number, end: number, newText: string) {
const script = this.getScriptInfo(filename);
if (script) {
script.editContent(start, end, newText);
return;
}
throw new Error("No script with name '" + filename + "'");
}
/**
* Add a file with contents
*/
addScript(filePath: string, contents: string) {
let path = toSimplePath(filePath);
if (!this.filenameToScript.has(path)) {
let info = new ScriptInfo(filePath, contents);
this.addRoot(info);
}
}
/**
* @param line 1 based index
*/
lineToTextSpan(filename: string, line: number): ts.TextSpan {
const path = toSimplePath(filename);
const script: ScriptInfo = this.filenameToScript.get(path);
const index = script.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);
}
/**
* @param line 1 based index
* @param offset 1 based index
*/
lineOffsetToPosition(filename: string, line: number, offset: number): number {
const path = toSimplePath(filename);
const script: ScriptInfo = this.filenameToScript.get(path);
const index = script.snap().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(filename: string, position: number): ILineInfo {
const path = toSimplePath(filename);
const script: ScriptInfo = this.filenameToScript.get(path);
const index = script.snap().index;
const lineOffset = index.charOffsetToLineNumberAndPos(position);
return { line: lineOffset.line, offset: lineOffset.offset + 1 };
}
}
/**
* BAS:
* This class is my own creation.
*/
export class LanguageServiceHost extends LSHost {
removeFile(filename: string){
const script = this.getScriptInfo(filename);
this.removeRoot(script);
}
/**
* Basically having setContents ensure long term stability even if stuff does get out of sync due to errors in above implementation
*/
setContents(filename: string, contents: string) {
const script = this.getScriptInfo(filename);
if (script) {
script.svc.reload(contents);
return;
}
throw new Error("No script with name '" + filename + "'");
}
/**
* Note : This can be slow
*/
getContents(filename: string) {
const script = this.getScriptInfo(filename);
if (script) {
return script.getText();
}
throw new Error("No script with name '" + filename + "'");
}
/** 0 based */
getPositionOfLineAndCharacter(filePath: string, line: number, ch: number) {
return this.lineOffsetToPosition(filePath, line + 1, ch + 1);
}
/** 0 based */
getLineAndCharacterOfPosition(filePath: string, pos: number): EditorPosition {
let res = this.positionToLineOffset(filePath, pos);
return { line: res.line - 1, ch: res.offset - 1 };
}
/** Like parent EditScript but works on EditorPosition */
applyCodeEdit(fileName: string, start: EditorPosition, end: EditorPosition, newText: string) {
var minChar = this.getPositionOfLineAndCharacter(fileName, start.line, start.ch);
var limChar = this.getPositionOfLineAndCharacter(fileName, end.line, end.ch);
super.editScript(fileName, minChar, limChar, newText);
}
/*
* LS host can optionally implement these methods to support getImportModuleCompletionsAtPosition.
* Without these methods, only completions for ambient modules will be provided.
*/
readDirectory = ts.sys ? ts.sys.readDirectory : undefined;
readFile = ts.sys ? ts.sys.readFile: undefined;
fileExists = ts.sys ? ts.sys.fileExists : undefined;
/**
* getDirectories is also required for full import and type reference completions.
* Without it defined, certain completions will not be provided
*/
getDirectories = ts.sys ? ts.sys.getDirectories : undefined;
/**
* For @types expansion, these two functions are needed.
*/
directoryExists = ts.sys ? ts.sys.directoryExists : undefined;
getCurrentDirectory = () => {
/**
* TODO: use the same path as the path of tsconfig.json (if any)
* `undefined` is handled correctly in the compiler source :
* https://github.com/Microsoft/TypeScript/blob/02493de5ccd9e8c4c901bb154ba584dee392bd14/src/compiler/moduleNameResolver.ts#L98
*/
return this.projectDirectory;
}
/**
* We allow incremental loading of resources.
* Needed for node_modules and for stuff like `user types a require statement`
*/
incrementallyAddedFile = new TypedEvent<{filePath: string}>();
}