Skip to content

Commit 73cc4de

Browse files
authored
Merge pull request #2294 from vuejs/cross-file-template-type-checking
Fix #1596
2 parents 8c12f6a + e17462c commit 73cc4de

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+843
-322
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
### 0.28.0
4+
5+
- Cross file template type checking - check that components are passed props with the correct types. #1596 and #2294.
6+
37
### 0.27.3 | 2020-09-13 | [VSIX](https://marketplace.visualstudio.com/_apis/public/gallery/publishers/octref/vsextensions/vetur/0.27.3/vspackage)
48

59
- 🙌 Fix corner case when analyzing class component. Thanks to contribution from [@yoyo930021](https://github.com/yoyo930021). #2254 and #2260.

docs/interpolation.md

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ export default {
8888

8989
## Prop Validation
9090

91+
*You can turn on/off this feature with `vetur.validation.templateProps`.*
92+
9193
Vetur will now validate HTML templates that uses child components. For example, given two children:
9294

9395
`Simple.vue`:
@@ -120,4 +122,54 @@ Vetur will show a warming for `<simple>` and an error for `<complex>`.
120122
The rules are:
121123

122124
- When using [array props](https://vuejs.org/v2/guide/components-props.html#Prop-Types), show **warning** for missing props.
123-
- When using [object prop validation](https://vuejs.org/v2/guide/components-props.html#Prop-Validation), show errors for missing `required` props.
125+
- When using [object prop validation](https://vuejs.org/v2/guide/components-props.html#Prop-Validation), show errors for missing `required` props.
126+
127+
## Prop Type Validation
128+
129+
*You can turn on/off this feature with `vetur.validation.templateProps`.*
130+
131+
Vetur will now validate that the interpolation expression you pass to child component's props match the props signature. Consider this simple case:
132+
133+
`Child.vue`:
134+
135+
```vue
136+
<template>
137+
<div></div>
138+
</template>
139+
140+
<script>
141+
export default {
142+
props: { str: String }
143+
}
144+
</script>
145+
```
146+
147+
`Parent.vue`:
148+
149+
```vue
150+
<template>
151+
<test :str="num" />
152+
</template>
153+
154+
<script>
155+
import Test from './Test.vue'
156+
157+
export default {
158+
components: { Test },
159+
data() {
160+
return {
161+
num: 42
162+
}
163+
}
164+
}
165+
</script>
166+
```
167+
168+
Vetur will generate a diagnostic error on `str` in `Parent.vue` template `:str="num"`, with a message that `type 'number' is not assignable to type 'string'`.
169+
170+
Supported:
171+
172+
- JS file with `export default {...}`
173+
- TS file with `defineComponent` in Vue 3 or `Vue.extend` in Vue 2
174+
- Prop Type: `foo: String`, `foo: { type: String }` or `foo: String as PropType<string>`
175+
- This is useful in the case of `foo: Array`. If you are using JS, there's no way to say `foo is a string array`, however with TS you can use `foo: Array as PropType<string[]>`. Vetur will then check that the provided expression matches `string[]`.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@
249249
"vetur.validation.templateProps": {
250250
"type": "boolean",
251251
"default": false,
252-
"description": "Validate props usage in <template> region. Show error/warning for not passing declared props to child components."
252+
"description": "Validate props usage in <template> region. Show error/warning for not passing declared props to child components and show error for passing wrongly typed interpolation expressions"
253253
},
254254
"vetur.validation.interpolation": {
255255
"type": "boolean",

server/src/embeddedSupport/languageModes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export class LanguageModes {
119119
}
120120

121121
/**
122-
* Documents where everything outside `<script>~ is replaced with whitespace
122+
* Documents where everything outside `<script>` is replaced with whitespace
123123
*/
124124
const scriptRegionDocuments = getLanguageModelCache(10, 60, document => {
125125
const vueDocument = this.documentRegions.refreshAndGet(document);

server/src/modes/script/componentInfo.ts

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,16 +138,90 @@ function getProps(tsModule: T_TypeScript, defaultExportType: ts.Type, checker: t
138138

139139
function getPropValidatorInfo(
140140
propertyValue: ts.Node | undefined
141-
): { hasObjectValidator: boolean; required: boolean } {
142-
if (!propertyValue || !tsModule.isObjectLiteralExpression(propertyValue)) {
141+
): { hasObjectValidator: boolean; required: boolean; typeString?: string } {
142+
if (!propertyValue) {
143143
return { hasObjectValidator: false, required: true };
144144
}
145145

146+
let typeString: string | undefined = undefined;
147+
let typeDeclaration: ts.Identifier | ts.AsExpression | undefined = undefined;
148+
149+
/**
150+
* case `foo: { type: String }`
151+
* extract type value: `String`
152+
*/
153+
if (tsModule.isObjectLiteralExpression(propertyValue)) {
154+
const propertyValueSymbol = checker.getTypeAtLocation(propertyValue).symbol;
155+
const typeValue = propertyValueSymbol?.members?.get('type' as ts.__String)?.valueDeclaration;
156+
if (typeValue && tsModule.isPropertyAssignment(typeValue)) {
157+
if (tsModule.isIdentifier(typeValue.initializer) || tsModule.isAsExpression(typeValue.initializer)) {
158+
typeDeclaration = typeValue.initializer;
159+
}
160+
}
161+
} else {
162+
/**
163+
* case `foo: String`
164+
* extract type value: `String`
165+
*/
166+
if (tsModule.isIdentifier(propertyValue) || tsModule.isAsExpression(propertyValue)) {
167+
typeDeclaration = propertyValue;
168+
}
169+
}
170+
171+
if (typeDeclaration) {
172+
/**
173+
* `String` case
174+
*
175+
* Per https://vuejs.org/v2/guide/components-props.html#Type-Checks, handle:
176+
*
177+
* String
178+
* Number
179+
* Boolean
180+
* Array
181+
* Object
182+
* Date
183+
* Function
184+
* Symbol
185+
*/
186+
if (tsModule.isIdentifier(typeDeclaration)) {
187+
const vueTypeCheckConstructorToTSType: Record<string, string> = {
188+
String: 'string',
189+
Number: 'number',
190+
Boolean: 'boolean',
191+
Array: 'any[]',
192+
Object: 'object',
193+
Date: 'Date',
194+
Function: 'Function',
195+
Symbol: 'Symbol'
196+
};
197+
const vueTypeString = typeDeclaration.getText();
198+
if (vueTypeCheckConstructorToTSType[vueTypeString]) {
199+
typeString = vueTypeCheckConstructorToTSType[vueTypeString];
200+
}
201+
} else if (
202+
/**
203+
* `String as PropType<'a' | 'b'>` case
204+
*/
205+
tsModule.isAsExpression(typeDeclaration) &&
206+
tsModule.isTypeReferenceNode(typeDeclaration.type) &&
207+
typeDeclaration.type.typeName.getText() === 'PropType' &&
208+
typeDeclaration.type.typeArguments &&
209+
typeDeclaration.type.typeArguments[0]
210+
) {
211+
const extractedPropType = typeDeclaration.type.typeArguments[0];
212+
typeString = extractedPropType.getText();
213+
}
214+
}
215+
216+
if (!propertyValue || !tsModule.isObjectLiteralExpression(propertyValue)) {
217+
return { hasObjectValidator: false, required: true, typeString };
218+
}
219+
146220
const propertyValueSymbol = checker.getTypeAtLocation(propertyValue).symbol;
147221
const requiredValue = propertyValueSymbol?.members?.get('required' as ts.__String)?.valueDeclaration;
148222
const defaultValue = propertyValueSymbol?.members?.get('default' as ts.__String)?.valueDeclaration;
149223
if (!requiredValue && !defaultValue) {
150-
return { hasObjectValidator: false, required: true };
224+
return { hasObjectValidator: false, required: true, typeString };
151225
}
152226

153227
const required = Boolean(
@@ -156,7 +230,7 @@ function getProps(tsModule: T_TypeScript, defaultExportType: ts.Type, checker: t
156230
requiredValue?.initializer.kind === tsModule.SyntaxKind.TrueKeyword
157231
);
158232

159-
return { hasObjectValidator: true, required };
233+
return { hasObjectValidator: true, required, typeString };
160234
}
161235

162236
function getClassProps(type: ts.Type) {

server/src/modes/template/index.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export class VueHTMLMode implements LanguageMode {
3535
const vueDocuments = getLanguageModelCache<HTMLDocument>(10, 60, document => parseHTMLDocument(document));
3636
const vueVersion = inferVueVersion(tsModule, workspacePath);
3737
this.htmlMode = new HTMLMode(documentRegions, workspacePath, vueVersion, vueDocuments, vueInfoService);
38-
this.vueInterpolationMode = new VueInterpolationMode(tsModule, serviceHost, vueDocuments);
38+
this.vueInterpolationMode = new VueInterpolationMode(tsModule, serviceHost, vueDocuments, vueInfoService);
3939
}
4040
getId() {
4141
return 'vue-html';
@@ -81,10 +81,9 @@ export class VueHTMLMode implements LanguageMode {
8181
return this.vueInterpolationMode.findReferences(document, position);
8282
}
8383
findDefinition(document: TextDocument, position: Position) {
84-
const interpolationDefinition = this.vueInterpolationMode.findDefinition(document, position);
85-
return interpolationDefinition.length > 0
86-
? interpolationDefinition
87-
: this.htmlMode.findDefinition(document, position);
84+
const htmlDefinition = this.htmlMode.findDefinition(document, position);
85+
86+
return htmlDefinition.length > 0 ? htmlDefinition : this.vueInterpolationMode.findDefinition(document, position);
8887
}
8988
getFoldingRanges(document: TextDocument) {
9089
return this.htmlMode.getFoldingRanges(document);

server/src/modes/template/interpolationMode.ts

Lines changed: 49 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,44 @@
1-
import { LanguageMode } from '../../embeddedSupport/languageModes';
1+
import * as _ from 'lodash';
2+
import * as ts from 'typescript';
23
import {
4+
CompletionItem,
5+
CompletionList,
6+
Definition,
37
Diagnostic,
4-
TextDocument,
58
DiagnosticSeverity,
6-
Position,
9+
Location,
710
MarkedString,
11+
MarkupContent,
12+
Position,
813
Range,
9-
Location,
10-
Definition,
11-
CompletionList,
12-
TextEdit,
13-
CompletionItem,
14-
MarkupContent
14+
TextDocument,
15+
TextEdit
1516
} from 'vscode-languageserver-types';
16-
import { IServiceHost } from '../../services/typescriptService/serviceHost';
17-
import { languageServiceIncludesFile } from '../script/javascript';
18-
import { getFileFsPath } from '../../utils/paths';
19-
import { mapBackRange, mapFromPositionToOffset } from '../../services/typescriptService/sourceMap';
2017
import { URI } from 'vscode-uri';
21-
import * as ts from 'typescript';
18+
import { VLSFullConfig } from '../../config';
19+
import { LanguageModelCache } from '../../embeddedSupport/languageModelCache';
20+
import { LanguageMode } from '../../embeddedSupport/languageModes';
2221
import { T_TypeScript } from '../../services/dependencyService';
23-
import * as _ from 'lodash';
22+
import { IServiceHost } from '../../services/typescriptService/serviceHost';
23+
import { mapBackRange, mapFromPositionToOffset } from '../../services/typescriptService/sourceMap';
2424
import { createTemplateDiagnosticFilter } from '../../services/typescriptService/templateDiagnosticFilter';
25-
import { NULL_COMPLETION } from '../nullMode';
2625
import { toCompletionItemKind } from '../../services/typescriptService/util';
27-
import { LanguageModelCache } from '../../embeddedSupport/languageModelCache';
26+
import { VueInfoService } from '../../services/vueInfoService';
27+
import { getFileFsPath } from '../../utils/paths';
28+
import { NULL_COMPLETION } from '../nullMode';
29+
import { languageServiceIncludesFile } from '../script/javascript';
30+
import * as Previewer from '../script/previewer';
2831
import { HTMLDocument } from './parser/htmlParser';
2932
import { isInsideInterpolation } from './services/isInsideInterpolation';
30-
import * as Previewer from '../script/previewer';
31-
import { VLSFullConfig } from '../../config';
3233

3334
export class VueInterpolationMode implements LanguageMode {
3435
private config: VLSFullConfig;
3536

3637
constructor(
3738
private tsModule: T_TypeScript,
3839
private serviceHost: IServiceHost,
39-
private vueDocuments: LanguageModelCache<HTMLDocument>
40+
private vueDocuments: LanguageModelCache<HTMLDocument>,
41+
private vueInfoService?: VueInfoService
4042
) {}
4143

4244
getId() {
@@ -67,7 +69,15 @@ export class VueInterpolationMode implements LanguageMode {
6769
document.getText()
6870
);
6971

70-
const { templateService, templateSourceMap } = this.serviceHost.updateCurrentVirtualVueTextDocument(templateDoc);
72+
const childComponents = this.config.vetur.validation.templateProps
73+
? this.vueInfoService && this.vueInfoService.getInfo(document)?.componentInfo.childComponents
74+
: [];
75+
76+
const { templateService, templateSourceMap } = this.serviceHost.updateCurrentVirtualVueTextDocument(
77+
templateDoc,
78+
childComponents
79+
);
80+
7181
if (!languageServiceIncludesFile(templateService, templateDoc.uri)) {
7282
return [];
7383
}
@@ -135,16 +145,27 @@ export class VueInterpolationMode implements LanguageMode {
135145
const mappedOffset = mapFromPositionToOffset(templateDoc, completionPos, templateSourceMap);
136146
const templateFileFsPath = getFileFsPath(templateDoc.uri);
137147

138-
const completions = templateService.getCompletionsAtPosition(templateFileFsPath, mappedOffset, {
139-
includeCompletionsWithInsertText: true,
140-
includeCompletionsForModuleExports: false
141-
});
148+
/**
149+
* A lot of times interpolation expressions aren't valid
150+
* TODO: Make sure interpolation expression, even incomplete, can generate incomplete
151+
* TS files that can be fed into language service
152+
*/
153+
let completions: ts.WithMetadata<ts.CompletionInfo> | undefined;
154+
try {
155+
completions = templateService.getCompletionsAtPosition(templateFileFsPath, mappedOffset, {
156+
includeCompletionsWithInsertText: true,
157+
includeCompletionsForModuleExports: false
158+
});
159+
} catch (err) {
160+
console.log('Interpolation completion failed');
161+
console.error(err.toString());
162+
}
142163

143164
if (!completions) {
144165
return NULL_COMPLETION;
145166
}
146167

147-
const tsItems = completions.entries.map((entry, index) => {
168+
const tsItems = completions!.entries.map((entry, index) => {
148169
return {
149170
uri: templateDoc.uri,
150171
position,
@@ -334,7 +355,7 @@ export class VueInterpolationMode implements LanguageMode {
334355
: convertRange(definitionTargetDoc, r.textSpan);
335356

336357
definitionResults.push({
337-
uri: URI.file(definitionTargetDoc.uri).toString(),
358+
uri: definitionTargetDoc.uri,
338359
range
339360
});
340361
}
@@ -382,7 +403,7 @@ export class VueInterpolationMode implements LanguageMode {
382403
: convertRange(referenceTargetDoc, r.textSpan);
383404

384405
referenceResults.push({
385-
uri: URI.file(referenceTargetDoc.uri).toString(),
406+
uri: referenceTargetDoc.uri,
386407
range
387408
});
388409
}

server/src/modes/template/services/htmlDefinition.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,25 +12,29 @@ export function findDefinition(
1212
position: Position,
1313
htmlDocument: HTMLDocument,
1414
vueFileInfo?: VueFileInfo
15-
): Definition {
15+
): Location[] {
1616
const offset = document.offsetAt(position);
1717
const node = htmlDocument.findNodeAt(offset);
1818
if (!node || !node.tag) {
1919
return [];
2020
}
2121

22-
function getTagDefinition(tag: string, range: Range, open: boolean): Definition {
22+
function getTagDefinition(tag: string, range: Range, open: boolean): Location[] {
2323
if (vueFileInfo && vueFileInfo.componentInfo.childComponents) {
2424
for (const cc of vueFileInfo.componentInfo.childComponents) {
25-
if (![tag, tag.toLowerCase(), kebabCase(tag)].includes(cc.name)) { continue; }
26-
if (!cc.definition) { continue; }
25+
if (![tag, tag.toLowerCase(), kebabCase(tag)].includes(cc.name)) {
26+
continue;
27+
}
28+
if (!cc.definition) {
29+
continue;
30+
}
2731

2832
const loc: Location = {
2933
uri: URI.file(cc.definition.path).toString(),
3034
// Todo: Resolve actual default export range
3135
range: Range.create(0, 0, 0, 0)
3236
};
33-
return loc;
37+
return [loc];
3438
}
3539
}
3640
return [];

0 commit comments

Comments
 (0)