diff --git a/server/npm-shrinkwrap.json b/server/npm-shrinkwrap.json index dd4620f64b..5863197db5 100644 --- a/server/npm-shrinkwrap.json +++ b/server/npm-shrinkwrap.json @@ -2,6 +2,16 @@ "name": "vscode-html-languageserver", "version": "1.0.0", "dependencies": { + "de-indent": { + "version": "1.0.2", + "from": "de-indent@>=1.0.2 <2.0.0", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz" + }, + "he": { + "version": "1.1.1", + "from": "he@>=1.1.0 <2.0.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz" + }, "@types/node": { "version": "6.0.65", "from": "@types/node@>=6.0.54 <7.0.0", @@ -123,6 +133,11 @@ "version": "1.0.0", "from": "vscode-uri@>=1.0.0 <2.0.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-1.0.0.tgz" + }, + "vue-template-compiler": { + "version": "2.2.1", + "from": "vue-template-compiler@latest", + "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.2.1.tgz" } } } diff --git a/server/package.json b/server/package.json index 9067ff2e37..debf3f4d92 100644 --- a/server/package.json +++ b/server/package.json @@ -11,7 +11,8 @@ "vetur-vls": "^0.2.3", "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", diff --git a/server/src/htmlServerMain.ts b/server/src/htmlServerMain.ts index fc65a95530..b47e30b654 100644 --- a/server/src/htmlServerMain.ts +++ b/server/src/htmlServerMain.ts @@ -40,7 +40,7 @@ connection.onInitialize((params: InitializeParams): InitializeResult => { workspacePath = params.rootPath; - languageModes = getLanguageModes(); + languageModes = getLanguageModes(workspacePath); documents.onDidClose(e => { languageModes.onDocumentRemoved(e.document); }); diff --git a/server/src/modes/javascriptMode.ts b/server/src/modes/javascriptMode.ts index fb1697de21..47531dac26 100644 --- a/server/src/modes/javascriptMode.ts +++ b/server/src/modes/javascriptMode.ts @@ -1,45 +1,116 @@ 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 { createUpdater, parseVue, isVue } from './typescriptMode'; +import Uri from 'vscode-uri'; +import path = require('path'); import * as ts from 'typescript'; -import { join } from 'path'; - -const FILE_NAME = 'vscode://javascript/1'; // the same 'file' is used for all contents const JS_WORD_REGEX = /(-?\d*\.\d\w*)|([^\`\~\!\@\#\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g; -export function getJavascriptMode(documentRegions: LanguageModelCache): LanguageMode { - let jsDocuments = getLanguageModelCache(10, 60, document => documentRegions.get(document).getEmbeddedDocument('javascript')); +export function getJavascriptMode(documentRegions: LanguageModelCache, workspacePath: string): LanguageMode { + let jsDocuments = getLanguageModelCache(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 }; + let compilerOptions: ts.CompilerOptions = { + allowNonTsExtensions: true, + allowJs: true, + lib: ['lib.dom.d.ts', 'lib.es2017.d.ts'], + target: ts.ScriptTarget.Latest, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + module: ts.ModuleKind.CommonJS, + allowSyntheticDefaultImports: true + }; let currentTextDocument: TextDocument; - let scriptFileVersion: number = 0; + let versions = new Map(); + let docs = new Map(); function updateCurrentTextDocument(doc: TextDocument) { if (!currentTextDocument || doc.uri !== currentTextDocument.uri || doc.version !== currentTextDocument.version) { currentTextDocument = jsDocuments.get(doc); - scriptFileVersion++; + const fileName = trimFileUri(doc.uri); + if (docs.has(fileName) && currentTextDocument.languageId !== docs.get(fileName).languageId) { + // if languageId changed, we must restart the language service; it can't handle file type changes + compilerOptions.allowJs = docs.get(fileName).languageId !== 'typescript'; + jsLanguageService = ts.createLanguageService(host); + } + docs.set(fileName, currentTextDocument); + versions.set(fileName, (versions.get(fileName) || 0) + 1); } } - let host = { + + // Patch typescript functions to insert `import Vue from 'vue'` and `new Vue` around export default. + // NOTE: Typescript 2.3 should add an API to allow this, and then this code should use that API. + const { createLanguageServiceSourceFile, updateLanguageServiceSourceFile } = createUpdater(); + (ts as any).createLanguageServiceSourceFile = createLanguageServiceSourceFile; + (ts as any).updateLanguageServiceSourceFile = updateLanguageServiceSourceFile; + const configFilename = ts.findConfigFile(workspacePath, ts.sys.fileExists, 'tsconfig.json') || + ts.findConfigFile(workspacePath, ts.sys.fileExists, 'jsconfig.json'); + const configJson = configFilename && ts.readConfigFile(configFilename, ts.sys.readFile).config || {}; + const parsedConfig = ts.parseJsonConfigFileContent(configJson, + ts.sys, + workspacePath, + compilerOptions, + configFilename, + undefined, + [{ extension: 'vue', isMixedContent: true }]); + const files = parsedConfig.fileNames; + compilerOptions = parsedConfig.options; + compilerOptions.allowNonTsExtensions = true; + + let host: ts.LanguageServiceHost = { getCompilationSettings: () => compilerOptions, - getScriptFileNames: () => [FILE_NAME], - getScriptVersion: (fileName: string) => { - if (fileName === FILE_NAME) { - return String(scriptFileVersion); + getScriptFileNames: () => files, + getScriptVersion(filename) { + filename = normalizeFileName(filename); + return versions.has(filename) ? versions.get(filename).toString() : '0'; + }, + getScriptKind(fileName) { + if(isVue(fileName)) { + const uri = Uri.file(fileName); + fileName = uri.fsPath; + const doc = docs.get(fileName) || + jsDocuments.get(TextDocument.create(uri.toString(), 'vue', 0, ts.sys.readFile(fileName))); + return doc.languageId === 'typescript' ? ts.ScriptKind.TS : ts.ScriptKind.JS; } - return '1'; // default lib an jquery.d.ts are static + else { + // NOTE: Typescript 2.3 should export getScriptKindFromFileName. Then this cast should be removed. + return (ts as any).getScriptKindFromFileName(fileName); + } }, - getScriptSnapshot: (fileName: string) => { - let text = ''; - if (startsWith(fileName, 'vscode:')) { - if (fileName === FILE_NAME) { - text = currentTextDocument.getText(); + 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 => { + if (path.isAbsolute(name) || !isVue(name)) { + return ts.resolveModuleName(name, containingFile, compilerOptions, ts.sys).resolvedModule; } - } else { - text = ts.sys.readFile(fileName) || ''; + else { + const uri = Uri.file(path.join(path.dirname(containingFile), name)); + const resolvedFileName = uri.fsPath; + const doc = docs.get(resolvedFileName) || + jsDocuments.get(TextDocument.create(uri.toString(), 'vue', 0, ts.sys.readFile(resolvedFileName))); + return { + resolvedFileName, + extension: doc.languageId === 'typescript' ? ts.Extension.Ts : ts.Extension.Js, + }; + } + }); + }, + getScriptSnapshot: (fileName: string) => { + fileName = normalizeFileName(fileName); + let text = docs.has(fileName) ? docs.get(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), @@ -47,9 +118,10 @@ export function getJavascriptMode(documentRegions: LanguageModelCache void 0 }; }, - getCurrentDirectory: () => '', - getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options) + getCurrentDirectory: () => workspacePath, + getDefaultLibFileName: ts.getDefaultLibFilePath, }; + let jsLanguageService = ts.createLanguageService(host); let settings: any = {}; @@ -63,8 +135,11 @@ export function getJavascriptMode(documentRegions: LanguageModelCache { + 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, @@ -74,8 +149,9 @@ export function getJavascriptMode(documentRegions: LanguageModelCache { return { @@ -173,7 +253,8 @@ export function getJavascriptMode(documentRegions: LanguageModelCache d.fileName === FILE_NAME).map(d => { + return definition.map(d => { return { uri: document.uri, range: convertRange(currentTextDocument, d.textSpan) @@ -222,9 +304,10 @@ export function getJavascriptMode(documentRegions: LanguageModelCache d.fileName === FILE_NAME).map(d => { + return references.map(d => { return { uri: document.uri, range: convertRange(currentTextDocument, d.textSpan) @@ -244,7 +327,8 @@ export function getJavascriptMode(documentRegions: LanguageModelCache(10, 60, document => getDocumentRegions(vls, document)); @@ -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 { diff --git a/server/src/modes/typescriptMode.ts b/server/src/modes/typescriptMode.ts new file mode 100644 index 0000000000..3b775578ff --- /dev/null +++ b/server/src/modes/typescriptMode.ts @@ -0,0 +1,78 @@ +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; + 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(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 inserted statement must be (0,0) to avoid overlapping existing statements) + const setZeroPos = getWrapperRangeSetter({ pos: 0, end: 0 }); + const vueImport = setZeroPos(ts.createImportDeclaration(undefined, + undefined, + setZeroPos(ts.createImportClause(ts.createIdentifier('Vue'), undefined)), + setZeroPos(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 wrapping construct call and *all* its members must be the same as the object literal it wraps) + const objectLiteral = (exportDefaultObject as ts.ExportAssignment).expression as ts.ObjectLiteralExpression; + const setObjPos = getWrapperRangeSetter(objectLiteral); + const vue = ts.setTextRange(ts.createIdentifier('Vue'), { pos: objectLiteral.pos, end: objectLiteral.pos + 1 }); + (exportDefaultObject as ts.ExportAssignment).expression = setObjPos(ts.createNew(vue, undefined, [objectLiteral])); + setObjPos(((exportDefaultObject as ts.ExportAssignment).expression as ts.NewExpression).arguments); + } +} + +/** Create a function that calls setTextRange on synthetic wrapper nodes that need a valid range */ +function getWrapperRangeSetter(wrapped: ts.TextRange): (wrapperNode: T) => T { + return (wrapperNode: T) => ts.setTextRange(wrapperNode, wrapped); + +} \ No newline at end of file diff --git a/server/tsconfig.json b/server/tsconfig.json index 47549f2a12..e26e637b50 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -5,7 +5,7 @@ "outDir": "../client/server", "sourceMap": true, "lib": [ - "es5", "es2015.promise", "dom" + "es5", "es2015.collection", "es2015.promise", "dom" ] } } \ No newline at end of file