feat(language-service): provide diagnostic for invalid templateUrls (#32586)
`templateUrls` that do not point to actual files are now diagnosed as such by the Language Service. Support for `styleUrls` will come in a next PR. This introduces a utility method `getPropertyValueOfType` that scans TypeScript ASTs until a property assignment whose initializer of a certain type is found. This PR also notices a couple of things that could be improved in the language-service implementation, such as enumerating directive properties and unifying common logic, that will be fixed in future PRs. Part of #32564. PR Close #32586
This commit is contained in:
		
							parent
							
								
									88c28ce208
								
							
						
					
					
						commit
						adeee0fa7f
					
				| @ -8,11 +8,13 @@ | ||||
| 
 | ||||
| import {NgAnalyzedModules} from '@angular/compiler'; | ||||
| import {getTemplateExpressionDiagnostics} from '@angular/compiler-cli/src/language_services'; | ||||
| import * as path from 'path'; | ||||
| import * as ts from 'typescript'; | ||||
| 
 | ||||
| import {AstResult} from './common'; | ||||
| import * as ng from './types'; | ||||
| import {offsetSpan, spanOf} from './utils'; | ||||
| import {TypeScriptServiceHost} from './typescript_host'; | ||||
| import {findPropertyValueOfType, findTightestNode, offsetSpan, spanOf} from './utils'; | ||||
| 
 | ||||
| /** | ||||
|  * Return diagnostic information for the parsed AST of the template. | ||||
| @ -53,8 +55,24 @@ function missingDirective(name: string, isComponent: boolean) { | ||||
|       'available inside a template. Consider adding it to a NgModule declaration.'; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Logs an error for an impossible state with a certain message. | ||||
|  */ | ||||
| function logImpossibleState(message: string) { | ||||
|   console.error(`Impossible state: ${message}`); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Performs a variety diagnostics on directive declarations. | ||||
|  * | ||||
|  * @param declarations Angular directive declarations | ||||
|  * @param modules NgModules in the project | ||||
|  * @param host TypeScript service host used to perform TypeScript queries | ||||
|  * @return diagnosed errors, if any | ||||
|  */ | ||||
| export function getDeclarationDiagnostics( | ||||
|     declarations: ng.Declaration[], modules: NgAnalyzedModules): ng.Diagnostic[] { | ||||
|     declarations: ng.Declaration[], modules: NgAnalyzedModules, | ||||
|     host: Readonly<TypeScriptServiceHost>): ng.Diagnostic[] { | ||||
|   const directives = new Set<ng.StaticSymbol>(); | ||||
|   for (const ngModule of modules.ngModules) { | ||||
|     for (const directive of ngModule.declaredDirectives) { | ||||
| @ -66,6 +84,20 @@ export function getDeclarationDiagnostics( | ||||
| 
 | ||||
|   for (const declaration of declarations) { | ||||
|     const {errors, metadata, type, declarationSpan} = declaration; | ||||
| 
 | ||||
|     const sf = host.getSourceFile(type.filePath); | ||||
|     if (!sf) { | ||||
|       logImpossibleState(`directive ${type.name} exists but has no source file`); | ||||
|       return []; | ||||
|     } | ||||
|     // TypeScript identifier of the directive declaration annotation (e.g. "Component" or
 | ||||
|     // "Directive") on a directive class.
 | ||||
|     const directiveIdentifier = findTightestNode(sf, declarationSpan.start); | ||||
|     if (!directiveIdentifier) { | ||||
|       logImpossibleState(`directive ${type.name} exists but has no identifier`); | ||||
|       return []; | ||||
|     } | ||||
| 
 | ||||
|     for (const error of errors) { | ||||
|       results.push({ | ||||
|         kind: ng.DiagnosticKind.Error, | ||||
| @ -91,13 +123,29 @@ export function getDeclarationDiagnostics( | ||||
|           message: `Component '${type.name}' must have a template or templateUrl`, | ||||
|           span: declarationSpan, | ||||
|         }); | ||||
|       } else if (template && templateUrl) { | ||||
|       } else if (templateUrl) { | ||||
|         if (template) { | ||||
|           results.push({ | ||||
|             kind: ng.DiagnosticKind.Error, | ||||
|             message: `Component '${type.name}' must not have both template and templateUrl`, | ||||
|             span: declarationSpan, | ||||
|           }); | ||||
|         } | ||||
| 
 | ||||
|         // Find templateUrl value from the directive call expression, which is the parent of the
 | ||||
|         // directive identifier.
 | ||||
|         //
 | ||||
|         // TODO: We should create an enum of the various properties a directive can have to use
 | ||||
|         // instead of string literals. We can then perform a mass migration of all literal usages.
 | ||||
|         const templateUrlNode = findPropertyValueOfType( | ||||
|             directiveIdentifier.parent, 'templateUrl', ts.isStringLiteralLike); | ||||
|         if (!templateUrlNode) { | ||||
|           logImpossibleState(`templateUrl ${templateUrl} exists but its TypeScript node doesn't`); | ||||
|           return []; | ||||
|         } | ||||
| 
 | ||||
|         results.push(...validateUrls([templateUrlNode], host.tsLsHost)); | ||||
|       } | ||||
|     } else if (!directives.has(declaration.type)) { | ||||
|       results.push({ | ||||
|         kind: ng.DiagnosticKind.Error, | ||||
| @ -110,6 +158,39 @@ export function getDeclarationDiagnostics( | ||||
|   return results; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Checks that URLs on a directive point to a valid file. | ||||
|  * Note that this diagnostic check may require a filesystem hit, and thus may be slower than other | ||||
|  * checks. | ||||
|  * | ||||
|  * @param urls urls to check for validity | ||||
|  * @param tsLsHost TS LS host used for querying filesystem information | ||||
|  * @return diagnosed url errors, if any | ||||
|  */ | ||||
| function validateUrls( | ||||
|     urls: ts.StringLiteralLike[], tsLsHost: Readonly<ts.LanguageServiceHost>): ng.Diagnostic[] { | ||||
|   if (!tsLsHost.fileExists) { | ||||
|     return []; | ||||
|   } | ||||
| 
 | ||||
|   const allErrors: ng.Diagnostic[] = []; | ||||
|   // TODO(ayazhafiz): most of this logic can be unified with the logic in
 | ||||
|   // definitions.ts#getUrlFromProperty. Create a utility function to be used by both.
 | ||||
|   for (const urlNode of urls) { | ||||
|     const curPath = urlNode.getSourceFile().fileName; | ||||
|     const url = path.join(path.dirname(curPath), urlNode.text); | ||||
|     if (tsLsHost.fileExists(url)) continue; | ||||
| 
 | ||||
|     allErrors.push({ | ||||
|       kind: ng.DiagnosticKind.Error, | ||||
|       message: `URL does not point to a valid file`, | ||||
|       // Exclude opening and closing quotes in the url span.
 | ||||
|       span: {start: urlNode.getStart() + 1, end: urlNode.end - 1}, | ||||
|     }); | ||||
|   } | ||||
|   return allErrors; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Return a recursive data structure that chains diagnostic messages. | ||||
|  * @param chain | ||||
|  | ||||
| @ -37,6 +37,7 @@ class LanguageServiceImpl implements LanguageService { | ||||
|     const analyzedModules = this.host.getAnalyzedModules();  // same role as 'synchronizeHostData'
 | ||||
|     const results: Diagnostic[] = []; | ||||
|     const templates = this.host.getTemplates(fileName); | ||||
| 
 | ||||
|     for (const template of templates) { | ||||
|       const astOrDiagnostic = this.host.getTemplateAst(template); | ||||
|       if (isAstResult(astOrDiagnostic)) { | ||||
| @ -45,10 +46,12 @@ class LanguageServiceImpl implements LanguageService { | ||||
|         results.push(astOrDiagnostic); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     const declarations = this.host.getDeclarations(fileName); | ||||
|     if (declarations && declarations.length) { | ||||
|       results.push(...getDeclarationDiagnostics(declarations, analyzedModules)); | ||||
|       results.push(...getDeclarationDiagnostics(declarations, analyzedModules, this.host)); | ||||
|     } | ||||
| 
 | ||||
|     const sourceFile = fileName.endsWith('.ts') ? this.host.getSourceFile(fileName) : undefined; | ||||
|     return uniqueBySpan(results).map(d => ngDiagnosticToTsDiagnostic(d, sourceFile)); | ||||
|   } | ||||
|  | ||||
| @ -214,3 +214,21 @@ export function getDirectiveClassLike(node: ts.Node): DirectiveClassLike|undefin | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Finds the value of a property assignment that is nested in a TypeScript node and is of a certain | ||||
|  * type T. | ||||
|  * | ||||
|  * @param startNode node to start searching for nested property assignment from | ||||
|  * @param propName property assignment name | ||||
|  * @param predicate function to verify that a node is of type T. | ||||
|  * @return node property assignment value of type T, or undefined if none is found | ||||
|  */ | ||||
| export function findPropertyValueOfType<T extends ts.Node>( | ||||
|     startNode: ts.Node, propName: string, predicate: (node: ts.Node) => node is T): T|undefined { | ||||
|   if (ts.isPropertyAssignment(startNode) && startNode.name.getText() === propName) { | ||||
|     const {initializer} = startNode; | ||||
|     if (predicate(initializer)) return initializer; | ||||
|   } | ||||
|   return startNode.forEachChild(c => findPropertyValueOfType(c, propName, predicate)); | ||||
| } | ||||
|  | ||||
| @ -475,6 +475,40 @@ describe('diagnostics', () => { | ||||
|             `Module '"../node_modules/@angular/core/core"' has no exported member 'OpaqueToken'.`); | ||||
|   }); | ||||
| 
 | ||||
|   describe('URL diagnostics', () => { | ||||
|     it('should report errors for invalid templateUrls', () => { | ||||
|       const fileName = mockHost.addCode(` | ||||
| 	@Component({ | ||||
|           templateUrl: '«notAFile»', | ||||
|         }) | ||||
|         export class MyComponent {}`);
 | ||||
| 
 | ||||
|       const marker = mockHost.getReferenceMarkerFor(fileName, 'notAFile'); | ||||
| 
 | ||||
|       const diagnostics = ngLS.getDiagnostics(fileName) !; | ||||
|       const urlDiagnostic = | ||||
|           diagnostics.find(d => d.messageText === 'URL does not point to a valid file'); | ||||
|       expect(urlDiagnostic).toBeDefined(); | ||||
| 
 | ||||
|       const {start, length} = urlDiagnostic !; | ||||
|       expect(start).toBe(marker.start); | ||||
|       expect(length).toBe(marker.length); | ||||
|     }); | ||||
| 
 | ||||
|     it('should not report errors for valid templateUrls', () => { | ||||
|       const fileName = mockHost.addCode(` | ||||
| 	@Component({ | ||||
|           templateUrl: './test.ng', | ||||
| 	}) | ||||
| 	export class MyComponent {}`);
 | ||||
| 
 | ||||
|       const diagnostics = ngLS.getDiagnostics(fileName) !; | ||||
|       const urlDiagnostic = | ||||
|           diagnostics.find(d => d.messageText === 'URL does not point to a valid file'); | ||||
|       expect(urlDiagnostic).toBeUndefined(); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   // https://github.com/angular/vscode-ng-language-service/issues/235
 | ||||
|   // There is no easy fix for this issue currently due to the way template
 | ||||
|   // tokenization is done. In the example below, the whole string
 | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user