diff --git a/.vscode/launch.json b/.vscode/launch.json index 06de8e9daf..3900e5875f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,7 +12,10 @@ "type": "extensionHost", "request": "launch", "runtimeExecutable": "${execPath}", - "args": ["--extensionDevelopmentPath=${workspaceFolder}"], + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}", + "--disable-extensions" + ], "internalConsoleOptions": "neverOpen", "sourceMaps": true, "outFiles": ["${workspaceFolder}/dist/**/*.js"], @@ -39,6 +42,7 @@ "args": [ "--extensionDevelopmentPath=${workspaceFolder}", "--extensionTestsPath=${workspaceFolder}/dist/test/lsp", + "--user-data-dir=${workspaceFolder}/test/lsp/data-dir", "${workspaceFolder}/test/lsp/fixture" ], "stopOnEntry": false, @@ -55,6 +59,7 @@ "args": [ "--extensionDevelopmentPath=${workspaceFolder}", "--extensionTestsPath=${workspaceFolder}/dist/test/interpolation", + "--user-data-dir=${workspaceFolder}/test/interpolation/data-dir", "${workspaceFolder}/test/interpolation/fixture" ], "stopOnEntry": false, @@ -82,6 +87,7 @@ "args": [ "--extensionDevelopmentPath=${workspaceFolder}", "--extensionTestsPath=${workspaceFolder}/dist/test/grammar", + "--user-data-dir=${workspaceFolder}/test/grammar/data-dir", "${workspaceFolder}/test/grammar/fixture" ], "stopOnEntry": false, diff --git a/package.json b/package.json index 78c427c017..c61d9ce1aa 100644 --- a/package.json +++ b/package.json @@ -503,6 +503,7 @@ "@types/glob": "^7.1.1", "@types/js-yaml": "^3.12.2", "@types/lodash": "^4.14.149", + "@types/minimist": "^1.2.0", "@types/mocha": "^7.0.1", "@types/node": "^13.7.7", "@types/shelljs": "^0.8.6", @@ -511,6 +512,7 @@ "husky": "^3.1.0", "js-yaml": "^3.13.1", "lint-staged": "^10.0.8", + "minimist": "^1.2.0", "mocha": "^7.1.0", "npm-run-all": "^4.1.5", "prettier": "^1.19.1", diff --git a/server/package.json b/server/package.json index 3c3be2bfdb..130f0b5ce1 100644 --- a/server/package.json +++ b/server/package.json @@ -26,7 +26,7 @@ "buefy-helper-json": "^1.0.2", "element-helper-json": "^2.0.6", "eslint": "^6.8.0", - "eslint-plugin-vue": "^6.0.1", + "eslint-plugin-vue": "^6.2.1", "gridsome-helper-json": "^1.0.3", "js-beautify": "^1.10.0", "lodash": "^4.17.4", diff --git a/server/src/modes/script/javascript.ts b/server/src/modes/script/javascript.ts index f79f252916..effce05de1 100644 --- a/server/src/modes/script/javascript.ts +++ b/server/src/modes/script/javascript.ts @@ -1,7 +1,6 @@ import { LanguageModelCache, getLanguageModelCache } from '../../embeddedSupport/languageModelCache'; import { SymbolInformation, - SymbolKind, CompletionItem, Location, SignatureHelp, @@ -41,6 +40,7 @@ import { getComponentInfo } from './componentInfo'; import { DependencyService, T_TypeScript, State } from '../../services/dependencyService'; import { RefactorAction } from '../../types'; import { IServiceHost } from '../../services/typescriptService/serviceHost'; +import { toCompletionItemKind, toSymbolKind } from '../../services/typescriptService/util'; // Todo: After upgrading to LS server 4.0, use CompletionContext for filtering trigger chars // https://microsoft.github.io/language-server-protocol/specification#completion-request-leftwards_arrow_with_hook @@ -163,7 +163,7 @@ export async function getJavascriptMode( label, detail, sortText: entry.sortText + index, - kind: convertKind(entry.kind), + kind: toCompletionItemKind(entry.kind), textEdit: range && TextEdit.replace(range, entry.name), data: { // data used for resolving item details (see 'doResolve') @@ -339,7 +339,7 @@ export async function getJavascriptMode( if (item.kind !== 'script' && !existing[sig]) { const symbol: SymbolInformation = { name: item.text, - kind: convertSymbolKind(item.kind), + kind: toSymbolKind(item.kind), location: { uri: doc.uri, range: convertRange(scriptDoc, item.spans[0]) @@ -648,70 +648,6 @@ function convertRange(document: TextDocument, span: ts.TextSpan): Range { return Range.create(startPosition, endPosition); } -function convertKind(kind: ts.ScriptElementKind): CompletionItemKind { - switch (kind) { - case 'primitive type': - case 'keyword': - return CompletionItemKind.Keyword; - case 'var': - case 'local var': - return CompletionItemKind.Variable; - case 'property': - case 'getter': - case 'setter': - return CompletionItemKind.Field; - case 'function': - case 'method': - case 'construct': - case 'call': - case 'index': - return CompletionItemKind.Function; - case 'enum': - return CompletionItemKind.Enum; - case 'module': - return CompletionItemKind.Module; - case 'class': - return CompletionItemKind.Class; - case 'interface': - return CompletionItemKind.Interface; - case 'warning': - return CompletionItemKind.File; - case 'script': - return CompletionItemKind.File; - case 'directory': - return CompletionItemKind.Folder; - } - - return CompletionItemKind.Property; -} - -function convertSymbolKind(kind: ts.ScriptElementKind): SymbolKind { - switch (kind) { - case 'var': - case 'local var': - case 'const': - return SymbolKind.Variable; - case 'function': - case 'local function': - return SymbolKind.Function; - case 'enum': - return SymbolKind.Enum; - case 'module': - return SymbolKind.Module; - case 'class': - return SymbolKind.Class; - case 'interface': - return SymbolKind.Interface; - case 'method': - return SymbolKind.Method; - case 'property': - case 'getter': - case 'setter': - return SymbolKind.Property; - } - return SymbolKind.Variable; -} - function convertOptions( formatSettings: ts.FormatCodeSettings, options: FormattingOptions, diff --git a/server/src/modes/template/htmlMode.ts b/server/src/modes/template/htmlMode.ts index 8fee6818ee..73bfa43e7d 100644 --- a/server/src/modes/template/htmlMode.ts +++ b/server/src/modes/template/htmlMode.ts @@ -11,7 +11,6 @@ import { findDocumentHighlights } from './services/htmlHighlighting'; import { findDocumentLinks } from './services/htmlLinks'; import { findDocumentSymbols } from './services/htmlSymbolsProvider'; import { htmlFormat } from './services/htmlFormat'; -import { parseHTMLDocument } from './parser/htmlParser'; import { doESLintValidation, createLintEngine } from './services/htmlValidation'; import { findDefinition } from './services/htmlDefinition'; import { getTagProviderSettings, IHTMLTagProvider, CompletionConfiguration } from './tagProviders'; @@ -25,7 +24,6 @@ export class HTMLMode implements LanguageMode { private tagProviderSettings: CompletionConfiguration; private enabledTagProviders: IHTMLTagProvider[]; private embeddedDocuments: LanguageModelCache; - private vueDocuments: LanguageModelCache; private config: any = {}; @@ -34,6 +32,7 @@ export class HTMLMode implements LanguageMode { constructor( documentRegions: LanguageModelCache, workspacePath: string | undefined, + private vueDocuments: LanguageModelCache, private vueInfoService?: VueInfoService ) { this.tagProviderSettings = getTagProviderSettings(workspacePath); @@ -41,7 +40,6 @@ export class HTMLMode implements LanguageMode { this.embeddedDocuments = getLanguageModelCache(10, 60, document => documentRegions.refreshAndGet(document).getSingleLanguageDocument('vue-html') ); - this.vueDocuments = getLanguageModelCache(10, 60, document => parseHTMLDocument(document)); } getId() { @@ -66,14 +64,7 @@ export class HTMLMode implements LanguageMode { tagProviders.push(getComponentInfoTagProvider(info.componentInfo.childComponents)); } - return doComplete( - embedded, - position, - this.vueDocuments.refreshAndGet(embedded), - tagProviders, - this.config.emmet, - info - ); + return doComplete(embedded, position, this.vueDocuments.refreshAndGet(embedded), tagProviders, this.config.emmet); } doHover(document: TextDocument, position: Position) { const embedded = this.embeddedDocuments.refreshAndGet(document); diff --git a/server/src/modes/template/index.ts b/server/src/modes/template/index.ts index f5e0f85ece..0574b9c629 100644 --- a/server/src/modes/template/index.ts +++ b/server/src/modes/template/index.ts @@ -1,6 +1,14 @@ -import { FormattingOptions, Position, Range, TextDocument, Hover, Location } from 'vscode-languageserver-types'; +import { + FormattingOptions, + Position, + Range, + TextDocument, + Hover, + Location, + CompletionItem +} from 'vscode-languageserver-types'; import { VueDocumentRegions } from '../../embeddedSupport/embeddedSupport'; -import { LanguageModelCache } from '../../embeddedSupport/languageModelCache'; +import { LanguageModelCache, getLanguageModelCache } from '../../embeddedSupport/languageModelCache'; import { LanguageMode } from '../../embeddedSupport/languageModes'; import { VueInfoService } from '../../services/vueInfoService'; import { DocumentContext } from '../../types'; @@ -8,6 +16,7 @@ import { HTMLMode } from './htmlMode'; import { VueInterpolationMode } from './interpolationMode'; import { IServiceHost } from '../../services/typescriptService/serviceHost'; import { T_TypeScript } from '../../services/dependencyService'; +import { HTMLDocument, parseHTMLDocument } from './parser/htmlParser'; type DocumentRegionCache = LanguageModelCache; @@ -22,8 +31,9 @@ export class VueHTMLMode implements LanguageMode { workspacePath: string | undefined, vueInfoService?: VueInfoService ) { - this.htmlMode = new HTMLMode(documentRegions, workspacePath, vueInfoService); - this.vueInterpolationMode = new VueInterpolationMode(tsModule, serviceHost); + const vueDocuments = getLanguageModelCache(10, 60, document => parseHTMLDocument(document)); + this.htmlMode = new HTMLMode(documentRegions, workspacePath, vueDocuments, vueInfoService); + this.vueInterpolationMode = new VueInterpolationMode(tsModule, serviceHost, vueDocuments); } getId() { return 'vue-html'; @@ -39,7 +49,15 @@ export class VueHTMLMode implements LanguageMode { return this.htmlMode.doValidation(document).concat(this.vueInterpolationMode.doValidation(document)); } doComplete(document: TextDocument, position: Position) { - return this.htmlMode.doComplete(document, position); + const htmlList = this.htmlMode.doComplete(document, position); + const intList = this.vueInterpolationMode.doComplete(document, position); + return { + isIncomplete: htmlList.isIncomplete || intList.isIncomplete, + items: htmlList.items.concat(intList.items) + }; + } + doResolve(document: TextDocument, item: CompletionItem): CompletionItem { + return this.vueInterpolationMode.doResolve(document, item); } doHover(document: TextDocument, position: Position): Hover { const interpolationHover = this.vueInterpolationMode.doHover(document, position); diff --git a/server/src/modes/template/interpolationMode.ts b/server/src/modes/template/interpolationMode.ts index 36bd7d0695..0875650855 100644 --- a/server/src/modes/template/interpolationMode.ts +++ b/server/src/modes/template/interpolationMode.ts @@ -7,7 +7,10 @@ import { MarkedString, Range, Location, - Definition + Definition, + CompletionList, + TextEdit, + CompletionItem } from 'vscode-languageserver-types'; import { IServiceHost } from '../../services/typescriptService/serviceHost'; import { languageServiceIncludesFile } from '../script/javascript'; @@ -17,11 +20,20 @@ import * as ts from 'typescript'; import { T_TypeScript } from '../../services/dependencyService'; import * as _ from 'lodash'; import { createTemplateDiagnosticFilter } from '../../services/typescriptService/templateDiagnosticFilter'; +import { NULL_COMPLETION } from '../nullMode'; +import { toCompletionItemKind } from '../../services/typescriptService/util'; +import { LanguageModelCache } from '../../embeddedSupport/languageModelCache'; +import { HTMLDocument } from './parser/htmlParser'; +import { isInsideInterpolation } from './services/isInsideInterpolation'; export class VueInterpolationMode implements LanguageMode { private config: any = {}; - constructor(private tsModule: T_TypeScript, private serviceHost: IServiceHost) {} + constructor( + private tsModule: T_TypeScript, + private serviceHost: IServiceHost, + private vueDocuments: LanguageModelCache + ) {} getId() { return 'vue-html-interpolation'; @@ -72,6 +84,131 @@ export class VueInterpolationMode implements LanguageMode { }); } + doComplete(document: TextDocument, position: Position): CompletionList { + if (!_.get(this.config, ['vetur', 'experimental', 'templateInterpolationService'], true)) { + return NULL_COMPLETION; + } + + const offset = document.offsetAt(position); + const node = this.vueDocuments.refreshAndGet(document).findNodeBefore(offset); + const nodeRange = Range.create(document.positionAt(node.start), document.positionAt(node.end)); + const nodeText = document.getText(nodeRange); + if (!isInsideInterpolation(node, nodeText, offset - node.start)) { + return NULL_COMPLETION; + } + + // Add suffix to process this doc as vue template. + const templateDoc = TextDocument.create( + document.uri + '.template', + document.languageId, + document.version, + document.getText() + ); + + const { templateService, templateSourceMap } = this.serviceHost.updateCurrentVirtualVueTextDocument(templateDoc); + if (!languageServiceIncludesFile(templateService, templateDoc.uri)) { + return NULL_COMPLETION; + } + + /** + * In the cases of empty content inside node + * For example, completion in {{ | }} + * Source map would only map this position {{| }} + * And position mapped back wouldn't fall into any source map ranges + */ + let completionPos = position; + // Case {{ }} + if (node.isInterpolation) { + if (nodeText.match(/{{\s*}}/)) { + completionPos = document.positionAt(node.start + '{{'.length); + } + } + // Todo: Case v-, : or @ directives + + const mappedOffset = mapFromPositionToOffset(templateDoc, completionPos, templateSourceMap); + const templateFileFsPath = getFileFsPath(templateDoc.uri); + + const completions = templateService.getCompletionsAtPosition(templateFileFsPath, mappedOffset, { + includeCompletionsWithInsertText: true, + includeCompletionsForModuleExports: false + }); + + if (!completions) { + return NULL_COMPLETION; + } + + const tsItems = completions.entries.map((entry, index) => { + return { + uri: templateDoc.uri, + position, + label: entry.name, + sortText: entry.name.startsWith('$') ? '1' + entry.sortText : '0' + entry.sortText, + kind: toCompletionItemKind(entry.kind), + textEdit: + entry.replacementSpan && + TextEdit.replace(mapBackRange(templateDoc, entry.replacementSpan, templateSourceMap), entry.name), + data: { + // data used for resolving item details (see 'doResolve') + languageId: 'vue-html', + uri: templateDoc.uri, + offset: position, + source: entry.source + } + }; + }); + + return { + isIncomplete: false, + items: tsItems + }; + } + + doResolve(document: TextDocument, item: CompletionItem): CompletionItem { + if (!_.get(this.config, ['vetur', 'experimental', 'templateInterpolationService'], true)) { + return item; + } + + /** + * resolve is called for both HTMl and interpolation completions + * HTML completions send back no data + */ + if (!item.data) { + return item; + } + + // Add suffix to process this doc as vue template. + const templateDoc = TextDocument.create( + document.uri + '.template', + document.languageId, + document.version, + document.getText() + ); + + const { templateService, templateSourceMap } = this.serviceHost.updateCurrentVirtualVueTextDocument(templateDoc); + if (!languageServiceIncludesFile(templateService, templateDoc.uri)) { + return item; + } + + const templateFileFsPath = getFileFsPath(templateDoc.uri); + const mappedOffset = mapFromPositionToOffset(templateDoc, item.data.offset, templateSourceMap); + + const details = templateService.getCompletionEntryDetails( + templateFileFsPath, + mappedOffset, + item.label, + undefined, + undefined, + undefined + ); + + if (details) { + item.detail = ts.displayPartsToString(details.displayParts); + item.documentation = ts.displayPartsToString(details.documentation); + delete item.data; + } + return item; + } + doHover( document: TextDocument, position: Position diff --git a/server/src/modes/template/services/htmlCompletion.ts b/server/src/modes/template/services/htmlCompletion.ts index fe1e1b9163..6f64d8a548 100644 --- a/server/src/modes/template/services/htmlCompletion.ts +++ b/server/src/modes/template/services/htmlCompletion.ts @@ -12,10 +12,7 @@ import { HTMLDocument } from '../parser/htmlParser'; import { TokenType, createScanner, ScannerState } from '../parser/htmlScanner'; import { IHTMLTagProvider } from '../tagProviders'; import * as emmet from 'vscode-emmet-helper'; -import { VueFileInfo } from '../../../services/vueInfoService'; -import { doVueInterpolationComplete } from './vueInterpolationCompletion'; import { NULL_COMPLETION } from '../../nullMode'; -import { isInsideInterpolation } from './isInsideInterpolation'; import { getModifierProvider, Modifier } from '../modifierProvider'; export function doComplete( @@ -23,8 +20,7 @@ export function doComplete( position: Position, htmlDocument: HTMLDocument, tagProviders: IHTMLTagProvider[], - emmetConfig: emmet.EmmetConfiguration, - vueFileInfo?: VueFileInfo + emmetConfig: emmet.EmmetConfiguration ): CompletionList { const modifierProvider = getModifierProvider(); @@ -35,30 +31,10 @@ export function doComplete( const offset = document.offsetAt(position); const node = htmlDocument.findNodeBefore(offset); - if (!node) { + if (!node || node.isInterpolation) { return result; } - const nodeRange = Range.create(document.positionAt(node.start), document.positionAt(node.end)); - const nodeText = document.getText(nodeRange); - const insideInterpolation = isInsideInterpolation(node, nodeText, document.offsetAt(position) - node.start); - - if (node.isInterpolation) { - if (!insideInterpolation) { - return NULL_COMPLETION; - } - - if (document.getText()[document.offsetAt(position) - 1] === '.') { - return NULL_COMPLETION; - } - - if (vueFileInfo) { - return doVueInterpolationComplete(vueFileInfo); - } else { - return NULL_COMPLETION; - } - } - const text = document.getText(); const scanner = createScanner(text, node.start); let currentTag: string; @@ -235,11 +211,7 @@ export function doComplete( function collectAttributeValueSuggestions(attr: string, valueStart: number, valueEnd?: number): CompletionList { if (attr.startsWith('v-') || attr.startsWith('@') || attr.startsWith(':')) { - if (vueFileInfo && insideInterpolation) { - return doVueInterpolationComplete(vueFileInfo); - } else { - return NULL_COMPLETION; - } + return NULL_COMPLETION; } let range: Range; diff --git a/server/src/modes/template/services/htmlValidation.ts b/server/src/modes/template/services/htmlValidation.ts index e6110bc9ad..e4dd544e7d 100644 --- a/server/src/modes/template/services/htmlValidation.ts +++ b/server/src/modes/template/services/htmlValidation.ts @@ -1,6 +1,7 @@ import { CLIEngine, Linter } from 'eslint'; import { configs } from 'eslint-plugin-vue'; import { TextDocument, Diagnostic, Range, DiagnosticSeverity } from 'vscode-languageserver-types'; +import { resolve } from 'path'; function toDiagnostic(error: Linter.LintMessage): Diagnostic { const line = error.line - 1; @@ -28,8 +29,11 @@ export function doESLintValidation(document: TextDocument, engine: CLIEngine): D } export function createLintEngine() { + const SERVER_ROOT = resolve(__dirname, '../../../../'); return new CLIEngine({ useEslintrc: false, + // So ESLint can find the bundled eslint-plugin-vue + cwd: SERVER_ROOT, ...configs.base, ...configs.essential }); diff --git a/server/src/modes/template/services/vueInterpolationCompletion.ts b/server/src/modes/template/services/vueInterpolationCompletion.ts index 51dc2c5f8e..3d64f0a364 100644 --- a/server/src/modes/template/services/vueInterpolationCompletion.ts +++ b/server/src/modes/template/services/vueInterpolationCompletion.ts @@ -1,15 +1,25 @@ -import { CompletionList, CompletionItemKind } from 'vscode-languageserver'; +import * as ts from 'typescript'; +import { CompletionItemKind, CompletionItem } from 'vscode-languageserver'; import { VueFileInfo } from '../../../services/vueInfoService'; +import { findNodeByOffset } from '../../../services/typescriptService/util'; +import { T_TypeScript } from '../../../services/dependencyService'; -export function doVueInterpolationComplete(vueFileInfo: VueFileInfo): CompletionList { - const result: CompletionList = { - isIncomplete: false, - items: [] - }; +export function getVueInterpolationCompletionMap( + tsModule: T_TypeScript, + fileName: string, + offset: number, + templateService: ts.LanguageService, + vueFileInfo: VueFileInfo +): Map | undefined { + const result = new Map(); + + if (!isComponentCompletion(tsModule, fileName, offset, templateService)) { + return; + } if (vueFileInfo.componentInfo.props) { vueFileInfo.componentInfo.props.forEach(p => { - result.items.push({ + result.set(p.name, { label: p.name, documentation: { kind: 'markdown', @@ -22,7 +32,7 @@ export function doVueInterpolationComplete(vueFileInfo: VueFileInfo): Completion if (vueFileInfo.componentInfo.data) { vueFileInfo.componentInfo.data.forEach(p => { - result.items.push({ + result.set(p.name, { label: p.name, documentation: { kind: 'markdown', @@ -35,7 +45,7 @@ export function doVueInterpolationComplete(vueFileInfo: VueFileInfo): Completion if (vueFileInfo.componentInfo.computed) { vueFileInfo.componentInfo.computed.forEach(p => { - result.items.push({ + result.set(p.name, { label: p.name, documentation: { kind: 'markdown', @@ -48,7 +58,7 @@ export function doVueInterpolationComplete(vueFileInfo: VueFileInfo): Completion if (vueFileInfo.componentInfo.methods) { vueFileInfo.componentInfo.methods.forEach(p => { - result.items.push({ + result.set(p.name, { label: p.name, documentation: { kind: 'markdown', @@ -61,3 +71,35 @@ export function doVueInterpolationComplete(vueFileInfo: VueFileInfo): Completion return result; } + +function isComponentCompletion( + tsModule: T_TypeScript, + fileName: string, + offset: number, + templateService: ts.LanguageService +): boolean { + const program = templateService.getProgram(); + if (!program) { + return false; + } + + const source = program.getSourceFile(fileName); + if (!source) { + return false; + } + + const completionTarget = findNodeByOffset(source, offset); + if (!completionTarget) { + return false; + } + + return ( + // Completion for direct component properties. + // e.g. {{ valu| }} + (tsModule.isPropertyAccessExpression(completionTarget.parent) && + completionTarget.parent.expression.kind === tsModule.SyntaxKind.ThisKeyword) || + // Completion for implicit component properties (e.g. triggering completion without any text). + // e.g. {{ | }} + !tsModule.isPropertyAccessExpression(completionTarget.parent) + ); +} diff --git a/server/src/services/typescriptService/preprocess.ts b/server/src/services/typescriptService/preprocess.ts index 23c15ce681..b30cd5fd4e 100644 --- a/server/src/services/typescriptService/preprocess.ts +++ b/server/src/services/typescriptService/preprocess.ts @@ -16,6 +16,8 @@ import { templateSourceMap } from './serviceHost'; import { generateSourceMap } from './sourceMap'; import { isVirtualVueTemplateFile, isVueFile } from './util'; +const importedComponentName = '__vlsComponent'; + export function parseVueScript(text: string): string { const doc = TextDocument.create('test://test/test.vue', 'vue', 0, text); const regions = getVueDocumentRegions(doc); @@ -222,7 +224,7 @@ export function injectVueTemplate( const componentImport = tsModule.createImportDeclaration( undefined, undefined, - tsModule.createImportClause(tsModule.createIdentifier('__Component'), undefined), + tsModule.createImportClause(tsModule.createIdentifier(importedComponentName), undefined), tsModule.createLiteral(componentFilePath) ); @@ -247,7 +249,7 @@ export function injectVueTemplate( const renderElement = tsModule.createExpressionStatement( tsModule.createCall(tsModule.createIdentifier(renderHelperName), undefined, [ // Reference to the component - tsModule.createIdentifier('__Component'), + tsModule.createIdentifier(importedComponentName), // A function simulating the render function tsModule.createFunctionExpression( undefined, diff --git a/server/src/services/typescriptService/serviceHost.ts b/server/src/services/typescriptService/serviceHost.ts index 5d6f352a7f..b6e15a3abb 100644 --- a/server/src/services/typescriptService/serviceHost.ts +++ b/server/src/services/typescriptService/serviceHost.ts @@ -14,6 +14,7 @@ import { TemplateSourceMap, stringifySourceMapNodes } from './sourceMap'; import { isVirtualVueTemplateFile, isVueFile } from './util'; import { logger } from '../../log'; import { ModuleResolutionCache } from './moduleResolutionCache'; +import { globalScope } from './transformTemplate'; const NEWLINE = process.platform === 'win32' ? '\r\n' : '\n'; @@ -406,7 +407,7 @@ export function getServiceHost( const registry = tsModule.createDocumentRegistry(true); let jsLanguageService = tsModule.createLanguageService(jsHost, registry); - const templateLanguageService = tsModule.createLanguageService(templateHost, registry); + const templateLanguageService = patchTemplateService(tsModule.createLanguageService(templateHost, registry)); return { queryVirtualFileInfo, @@ -419,6 +420,33 @@ export function getServiceHost( }; } +function patchTemplateService(original: ts.LanguageService): ts.LanguageService { + const allowedGlobals = new Set(globalScope); + + return { + ...original, + + getCompletionsAtPosition(fileName, position, options) { + const result = original.getCompletionsAtPosition(fileName, position, options); + if (!result) { + return; + } + + if (result.isMemberCompletion) { + return result; + } + + return { + ...result, + + entries: result.entries.filter(entry => { + return allowedGlobals.has(entry.name); + }) + }; + } + }; +} + function defaultIgnorePatterns(tsModule: T_TypeScript, workspacePath: string) { const nodeModules = ['node_modules', '**/node_modules/*']; const gitignore = tsModule.findConfigFile(workspacePath, tsModule.sys.fileExists, '.gitignore'); diff --git a/server/src/services/typescriptService/sourceMap.ts b/server/src/services/typescriptService/sourceMap.ts index 6eb99ee9fb..3402a2a1e9 100644 --- a/server/src/services/typescriptService/sourceMap.ts +++ b/server/src/services/typescriptService/sourceMap.ts @@ -276,7 +276,10 @@ function updateOffsetMapping(node: TemplateSourceMapNode, isThisInjected: boolea const mapping = fillIntermediate ? from.map((from, i) => [from, toFiltered[i]]) - : [[from[0], toFiltered[0]], [from[from.length - 1], toFiltered[toFiltered.length - 1]]]; + : [ + [from[0], toFiltered[0]], + [from[from.length - 1], toFiltered[toFiltered.length - 1]] + ]; mapping.forEach(([fromOffset, toOffset]) => { const from = fromOffset + node.from.start; diff --git a/server/src/services/typescriptService/transformTemplate.ts b/server/src/services/typescriptService/transformTemplate.ts index ba5d71c04e..c5abe5ed20 100644 --- a/server/src/services/typescriptService/transformTemplate.ts +++ b/server/src/services/typescriptService/transformTemplate.ts @@ -11,7 +11,7 @@ export const iterationHelperName = '__vlsIterationHelper'; * Allowed global variables in templates. * Borrowed from: https://github.com/vuejs/vue/blob/dev/src/core/instance/proxy.js */ -const globalScope = ( +export const globalScope = ( 'Infinity,undefined,NaN,isFinite,isNaN,' + 'parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,' + 'Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,' + @@ -201,7 +201,6 @@ export function getTemplateTransformFunctions(ts: T_TypeScript) { ts.createBlock([]) ); } - return directiveToObjectElement(vOn, exp, code, scope); } @@ -732,8 +731,4 @@ export function getTemplateTransformFunctions(ts: T_TypeScript) { function isVSlot(node: AST.VAttribute | AST.VDirective): node is AST.VDirective { return node.directive && (node.key.name.name === 'slot' || node.key.name.name === 'slot-scope'); } - - function hasValidPos(node: ts.Node) { - return node.pos !== -1 && node.end !== -1; - } } diff --git a/server/src/services/typescriptService/util.ts b/server/src/services/typescriptService/util.ts index d57fec3419..5d3631abc5 100644 --- a/server/src/services/typescriptService/util.ts +++ b/server/src/services/typescriptService/util.ts @@ -1,3 +1,6 @@ +import * as ts from 'typescript'; +import { CompletionItemKind, SymbolKind } from 'vscode-languageserver'; + export function isVueFile(path: string) { return path.endsWith('.vue'); } @@ -17,3 +20,79 @@ export function isVirtualVueFile(path: string) { export function isVirtualVueTemplateFile(path: string) { return path.endsWith('.vue.template'); } + +export function findNodeByOffset(root: ts.Node, offset: number): ts.Node | undefined { + if (offset < root.getStart() || root.getEnd() < offset) { + return undefined; + } + + const childMatch = root.getChildren().reduce((matched, child) => { + return matched || findNodeByOffset(child, offset); + }, undefined); + + return childMatch ? childMatch : root; +} + +export function toCompletionItemKind(kind: ts.ScriptElementKind): CompletionItemKind { + switch (kind) { + case 'primitive type': + case 'keyword': + return CompletionItemKind.Keyword; + case 'var': + case 'local var': + return CompletionItemKind.Variable; + case 'property': + case 'getter': + case 'setter': + return CompletionItemKind.Field; + case 'function': + case 'method': + case 'construct': + case 'call': + case 'index': + return CompletionItemKind.Function; + case 'enum': + return CompletionItemKind.Enum; + case 'module': + return CompletionItemKind.Module; + case 'class': + return CompletionItemKind.Class; + case 'interface': + return CompletionItemKind.Interface; + case 'warning': + return CompletionItemKind.File; + case 'script': + return CompletionItemKind.File; + case 'directory': + return CompletionItemKind.Folder; + } + + return CompletionItemKind.Property; +} + +export function toSymbolKind(kind: ts.ScriptElementKind): SymbolKind { + switch (kind) { + case 'var': + case 'local var': + case 'const': + return SymbolKind.Variable; + case 'function': + case 'local function': + return SymbolKind.Function; + case 'enum': + return SymbolKind.Enum; + case 'module': + return SymbolKind.Module; + case 'class': + return SymbolKind.Class; + case 'interface': + return SymbolKind.Interface; + case 'method': + return SymbolKind.Method; + case 'property': + case 'getter': + case 'setter': + return SymbolKind.Property; + } + return SymbolKind.Variable; +} diff --git a/server/src/services/vls.ts b/server/src/services/vls.ts index 045f13beec..425cc8cc9e 100644 --- a/server/src/services/vls.ts +++ b/server/src/services/vls.ts @@ -48,6 +48,7 @@ import { DocumentService } from './documentService'; import { VueHTMLMode } from '../modes/template'; import { logger } from '../log'; import { getDefaultVLSConfig, VLSFullConfig, VLSConfig } from '../config'; +import { LanguageId } from '../embeddedSupport/embeddedSupport'; export class VLS { // @Todo: Remove this and DocumentContext @@ -294,7 +295,20 @@ export class VLS { onCompletionResolve(item: CompletionItem): CompletionItem { if (item.data) { - const { uri, languageId } = item.data; + const uri: string = item.data.uri; + const languageId: LanguageId = item.data.languageId; + + /** + * Template files need to go through HTML-template service + */ + if (uri.endsWith('.template')) { + const doc = this.documentService.getDocument(uri.slice(0, -'.template'.length)); + const mode = this.languageModes.getMode(languageId); + if (doc && mode && mode.doResolve) { + return mode.doResolve(doc, item); + } + } + if (uri && languageId) { const doc = this.documentService.getDocument(uri); const mode = this.languageModes.getMode(languageId); diff --git a/server/yarn.lock b/server/yarn.lock index 29f2488193..d6ace25f0c 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -1439,11 +1439,12 @@ escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1 resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= -eslint-plugin-vue@^6.0.1: - version "6.1.2" - resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-6.1.2.tgz#4b05c28c83c0ec912669b64dbd998bb8bf692ef6" - integrity sha512-M75oAB+2a/LNkLKRbeEaS07EjzjIUaV7/hYoHAfRFeeF8ZMmCbahUn8nQLsLP85mkar24+zDU3QW2iT1JRsACw== +eslint-plugin-vue@^6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-6.2.1.tgz#ca802df5c33146aed1e56bb21d250c1abb6120a3" + integrity sha512-MiIDOotoWseIfLIfGeDzF6sDvHkVvGd2JgkvjyHtN3q4RoxdAXrAMuI3SXTOKatljgacKwpNAYShmcKZa4yZzw== dependencies: + natural-compare "^1.4.0" semver "^5.6.0" vue-eslint-parser "^7.0.0" diff --git a/test/codeTestRunner.ts b/test/codeTestRunner.ts index 98fad009f7..805d2b7856 100644 --- a/test/codeTestRunner.ts +++ b/test/codeTestRunner.ts @@ -2,75 +2,54 @@ import * as path from 'path'; import * as cp from 'child_process'; import * as fs from 'fs'; import * as $ from 'shelljs'; -import { downloadAndUnzipVSCode } from 'vscode-test'; +import * as minimist from 'minimist'; + +import { downloadAndUnzipVSCode, runTests } from 'vscode-test'; console.log('### Vetur Integration Test ###'); console.log(''); const EXT_ROOT = path.resolve(__dirname, '../../'); -function runTests(execPath: string, testWorkspaceRelativePath: string): Promise { - return new Promise((resolve, reject) => { - const testWorkspace = path.resolve(EXT_ROOT, testWorkspaceRelativePath, 'fixture'); - const extTestPath = path.resolve(EXT_ROOT, 'dist', testWorkspaceRelativePath); - const userDataDir = path.resolve(EXT_ROOT, testWorkspaceRelativePath, 'data-dir'); - - const args = [ - testWorkspace, - '--extensionDevelopmentPath=' + EXT_ROOT, - '--extensionTestsPath=' + extTestPath, - '--locale=en', - '--disable-extensions' - ]; - args.push(`--user-data-dir=${userDataDir}`); - - console.log(`Test folder: ${path.join('dist', testWorkspaceRelativePath)}`); - console.log(`Workspace: ${testWorkspaceRelativePath}`); - if (fs.existsSync(userDataDir)) { - console.log(`Data dir: ${userDataDir}`); - } - - const cmd = cp.spawn(execPath, args); +async function run(execPath: string, testWorkspaceRelativePath: string, mochaArgs: any): Promise { + const testWorkspace = path.resolve(EXT_ROOT, testWorkspaceRelativePath, 'fixture'); + const extTestPath = path.resolve(EXT_ROOT, 'dist', testWorkspaceRelativePath); + const userDataDir = path.resolve(EXT_ROOT, testWorkspaceRelativePath, 'data-dir'); - cmd.stdout.on('data', function(data) { - const s = data.toString(); - if (!s.includes('update#setState idle')) { - console.log(s); - } - }); - - cmd.stderr.on('data', function(data) { - const s = data.toString(); - if (!s.includes('stty: stdin')) { - console.log(`Spawn Error: ${data.toString()}`); - } - }); + const args = [testWorkspace, '--locale=en', '--disable-extensions', `--user-data-dir=${userDataDir}`]; - cmd.on('error', function(data) { - console.log('Test error: ' + data.toString()); - }); - - cmd.on('close', function(code) { - console.log(`Exit code: ${code}`); - - if (code !== 0) { - reject('Failed'); - } + console.log(`Test folder: ${path.join('dist', testWorkspaceRelativePath)}`); + console.log(`Workspace: ${testWorkspaceRelativePath}`); + if (fs.existsSync(userDataDir)) { + console.log(`Data dir: ${userDataDir}`); + } - console.log('Done\n'); - resolve(code); - }); + return await runTests({ + vscodeExecutablePath: execPath, + extensionDevelopmentPath: EXT_ROOT, + extensionTestsPath: extTestPath, + extensionTestsEnv: mochaArgs, + launchArgs: args }); } async function runAllTests(execPath: string) { const testDirs = fs.readdirSync(path.resolve(EXT_ROOT, './test')).filter(p => !p.includes('.')); - const targetDir = process.argv[2]; + const argv = minimist(process.argv.slice(2)); + const targetDir = argv._[0]; + + const mochaArgs = {}; + Object.keys(argv) + .filter(k => k.match(/^[A-Za-z]/)) + .forEach(k => { + mochaArgs[`MOCHA_${k}`] = argv[k]; + }); + if (targetDir && testDirs.indexOf(targetDir) !== -1) { try { installMissingDependencies(path.resolve(path.resolve(EXT_ROOT, `./test/${targetDir}/fixture`))); - await runTests(execPath, `test/${targetDir}`); + await run(execPath, `test/${targetDir}`, mochaArgs); } catch (err) { console.error(err); process.exit(1); @@ -79,7 +58,7 @@ async function runAllTests(execPath: string) { for (const dir of testDirs) { try { installMissingDependencies(path.resolve(path.resolve(EXT_ROOT, `./test/${dir}/fixture`))); - await runTests(execPath, `test/${dir}`); + await run(execPath, `test/${dir}`, mochaArgs); } catch (err) { console.error(err); process.exit(1); diff --git a/test/grammar/index.ts b/test/grammar/index.ts index 105b20c555..a9e76d83c5 100644 --- a/test/grammar/index.ts +++ b/test/grammar/index.ts @@ -3,10 +3,19 @@ import * as Mocha from 'mocha'; import * as glob from 'glob'; export function run(): Promise { + const args = {}; + + Object.keys(process.env) + .filter(k => k.startsWith('MOCHA_')) + .forEach(k => { + args[k.slice('MOCHA_'.length)] = process.env[k]; + }); + // Create the mocha test const mocha = new Mocha({ ui: 'bdd', - timeout: 100000 + timeout: 100000, + ...args }); mocha.useColors(true); diff --git a/test/interpolation/completion/basic.test.ts b/test/interpolation/completion/basic.test.ts index 2769a4fc62..02967c0dfa 100644 --- a/test/interpolation/completion/basic.test.ts +++ b/test/interpolation/completion/basic.test.ts @@ -10,8 +10,7 @@ describe('Should autocomplete interpolation for