Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add vue.d.ts and typescript support #94

Merged
merged 13 commits into from
Mar 21, 2017
Merged
15 changes: 15 additions & 0 deletions server/npm-shrinkwrap.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"vetur-vls": "^0.2.0",
"vscode-css-languageservice": "^2.0.0",
"vscode-languageserver": "^3.0.5",
"vscode-uri": "^1.0.0"
"vscode-uri": "^1.0.0",
"vue-template-compiler": "^2.2.1"
},
"devDependencies": {
"@types/node": "^6.0.54",
Expand Down
4 changes: 2 additions & 2 deletions server/src/htmlServerMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ connection.onInitialize((params: InitializeParams): InitializeResult => {

workspacePath = params.rootPath;

languageModes = getLanguageModes();
languageModes = getLanguageModes(workspacePath);
documents.onDidClose(e => {
languageModes.onDocumentRemoved(e.document);
});
Expand Down Expand Up @@ -147,7 +147,7 @@ function triggerValidation(textDocument: TextDocument): void {

function validateTextDocument(textDocument: TextDocument): void {
let diagnostics: Diagnostic[] = [];
if (textDocument.languageId === 'vue-html') {
if (textDocument.languageId === 'vue') {
languageModes.getAllModesInDocument(textDocument).forEach(mode => {
if (mode.doValidation && validation[mode.getId()]) {
pushAll(diagnostics, mode.doValidation(textDocument));
Expand Down
131 changes: 94 additions & 37 deletions server/src/modes/javascriptMode.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,97 @@
import { LanguageModelCache, getLanguageModelCache } from '../languageModelCache';
import { SymbolInformation, SymbolKind, CompletionItem, Location, SignatureHelp, SignatureInformation, ParameterInformation, Definition, TextEdit, TextDocument, Diagnostic, DiagnosticSeverity, Range, CompletionItemKind, Hover, MarkedString, DocumentHighlight, DocumentHighlightKind, CompletionList, Position, FormattingOptions } from 'vscode-languageserver-types';
import { LanguageMode } from './languageModes';
import { getWordAtText, startsWith, isWhitespaceOnly, repeat } from '../utils/strings';
import { getWordAtText, isWhitespaceOnly, repeat } from '../utils/strings';
import { HTMLDocumentRegions } from './embeddedSupport';
import path = require('path');
import url = require('url');

import * as ts from 'typescript';
import { join } from 'path';
import { createUpdater, parseVue, isVue } from './typescriptMode';

const FILE_NAME = 'vscode://javascript/1'; // the same 'file' is used for all contents
const JQUERY_D_TS = join(__dirname, '../../lib/jquery.d.ts');
import * as ts from 'typescript';

const JS_WORD_REGEX = /(-?\d*\.\d\w*)|([^\`\~\!\@\#\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g;

export function getJavascriptMode(documentRegions: LanguageModelCache<HTMLDocumentRegions>): LanguageMode {
let jsDocuments = getLanguageModelCache<TextDocument>(10, 60, document => documentRegions.get(document).getEmbeddedDocument('javascript'));
export function getJavascriptMode(documentRegions: LanguageModelCache<HTMLDocumentRegions>, workspacePath: string): LanguageMode {
let jsDocuments = getLanguageModelCache<TextDocument>(10, 60, document => {
const vueDocument = documentRegions.get(document);
if (vueDocument.getLanguagesInDocument().indexOf('typescript') > -1) {
return vueDocument.getEmbeddedDocument('typescript');
}
return vueDocument.getEmbeddedDocument('javascript');
});

let compilerOptions: ts.CompilerOptions = { allowNonTsExtensions: true, allowJs: true, lib: ['lib.es6.d.ts'], target: ts.ScriptTarget.Latest, moduleResolution: ts.ModuleResolutionKind.Classic };
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this i would defere to ts.getDefaultCompilerOptions and just set:

let compilerOptions = ts.getDefaultCompilerOptions();
if (docs[fileName].languageId !== 'typescript') {
     copilerOptions.allowJs = true
}
compilerOptions.allowNonTsExtensions = true;

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I take that back..

{
   "allowJs": docs[fileName].languageId !== 'typescript',
   "lib" : ["dom", "esNext"],
   "target" : ["esNext"],
   "module": ts.ModuleKind.CommonJs
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"esnext" didn't ship as a lib target in 2.2, so I'll use "es2017" for now.
(It was committed Dec 30, but I guess it was part of a PR that got merged later.)

let currentTextDocument: TextDocument;
let scriptFileVersion: number = 0;
let versions: ts.MapLike<number> = {};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can use ES6 Map

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

let docs: ts.MapLike<TextDocument> = {};
function updateCurrentTextDocument(doc: TextDocument) {
if (!currentTextDocument || doc.uri !== currentTextDocument.uri || doc.version !== currentTextDocument.version) {
currentTextDocument = jsDocuments.get(doc);
scriptFileVersion++;
const fileName = trimFileUri(currentTextDocument.uri);
if (docs[fileName] && currentTextDocument.languageId !== docs[fileName].languageId) {
// if languageId changed, we must restart the language service; it can't handle file type changes
jsLanguageService = ts.createLanguageService(host);
}
docs[fileName] = currentTextDocument;
versions[fileName] = (versions[fileName] || 0) + 1;
}
}
let host = {

// Patch typescript functions to insert `import Vue from 'vue'` and `new Vue` around export default.
const { createLanguageServiceSourceFile, updateLanguageServiceSourceFile } = createUpdater();
(ts as any).createLanguageServiceSourceFile = createLanguageServiceSourceFile;
(ts as any).updateLanguageServiceSourceFile = updateLanguageServiceSourceFile;
const configFile = ts.findConfigFile(workspacePath, ts.sys.fileExists, 'tsconfig.json') ||
ts.findConfigFile(workspacePath, ts.sys.fileExists, 'jsconfig.json');
const files = ts.parseJsonConfigFileContent({},
ts.sys,
workspacePath,
compilerOptions,
configFile,
undefined,
[{ extension: 'vue', isMixedContent: true }]).fileNames;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should also set compilerOptions to the options from the file. we also need to set compilerOptions.allowNonTsExtensions = true; all the time to allow .vue files.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


let host: ts.LanguageServiceHost = {
getCompilationSettings: () => compilerOptions,
getScriptFileNames: () => [FILE_NAME, JQUERY_D_TS],
getScriptVersion: (fileName: string) => {
if (fileName === FILE_NAME) {
return String(scriptFileVersion);
getScriptFileNames: () => files,
getScriptVersion: filename => filename in versions ? versions[filename].toString() : '1',
getScriptKind(fileName) {
if(isVue(fileName) && docs[fileName]) {
return docs[fileName].languageId === 'typescript' ? ts.ScriptKind.TS : ts.ScriptKind.JS;
}
return '1'; // default lib an jquery.d.ts are static
else {
return (ts as any).getScriptKindFromFileName(fileName);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to expose this on the API for TS 2.3, also add a comment allowing removal of this cast.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added comment. I'll send out a PR for the API change after this.

}
},
resolveModuleNames(moduleNames: string[], containingFile: string): ts.ResolvedModule[] {
// in the normal case, delegate to ts.resolveModuleName
// in the relative-imported.vue case, manually build a resolved filename
return moduleNames.map(name =>
path.isAbsolute(name) || !isVue(name) ?
ts.resolveModuleName(name, containingFile, compilerOptions, ts.sys).resolvedModule :
{
resolvedFileName: path.join(path.dirname(containingFile), name),
extension: docs[name] && docs[name].languageId === 'typescript' ? ts.Extension.Ts : ts.Extension.Js,
})
},
getScriptSnapshot: (fileName: string) => {
let text = '';
if (startsWith(fileName, 'vscode:')) {
if (fileName === FILE_NAME) {
text = currentTextDocument.getText();
}
} else {
text = ts.sys.readFile(fileName) || '';
let text = fileName in docs ? docs[fileName].getText() : (ts.sys.readFile(fileName) || '');
if (isVue(fileName)) {
// Note: This is required in addition to the parsing in embeddedSupport because
// this works for .vue files that aren't even loaded by VS Code yet.
text = parseVue(text);
}
return {
getText: (start, end) => text.substring(start, end),
getLength: () => text.length,
getChangeRange: () => void 0
};
},
getCurrentDirectory: () => '',
getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options)
getCurrentDirectory: () => workspacePath,
getDefaultLibFileName: ts.getDefaultLibFilePath,
};

let jsLanguageService = ts.createLanguageService(host);

let settings: any = {};
Expand All @@ -64,8 +105,11 @@ export function getJavascriptMode(documentRegions: LanguageModelCache<HTMLDocume
},
doValidation(document: TextDocument): Diagnostic[] {
updateCurrentTextDocument(document);
const diagnostics = jsLanguageService.getSyntacticDiagnostics(FILE_NAME);
return diagnostics.map((diag): Diagnostic => {
const filename = trimFileUri(document.uri);
const diagnostics = [...jsLanguageService.getSyntacticDiagnostics(filename),
...jsLanguageService.getSemanticDiagnostics(filename)];

return diagnostics.map(diag => {
return {
range: convertRange(currentTextDocument, diag),
severity: DiagnosticSeverity.Error,
Expand All @@ -75,8 +119,9 @@ export function getJavascriptMode(documentRegions: LanguageModelCache<HTMLDocume
},
doComplete(document: TextDocument, position: Position): CompletionList {
updateCurrentTextDocument(document);
let filename = trimFileUri(document.uri);
let offset = currentTextDocument.offsetAt(position);
let completions = jsLanguageService.getCompletionsAtPosition(FILE_NAME, offset);
let completions = jsLanguageService.getCompletionsAtPosition(filename, offset);
if (!completions) {
return { isIncomplete: false, items: [] };
}
Expand All @@ -102,7 +147,8 @@ export function getJavascriptMode(documentRegions: LanguageModelCache<HTMLDocume
},
doResolve(document: TextDocument, item: CompletionItem): CompletionItem {
updateCurrentTextDocument(document);
let details = jsLanguageService.getCompletionEntryDetails(FILE_NAME, item.data.offset, item.label);
let filename = trimFileUri(document.uri);
let details = jsLanguageService.getCompletionEntryDetails(filename, item.data.offset, item.label);
if (details) {
item.detail = ts.displayPartsToString(details.displayParts);
item.documentation = ts.displayPartsToString(details.documentation);
Expand All @@ -112,7 +158,8 @@ export function getJavascriptMode(documentRegions: LanguageModelCache<HTMLDocume
},
doHover(document: TextDocument, position: Position): Hover {
updateCurrentTextDocument(document);
let info = jsLanguageService.getQuickInfoAtPosition(FILE_NAME, currentTextDocument.offsetAt(position));
let filename = trimFileUri(document.uri);
let info = jsLanguageService.getQuickInfoAtPosition(filename, currentTextDocument.offsetAt(position));
if (info) {
let contents = ts.displayPartsToString(info.displayParts);
return {
Expand All @@ -124,7 +171,8 @@ export function getJavascriptMode(documentRegions: LanguageModelCache<HTMLDocume
},
doSignatureHelp(document: TextDocument, position: Position): SignatureHelp {
updateCurrentTextDocument(document);
let signHelp = jsLanguageService.getSignatureHelpItems(FILE_NAME, currentTextDocument.offsetAt(position));
let filename = trimFileUri(document.uri);
let signHelp = jsLanguageService.getSignatureHelpItems(filename, currentTextDocument.offsetAt(position));
if (signHelp) {
let ret: SignatureHelp = {
activeSignature: signHelp.selectedItemIndex,
Expand Down Expand Up @@ -161,7 +209,8 @@ export function getJavascriptMode(documentRegions: LanguageModelCache<HTMLDocume
},
findDocumentHighlight(document: TextDocument, position: Position): DocumentHighlight[] {
updateCurrentTextDocument(document);
let occurrences = jsLanguageService.getOccurrencesAtPosition(FILE_NAME, currentTextDocument.offsetAt(position));
let filename = trimFileUri(document.uri);
let occurrences = jsLanguageService.getOccurrencesAtPosition(filename, currentTextDocument.offsetAt(position));
if (occurrences) {
return occurrences.map(entry => {
return {
Expand All @@ -174,7 +223,8 @@ export function getJavascriptMode(documentRegions: LanguageModelCache<HTMLDocume
},
findDocumentSymbols(document: TextDocument): SymbolInformation[] {
updateCurrentTextDocument(document);
let items = jsLanguageService.getNavigationBarItems(FILE_NAME);
let filename = trimFileUri(document.uri);
let items = jsLanguageService.getNavigationBarItems(filename);
if (items) {
let result: SymbolInformation[] = [];
let existing = {};
Expand Down Expand Up @@ -210,9 +260,10 @@ export function getJavascriptMode(documentRegions: LanguageModelCache<HTMLDocume
},
findDefinition(document: TextDocument, position: Position): Definition {
updateCurrentTextDocument(document);
let definition = jsLanguageService.getDefinitionAtPosition(FILE_NAME, currentTextDocument.offsetAt(position));
let filename = trimFileUri(document.uri);
let definition = jsLanguageService.getDefinitionAtPosition(filename, currentTextDocument.offsetAt(position));
if (definition) {
return definition.filter(d => d.fileName === FILE_NAME).map(d => {
return definition.map(d => {
return {
uri: document.uri,
range: convertRange(currentTextDocument, d.textSpan)
Expand All @@ -223,9 +274,10 @@ export function getJavascriptMode(documentRegions: LanguageModelCache<HTMLDocume
},
findReferences(document: TextDocument, position: Position): Location[] {
updateCurrentTextDocument(document);
let references = jsLanguageService.getReferencesAtPosition(FILE_NAME, currentTextDocument.offsetAt(position));
let filename = trimFileUri(document.uri);
let references = jsLanguageService.getReferencesAtPosition(filename, currentTextDocument.offsetAt(position));
if (references) {
return references.filter(d => d.fileName === FILE_NAME).map(d => {
return references.map(d => {
return {
uri: document.uri,
range: convertRange(currentTextDocument, d.textSpan)
Expand All @@ -245,7 +297,8 @@ export function getJavascriptMode(documentRegions: LanguageModelCache<HTMLDocume
end -= range.end.character;
lastLineRange = Range.create(Position.create(range.end.line, 0), range.end);
}
let edits = jsLanguageService.getFormattingEditsForRange(FILE_NAME, start, end, formatSettings);
let filename = trimFileUri(document.uri);
let edits = jsLanguageService.getFormattingEditsForRange(filename, start, end, formatSettings);
if (edits) {
let result = [];
for (let edit of edits) {
Expand Down Expand Up @@ -276,6 +329,10 @@ export function getJavascriptMode(documentRegions: LanguageModelCache<HTMLDocume
};
};

function trimFileUri(uri: string): string {
return url.parse(uri).path;
}

function convertRange(document: TextDocument, span: { start: number, length: number }): Range {
let startPosition = document.positionAt(span.start);
let endPosition = document.positionAt(span.start + span.length);
Expand Down
5 changes: 3 additions & 2 deletions server/src/modes/languageModes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export interface LanguageModeRange extends Range {
attributeValue?: boolean;
}

export function getLanguageModes(): LanguageModes {
export function getLanguageModes(workspacePath: string): LanguageModes {

var vls = getVls();
let documentRegions = getLanguageModelCache<HTMLDocumentRegions>(10, 60, document => getDocumentRegions(vls, document));
Expand All @@ -63,8 +63,9 @@ export function getLanguageModes(): LanguageModes {
css: getCSSMode(vls, documentRegions),
scss: getSCSSMode(vls, documentRegions),
less: getLESSMode(vls, documentRegions),
javascript: getJavascriptMode(documentRegions)
javascript: getJavascriptMode(documentRegions, workspacePath)
};
modes['typescript'] = modes.javascript;

return {
getModeAtPosition(document: TextDocument, position: Position): LanguageMode {
Expand Down
76 changes: 76 additions & 0 deletions server/src/modes/typescriptMode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import * as ts from 'typescript';
import path = require('path');
import { parseComponent } from "vue-template-compiler";

export function isVue(filename: string): boolean {
return path.extname(filename) === '.vue';
}

export function parseVue(text: string): string {
const output = parseComponent(text, { pad: 'space' });
if (output && output.script && output.script.content) {
return output.script.content;
}
else {
return text;
}
}

export function createUpdater() {
const clssf = ts.createLanguageServiceSourceFile;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these should be added to the LanguageServiceHost API in TS 2.3, I would also add a comment here with that to allow removing this global function hijacking.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added comment where the hijacking happens. This part of the code may remain the same since it's basically the same way that the plugin API allows users to wrap the language service.

const ulssf = ts.updateLanguageServiceSourceFile;
return {
createLanguageServiceSourceFile(fileName: string, scriptSnapshot: ts.IScriptSnapshot, scriptTarget: ts.ScriptTarget, version: string, setNodeParents: boolean, scriptKind?: ts.ScriptKind): ts.SourceFile {
let sourceFile = clssf(fileName, scriptSnapshot, scriptTarget, version, setNodeParents, scriptKind);
if (isVue(fileName)) {
modifyVueSource(sourceFile);
}
return sourceFile;
},
updateLanguageServiceSourceFile(sourceFile: ts.SourceFile, scriptSnapshot: ts.IScriptSnapshot, version: string, textChangeRange: ts.TextChangeRange, aggressiveChecks?: boolean): ts.SourceFile {
sourceFile = ulssf(sourceFile, scriptSnapshot, version, textChangeRange, aggressiveChecks);
if (isVue(sourceFile.fileName)) {
modifyVueSource(sourceFile);
}
return sourceFile;
}
}
}

/** Works like Array.prototype.find, returning `undefined` if no element satisfying the predicate is found. */
function find<T>(array: T[], predicate: (element: T, index: number) => boolean): T | undefined {
for (let i = 0; i < array.length; i++) {
const value = array[i];
if (predicate(value, i)) {
return value;
}
}
return undefined;
}

function modifyVueSource(sourceFile: ts.SourceFile): void {
const exportDefaultObject = find(sourceFile.statements, st => st.kind === ts.SyntaxKind.ExportAssignment &&
(st as ts.ExportAssignment).expression.kind === ts.SyntaxKind.ObjectLiteralExpression);
if (exportDefaultObject) {
// 1. add `import Vue from './vue'
// (the span of the statement is (0,0) to avoid overlapping existing statements)
const zero = <T extends ts.Node>(n: T) => ts.setTextRange(n, { pos: 0, end: 0 });
const vueImport = zero(ts.createImportDeclaration(undefined,
undefined,
zero(ts.createImportClause(undefined,
zero(ts.createNamedImports([
zero(ts.createImportSpecifier(
zero(ts.createIdentifier('Vue')),
zero(ts.createIdentifier('Vue'))))])))),
zero(ts.createLiteral('vue'))));
sourceFile.statements.unshift(vueImport);

// 2. find the export default and wrap it in `new Vue(...)` if it exists and is an object literal
// (the span of the construct call is the same as the object literal)
const objectLiteral = (exportDefaultObject as ts.ExportAssignment).expression as ts.ObjectLiteralExpression;
const o = <T extends ts.TextRange>(n: T) => ts.setTextRange(n, objectLiteral);
const vue = ts.setTextRange(ts.createIdentifier('Vue'), { pos: objectLiteral.pos, end: objectLiteral.pos + 1 });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you mind giving o and n more descriptive names? I'm not quite familiar with TS compilers but would want to fiddle with this part of code.

(exportDefaultObject as ts.ExportAssignment).expression = o(ts.createNew(vue, undefined, [objectLiteral]));
o(((exportDefaultObject as ts.ExportAssignment).expression as ts.NewExpression).arguments);
}
}