-
-
Notifications
You must be signed in to change notification settings - Fork 594
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
Type checking for template expressions #681
Changes from all commits
e98740d
e4b1549
dc9d8c1
912b134
a68726e
a66b78c
a10d492
6865c30
34a58dc
3b852e4
de2e859
c6a38a0
b90f96a
1f02005
deaa8a7
68b8200
6fc1e52
60562ef
3138243
7696040
b7a805b
a8ef51e
0c97e75
1642733
ce43c75
55191cb
bd98089
9f5304d
f2f3236
bca4ad4
5d6d62f
0fd53f0
0542c1a
653bf3d
aabba0f
31d88bf
bec63eb
71fd337
4e73004
b8be711
889d1ce
39c807b
30ddc84
e26d5d9
79dc034
ef7978a
c143dfa
d0713d1
eff560a
f428765
2923e08
96797c0
de998c6
532074a
ab4a5cc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -44,11 +44,14 @@ | |
"vscode-languageserver": "^5.3.0-next.4", | ||
"vscode-languageserver-types": "^3.15.0-next.1", | ||
"vscode-uri": "^1.0.1", | ||
"vue-eslint-parser": "^6.0.3", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm using v6 now. There are a few changes in the AST format. Take a look at the transformer for Todo comments. |
||
"vue-onsenui-helper-json": "^1.0.2", | ||
"vuetify-helper-json": "^1.0.0" | ||
}, | ||
"devDependencies": { | ||
"@types/eslint": "^4.16.5", | ||
"@types/eslint-scope": "^3.7.0", | ||
"@types/eslint-visitor-keys": "^1.0.0", | ||
"@types/glob": "^7.1.0", | ||
"@types/js-beautify": "^1.8.0", | ||
"@types/lodash": "^4.14.118", | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,21 +1,44 @@ | ||
// this bridge file will be injected into TypeScript service | ||
import { renderHelperName, componentHelperName, iterationHelperName, listenerHelperName } from './transformTemplate'; | ||
|
||
// This bridge file will be injected into TypeScript language service | ||
// it enable type checking and completion, yet still preserve precise option type | ||
|
||
export const moduleName = 'vue-editor-bridge'; | ||
|
||
export const fileName = 'vue-temp/vue-editor-bridge.ts'; | ||
|
||
export const oldContent = ` | ||
const renderHelpers = ` | ||
export declare const ${renderHelperName}: { | ||
<T>(Component: (new (...args: any[]) => T), fn: (this: T) => any): any; | ||
}; | ||
export declare const ${componentHelperName}: { | ||
(tag: string, data: any, children: any[]): any; | ||
}; | ||
export declare const ${iterationHelperName}: { | ||
<T>(list: T[], fn: (value: T, index: number) => any): any; | ||
<T>(obj: { [key: string]: T }, fn: (value: T, key: string, index: number) => any): any; | ||
(num: number, fn: (value: number) => any): any; | ||
<T>(obj: object, fn: (value: any, key: string, index: number) => any): any; | ||
}; | ||
export declare const ${listenerHelperName}: { | ||
<T>(vm: T, listener: (this: T, ...args: any[]) => any): any; | ||
}; | ||
`; | ||
|
||
export const oldContent = | ||
` | ||
import Vue from 'vue'; | ||
export interface GeneralOption extends Vue.ComponentOptions<Vue> { | ||
[key: string]: any; | ||
} | ||
export default function bridge<T>(t: T & GeneralOption): T { | ||
return t; | ||
}`; | ||
} | ||
` + renderHelpers; | ||
|
||
export const content = ` | ||
export const content = | ||
` | ||
import Vue from 'vue'; | ||
const func = Vue.extend; | ||
export default func; | ||
`; | ||
` + renderHelpers; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -102,18 +102,23 @@ export async function getJavascriptMode( | |
}, | ||
|
||
doValidation(doc: TextDocument): Diagnostic[] { | ||
const templateDiags = getTemplateDiagnostics(); | ||
const scriptDiags = getScriptDiagnostics(); | ||
return [...templateDiags, ...scriptDiags]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Decided to keep the diagnostics calculation all in JS mode. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @octref I realized this might cause an edge case. When we do not have There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @ktsn When you don't have There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mainly detecting some typos. For example, sometimes I have some typo in template expression like: <template>
<ChildComp @click="$('click')" /> <!-- <= typo of $emit('click') -->
</template> Another example is when we use scoped slot component (scoped slot variables not handled currently though): <template>
<apollo-query :query="..." v-slot="{ isLoading, result }">
<-- typo of v-if="isLoading" -->
<div v-if="loading">Loading...</div>
<div v-else-if="result.data">{{ result.data }}</div>
</apollo-query>
</template> There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'll open a new issue for this. Let's keep these two things separate:
|
||
|
||
function getScriptDiagnostics(): Diagnostic[] { | ||
const { scriptDoc, service } = updateCurrentTextDocument(doc); | ||
if (!languageServiceIncludesFile(service, doc.uri)) { | ||
return []; | ||
} | ||
|
||
const fileFsPath = getFileFsPath(doc.uri); | ||
const diagnostics = [ | ||
const rawScriptDiagnostics = [ | ||
...service.getSyntacticDiagnostics(fileFsPath), | ||
...service.getSemanticDiagnostics(fileFsPath) | ||
]; | ||
|
||
return diagnostics.map(diag => { | ||
return rawScriptDiagnostics.map(diag => { | ||
const tags: DiagnosticTag[] = []; | ||
|
||
if (diag.reportsUnnecessary) { | ||
|
@@ -131,6 +136,39 @@ export async function getJavascriptMode( | |
source: 'Vetur' | ||
}; | ||
}); | ||
} | ||
|
||
function getTemplateDiagnostics(): Diagnostic[] { | ||
const enabledTemplateValidation = config.vetur.experimental.templateTypeCheck; | ||
if (!enabledTemplateValidation) { | ||
return []; | ||
} | ||
|
||
// Add suffix to process this doc as vue template. | ||
const templateDoc = TextDocument.create(doc.uri + '.template', doc.languageId, doc.version, doc.getText()); | ||
|
||
const { templateService } = updateCurrentTextDocument(templateDoc); | ||
if (!languageServiceIncludesFile(templateService, templateDoc.uri)) { | ||
return []; | ||
} | ||
|
||
const templateFileFsPath = getFileFsPath(templateDoc.uri); | ||
// We don't need syntactic diagnostics because | ||
// compiled template is always valid JavaScript syntax. | ||
const rawTemplateDiagnostics = templateService.getSemanticDiagnostics(templateFileFsPath); | ||
|
||
return rawTemplateDiagnostics.map(diag => { | ||
// syntactic/semantic diagnostic always has start and length | ||
// so we can safely cast diag to TextSpan | ||
return { | ||
range: convertRange(templateDoc, diag as ts.TextSpan), | ||
severity: DiagnosticSeverity.Error, | ||
message: ts.flattenDiagnosticMessageText(diag.messageText, '\n'), | ||
code: diag.code, | ||
source: 'Vetur' | ||
}; | ||
}); | ||
} | ||
}, | ||
doComplete(doc: TextDocument, position: Position): CompletionList { | ||
const { scriptDoc, service } = updateCurrentTextDocument(doc); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,32 +1,85 @@ | ||
import * as ts from 'typescript'; | ||
import * as path from 'path'; | ||
import { parse } from 'vue-eslint-parser'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can implement parsing in VLS at all, so no additional dependency/script is needed. Actually template completion needs it too. But I don't have time to implement Vue specific elements 😞 . For now eslint-parser is the only option. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes. I actually tried to extend the parser in VLS on the first time but I ended up using vue-eslint-parser because I would like to focus finishing essential implementation of template type checking at first. |
||
|
||
import { getVueDocumentRegions } from '../../embeddedSupport/embeddedSupport'; | ||
import { TextDocument } from 'vscode-languageserver-types'; | ||
import { T_TypeScript } from '../../services/dependencyService'; | ||
import { | ||
getTemplateTransformFunctions, | ||
componentHelperName, | ||
iterationHelperName, | ||
renderHelperName, | ||
listenerHelperName | ||
} from './transformTemplate'; | ||
import { isVirtualVueTemplateFile } from './serviceHost'; | ||
|
||
export function isVue(filename: string): boolean { | ||
return path.extname(filename) === '.vue'; | ||
} | ||
|
||
export function parseVue(text: string): string { | ||
export function parseVueScript(text: string): string { | ||
const doc = TextDocument.create('test://test/test.vue', 'vue', 0, text); | ||
const regions = getVueDocumentRegions(doc); | ||
const script = regions.getSingleTypeDocument('script'); | ||
return script.getText() || 'export default {};'; | ||
} | ||
|
||
function parseVueTemplate(text: string): string { | ||
const doc = TextDocument.create('test://test/test.vue', 'vue', 0, text); | ||
const regions = getVueDocumentRegions(doc); | ||
const template = regions.getSingleTypeDocument('template'); | ||
|
||
if (template.languageId !== 'vue-html') { | ||
return ''; | ||
} | ||
const rawText = template.getText(); | ||
// skip checking on empty template | ||
if (rawText.replace(/\s/g, '') === '') { | ||
return ''; | ||
} | ||
return rawText.replace(/^\s*\n/, '<template>\n').replace(/\s*\n$/, '\n</template>'); | ||
} | ||
|
||
export function createUpdater(tsModule: T_TypeScript) { | ||
const clssf = tsModule.createLanguageServiceSourceFile; | ||
const ulssf = tsModule.updateLanguageServiceSourceFile; | ||
const scriptKindTracker = new WeakMap<ts.SourceFile, ts.ScriptKind | undefined>(); | ||
const modificationTracker = new WeakSet<ts.SourceFile>(); | ||
|
||
function isTSLike(scriptKind: ts.ScriptKind | undefined) { | ||
return scriptKind === ts.ScriptKind.TS || scriptKind === ts.ScriptKind.TSX; | ||
return scriptKind === tsModule.ScriptKind.TS || scriptKind === tsModule.ScriptKind.TSX; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Vetur now uses TS from workspace, or fallbacks to bundled TS. That's why you need to use I'll do the conversion for |
||
} | ||
|
||
return { | ||
createLanguageServiceSourceFile( | ||
function modifySourceFile( | ||
fileName: string, | ||
sourceFile: ts.SourceFile, | ||
scriptSnapshot: ts.IScriptSnapshot, | ||
version: string, | ||
scriptKind?: ts.ScriptKind | ||
): void { | ||
if (modificationTracker.has(sourceFile)) { | ||
return; | ||
} | ||
|
||
if (isVue(fileName) && !isTSLike(scriptKind)) { | ||
modifyVueScript(tsModule, sourceFile); | ||
modificationTracker.add(sourceFile); | ||
return; | ||
} | ||
|
||
if (isVirtualVueTemplateFile(fileName)) { | ||
// TODO: share the logic of transforming the code into AST | ||
// with the template mode | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @ktsn Can you clarify what's your plan for this one? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To use Vetur's template parser instead of vue-eslint-parser. I guess I'll work on it after this PR. |
||
const code = parseVueTemplate(scriptSnapshot.getText(0, scriptSnapshot.getLength())); | ||
const program = parse(code, { sourceType: 'module' }); | ||
const tsCode = getTemplateTransformFunctions(tsModule).transformTemplate(program, code); | ||
injectVueTemplate(tsModule, sourceFile, tsCode); | ||
modificationTracker.add(sourceFile); | ||
} | ||
} | ||
|
||
function createLanguageServiceSourceFile( | ||
fileName: string, | ||
scriptSnapshot: ts.IScriptSnapshot, | ||
scriptTarget: ts.ScriptTarget, | ||
|
@@ -36,12 +89,11 @@ export function createUpdater(tsModule: T_TypeScript) { | |
): ts.SourceFile { | ||
const sourceFile = clssf(fileName, scriptSnapshot, scriptTarget, version, setNodeParents, scriptKind); | ||
scriptKindTracker.set(sourceFile, scriptKind); | ||
if (isVue(fileName) && !isTSLike(scriptKind)) { | ||
modifyVueSource(tsModule, sourceFile); | ||
} | ||
modifySourceFile(fileName, sourceFile, scriptSnapshot, version, scriptKind); | ||
return sourceFile; | ||
}, | ||
updateLanguageServiceSourceFile( | ||
} | ||
|
||
function updateLanguageServiceSourceFile( | ||
sourceFile: ts.SourceFile, | ||
scriptSnapshot: ts.IScriptSnapshot, | ||
version: string, | ||
|
@@ -50,15 +102,17 @@ export function createUpdater(tsModule: T_TypeScript) { | |
): ts.SourceFile { | ||
const scriptKind = scriptKindTracker.get(sourceFile); | ||
sourceFile = ulssf(sourceFile, scriptSnapshot, version, textChangeRange, aggressiveChecks); | ||
if (isVue(sourceFile.fileName) && !isTSLike(scriptKind)) { | ||
modifyVueSource(tsModule, sourceFile); | ||
} | ||
modifySourceFile(sourceFile.fileName, sourceFile, scriptSnapshot, version, scriptKind); | ||
return sourceFile; | ||
} | ||
|
||
return { | ||
createLanguageServiceSourceFile, | ||
updateLanguageServiceSourceFile | ||
}; | ||
} | ||
|
||
function modifyVueSource(tsModule: T_TypeScript, sourceFile: ts.SourceFile): void { | ||
function modifyVueScript(tsModule: T_TypeScript, sourceFile: ts.SourceFile): void { | ||
const exportDefaultObject = sourceFile.statements.find( | ||
st => | ||
st.kind === tsModule.SyntaxKind.ExportAssignment && | ||
|
@@ -94,6 +148,91 @@ function modifyVueSource(tsModule: T_TypeScript, sourceFile: ts.SourceFile): voi | |
} | ||
} | ||
|
||
/** | ||
* Wrap render function with component options in the script block | ||
* to validate its types | ||
*/ | ||
function injectVueTemplate(tsModule: T_TypeScript, sourceFile: ts.SourceFile, renderBlock: ts.Expression[]): void { | ||
// add import statement for corresponding Vue file | ||
// so that we acquire the component type from it. | ||
const setZeroPos = getWrapperRangeSetter(tsModule, { pos: 0, end: 0 }); | ||
const vueFilePath = './' + path.basename(sourceFile.fileName.slice(0, -9)); | ||
const componentImport = setZeroPos( | ||
tsModule.createImportDeclaration( | ||
undefined, | ||
undefined, | ||
setZeroPos(tsModule.createImportClause(setZeroPos(tsModule.createIdentifier('__Component')), undefined)), | ||
setZeroPos(tsModule.createLiteral(vueFilePath)) | ||
) | ||
); | ||
|
||
// import helper type to handle Vue's private methods | ||
const helperImport = setZeroPos( | ||
tsModule.createImportDeclaration( | ||
undefined, | ||
undefined, | ||
setZeroPos( | ||
tsModule.createImportClause( | ||
undefined, | ||
setZeroPos( | ||
tsModule.createNamedImports([ | ||
setZeroPos( | ||
tsModule.createImportSpecifier(undefined, setZeroPos(tsModule.createIdentifier(renderHelperName))) | ||
), | ||
setZeroPos( | ||
tsModule.createImportSpecifier(undefined, setZeroPos(tsModule.createIdentifier(componentHelperName))) | ||
), | ||
setZeroPos( | ||
tsModule.createImportSpecifier(undefined, setZeroPos(tsModule.createIdentifier(iterationHelperName))) | ||
), | ||
setZeroPos( | ||
tsModule.createImportSpecifier(undefined, setZeroPos(tsModule.createIdentifier(listenerHelperName))) | ||
) | ||
]) | ||
) | ||
) | ||
), | ||
setZeroPos(tsModule.createLiteral('vue-editor-bridge')) | ||
) | ||
); | ||
|
||
// wrap render code with a function decralation | ||
// with `this` type of component. | ||
const setRenderPos = getWrapperRangeSetter(tsModule, sourceFile); | ||
const statements = renderBlock.map(exp => tsModule.createStatement(exp)); | ||
const renderElement = setRenderPos( | ||
tsModule.createStatement( | ||
setRenderPos( | ||
tsModule.createCall(setRenderPos(tsModule.createIdentifier(renderHelperName)), undefined, [ | ||
// Reference to the component | ||
setRenderPos(tsModule.createIdentifier('__Component')), | ||
|
||
// A function simulating the render function | ||
setRenderPos( | ||
tsModule.createFunctionExpression( | ||
undefined, | ||
undefined, | ||
undefined, | ||
undefined, | ||
[], | ||
undefined, | ||
setRenderPos(tsModule.createBlock(statements)) | ||
) | ||
) | ||
]) | ||
) | ||
) | ||
); | ||
|
||
// replace the original statements with wrapped code. | ||
sourceFile.statements = setRenderPos(tsModule.createNodeArray([componentImport, helperImport, renderElement])); | ||
|
||
// Update external module indicator to the transformed template node, | ||
// otherwise symbols in this template (e.g. __Component) will be put | ||
// into global namespace and it causes duplicated identifier error. | ||
(sourceFile as any).externalModuleIndicator = componentImport; | ||
} | ||
|
||
/** Create a function that calls setTextRange on synthetic wrapper nodes that need a valid range */ | ||
function getWrapperRangeSetter( | ||
tsModule: T_TypeScript, | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we can enable it by default to get more feedback.