| 
									
										
										
										
											2020-04-30 15:48:20 -07:00
										 |  |  | /** | 
					
						
							|  |  |  |  * @license | 
					
						
							| 
									
										
										
										
											2020-05-19 12:08:49 -07:00
										 |  |  |  * Copyright Google LLC All Rights Reserved. | 
					
						
							| 
									
										
										
										
											2020-04-30 15:48:20 -07:00
										 |  |  |  * | 
					
						
							|  |  |  |  * Use of this source code is governed by an MIT-style license that can be | 
					
						
							|  |  |  |  * found in the LICENSE file at https://angular.io/license
 | 
					
						
							|  |  |  |  */ | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-01-13 14:52:29 -08:00
										 |  |  | import {NgCompilerOptions} from '@angular/compiler-cli/src/ngtsc/core/api'; | 
					
						
							| 
									
										
										
										
											2020-04-30 15:48:20 -07:00
										 |  |  | import {join} from 'path'; | 
					
						
							|  |  |  | import * as ts from 'typescript/lib/tsserverlibrary'; | 
					
						
							| 
									
										
										
										
											2020-10-09 11:22:03 -07:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-11-04 12:28:37 -08:00
										 |  |  | import {isTypeScriptFile} from '../../utils'; | 
					
						
							| 
									
										
										
										
											2020-04-30 15:48:20 -07:00
										 |  |  | 
 | 
					
						
							|  |  |  | const logger: ts.server.Logger = { | 
					
						
							|  |  |  |   close(): void{}, | 
					
						
							|  |  |  |   hasLevel(level: ts.server.LogLevel): boolean { | 
					
						
							|  |  |  |     return false; | 
					
						
							|  |  |  |   }, | 
					
						
							|  |  |  |   loggingEnabled(): boolean { | 
					
						
							|  |  |  |     return false; | 
					
						
							|  |  |  |   }, | 
					
						
							|  |  |  |   perftrc(s: string): void{}, | 
					
						
							|  |  |  |   info(s: string): void{}, | 
					
						
							|  |  |  |   startGroup(): void{}, | 
					
						
							|  |  |  |   endGroup(): void{}, | 
					
						
							|  |  |  |   msg(s: string, type?: ts.server.Msg): void{}, | 
					
						
							|  |  |  |   getLogFileName(): string | | 
					
						
							|  |  |  |       undefined { | 
					
						
							|  |  |  |         return; | 
					
						
							|  |  |  |       }, | 
					
						
							|  |  |  | }; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | export const TEST_SRCDIR = process.env.TEST_SRCDIR!; | 
					
						
							|  |  |  | export const PROJECT_DIR = | 
					
						
							|  |  |  |     join(TEST_SRCDIR, 'angular', 'packages', 'language-service', 'test', 'project'); | 
					
						
							|  |  |  | export const TSCONFIG = join(PROJECT_DIR, 'tsconfig.json'); | 
					
						
							|  |  |  | export const APP_COMPONENT = join(PROJECT_DIR, 'app', 'app.component.ts'); | 
					
						
							|  |  |  | export const APP_MAIN = join(PROJECT_DIR, 'app', 'main.ts'); | 
					
						
							|  |  |  | export const PARSING_CASES = join(PROJECT_DIR, 'app', 'parsing-cases.ts'); | 
					
						
							| 
									
										
										
										
											2020-04-30 15:48:20 -07:00
										 |  |  | export const TEST_TEMPLATE = join(PROJECT_DIR, 'app', 'test.ng'); | 
					
						
							| 
									
										
										
										
											2020-04-30 15:48:20 -07:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-04-29 15:52:17 -07:00
										 |  |  | const NOOP_FILE_WATCHER: ts.FileWatcher = { | 
					
						
							|  |  |  |   close() {} | 
					
						
							|  |  |  | }; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-11-09 18:20:02 -06:00
										 |  |  | class MockWatcher implements ts.FileWatcher { | 
					
						
							|  |  |  |   constructor( | 
					
						
							|  |  |  |       private readonly fileName: string, | 
					
						
							|  |  |  |       private readonly cb: ts.FileWatcherCallback, | 
					
						
							|  |  |  |       readonly close: () => void, | 
					
						
							|  |  |  |   ) {} | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   changed() { | 
					
						
							|  |  |  |     this.cb(this.fileName, ts.FileWatcherEventKind.Changed); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   deleted() { | 
					
						
							|  |  |  |     this.cb(this.fileName, ts.FileWatcherEventKind.Deleted); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | /** | 
					
						
							|  |  |  |  * A mock file system impacting configuration files. | 
					
						
							|  |  |  |  * Queries for all other files are deferred to the underlying filesystem. | 
					
						
							|  |  |  |  */ | 
					
						
							|  |  |  | export class MockConfigFileFs implements | 
					
						
							|  |  |  |     Pick<ts.server.ServerHost, 'readFile'|'fileExists'|'watchFile'> { | 
					
						
							|  |  |  |   private configOverwrites = new Map<string, string>(); | 
					
						
							|  |  |  |   private configFileWatchers = new Map<string, MockWatcher>(); | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-01-13 14:52:29 -08:00
										 |  |  |   overwriteConfigFile(configFile: string, contents: {angularCompilerOptions?: NgCompilerOptions}) { | 
					
						
							| 
									
										
										
										
											2020-11-09 18:20:02 -06:00
										 |  |  |     if (!configFile.endsWith('.json')) { | 
					
						
							|  |  |  |       throw new Error(`${configFile} is not a configuration file.`); | 
					
						
							|  |  |  |     } | 
					
						
							| 
									
										
										
										
											2021-01-13 14:52:29 -08:00
										 |  |  |     this.configOverwrites.set(configFile, JSON.stringify(contents)); | 
					
						
							| 
									
										
										
										
											2020-11-09 18:20:02 -06:00
										 |  |  |     this.configFileWatchers.get(configFile)?.changed(); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   readFile(file: string, encoding?: string): string|undefined { | 
					
						
							|  |  |  |     const read = this.configOverwrites.get(file) ?? ts.sys.readFile(file, encoding); | 
					
						
							|  |  |  |     return read; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   fileExists(file: string): boolean { | 
					
						
							|  |  |  |     return this.configOverwrites.has(file) || ts.sys.fileExists(file); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   watchFile(path: string, callback: ts.FileWatcherCallback) { | 
					
						
							|  |  |  |     if (!path.endsWith('.json')) { | 
					
						
							|  |  |  |       // We only care about watching config files.
 | 
					
						
							|  |  |  |       return NOOP_FILE_WATCHER; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |     const watcher = new MockWatcher(path, callback, () => { | 
					
						
							|  |  |  |       this.configFileWatchers.delete(path); | 
					
						
							|  |  |  |     }); | 
					
						
							|  |  |  |     this.configFileWatchers.set(path, watcher); | 
					
						
							|  |  |  |     return watcher; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   clear() { | 
					
						
							| 
									
										
										
										
											2021-01-13 14:52:29 -08:00
										 |  |  |     for (const [fileName, watcher] of this.configFileWatchers) { | 
					
						
							|  |  |  |       this.configOverwrites.delete(fileName); | 
					
						
							|  |  |  |       watcher.changed(); | 
					
						
							|  |  |  |     } | 
					
						
							| 
									
										
										
										
											2020-11-09 18:20:02 -06:00
										 |  |  |     this.configOverwrites.clear(); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | function createHost(configFileFs: MockConfigFileFs): ts.server.ServerHost { | 
					
						
							|  |  |  |   return { | 
					
						
							|  |  |  |     ...ts.sys, | 
					
						
							|  |  |  |     fileExists(absPath: string): boolean { | 
					
						
							|  |  |  |       return configFileFs.fileExists(absPath); | 
					
						
							|  |  |  |     }, | 
					
						
							|  |  |  |     readFile(absPath: string, encoding?: string): string | | 
					
						
							|  |  |  |         undefined { | 
					
						
							|  |  |  |           return configFileFs.readFile(absPath, encoding); | 
					
						
							|  |  |  |         }, | 
					
						
							|  |  |  |     watchFile(path: string, callback: ts.FileWatcherCallback): ts.FileWatcher { | 
					
						
							|  |  |  |       return configFileFs.watchFile(path, callback); | 
					
						
							|  |  |  |     }, | 
					
						
							|  |  |  |     watchDirectory(path: string, callback: ts.DirectoryWatcherCallback): ts.FileWatcher { | 
					
						
							|  |  |  |       return NOOP_FILE_WATCHER; | 
					
						
							|  |  |  |     }, | 
					
						
							|  |  |  |     setTimeout() { | 
					
						
							|  |  |  |       throw new Error('setTimeout is not implemented'); | 
					
						
							|  |  |  |     }, | 
					
						
							|  |  |  |     clearTimeout() { | 
					
						
							|  |  |  |       throw new Error('clearTimeout is not implemented'); | 
					
						
							|  |  |  |     }, | 
					
						
							|  |  |  |     setImmediate() { | 
					
						
							|  |  |  |       throw new Error('setImmediate is not implemented'); | 
					
						
							|  |  |  |     }, | 
					
						
							|  |  |  |     clearImmediate() { | 
					
						
							|  |  |  |       throw new Error('clearImmediate is not implemented'); | 
					
						
							|  |  |  |     }, | 
					
						
							|  |  |  |   }; | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-04-30 15:48:20 -07:00
										 |  |  | 
 | 
					
						
							|  |  |  | /** | 
					
						
							|  |  |  |  * Create a ConfiguredProject and an actual program for the test project located | 
					
						
							|  |  |  |  * in packages/language-service/test/project. Project creation exercises the | 
					
						
							|  |  |  |  * actual code path, but a mock host is used for the filesystem to intercept | 
					
						
							|  |  |  |  * and modify test files. | 
					
						
							|  |  |  |  */ | 
					
						
							|  |  |  | export function setup() { | 
					
						
							| 
									
										
										
										
											2020-11-09 18:20:02 -06:00
										 |  |  |   const configFileFs = new MockConfigFileFs(); | 
					
						
							| 
									
										
										
										
											2020-10-17 12:35:16 -07:00
										 |  |  |   const projectService = new ts.server.ProjectService({ | 
					
						
							| 
									
										
										
										
											2020-11-09 18:20:02 -06:00
										 |  |  |     host: createHost(configFileFs), | 
					
						
							| 
									
										
										
										
											2020-10-17 12:35:16 -07:00
										 |  |  |     logger, | 
					
						
							|  |  |  |     cancellationToken: ts.server.nullCancellationToken, | 
					
						
							|  |  |  |     useSingleInferredProject: true, | 
					
						
							|  |  |  |     useInferredProjectPerProjectRoot: true, | 
					
						
							|  |  |  |     typingsInstaller: ts.server.nullTypingsInstaller, | 
					
						
							|  |  |  |   }); | 
					
						
							|  |  |  |   // Opening APP_COMPONENT forces a new ConfiguredProject to be created based
 | 
					
						
							|  |  |  |   // on the tsconfig.json in the test project.
 | 
					
						
							|  |  |  |   projectService.openClientFile(APP_COMPONENT); | 
					
						
							| 
									
										
										
										
											2020-04-30 15:48:20 -07:00
										 |  |  |   const project = projectService.findProject(TSCONFIG); | 
					
						
							|  |  |  |   if (!project) { | 
					
						
							|  |  |  |     throw new Error(`Failed to create project for ${TSCONFIG}`); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |   // The following operation forces a ts.Program to be created.
 | 
					
						
							|  |  |  |   const tsLS = project.getLanguageService(); | 
					
						
							| 
									
										
										
										
											2020-04-29 15:52:17 -07:00
										 |  |  |   return { | 
					
						
							|  |  |  |     service: new MockService(project, projectService), | 
					
						
							|  |  |  |     project, | 
					
						
							|  |  |  |     tsLS, | 
					
						
							| 
									
										
										
										
											2020-11-09 18:20:02 -06:00
										 |  |  |     configFileFs, | 
					
						
							| 
									
										
										
										
											2020-04-29 15:52:17 -07:00
										 |  |  |   }; | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-08-21 10:35:49 -07:00
										 |  |  | interface OverwriteResult { | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Position of the cursor, -1 if there isn't one. | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   position: number; | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Overwritten content without the cursor. | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   text: string; | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-10-02 13:54:18 -07:00
										 |  |  | export class MockService { | 
					
						
							| 
									
										
										
										
											2020-04-29 15:52:17 -07:00
										 |  |  |   private readonly overwritten = new Set<ts.server.NormalizedPath>(); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   constructor( | 
					
						
							|  |  |  |       private readonly project: ts.server.Project, | 
					
						
							|  |  |  |       private readonly ps: ts.server.ProjectService, | 
					
						
							|  |  |  |   ) {} | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-08-21 10:35:49 -07:00
										 |  |  |   /** | 
					
						
							|  |  |  |    * Overwrite the entire content of `fileName` with `newText`. If cursor is | 
					
						
							|  |  |  |    * present in `newText`, it will be removed and the position of the cursor | 
					
						
							|  |  |  |    * will be returned. | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   overwrite(fileName: string, newText: string): OverwriteResult { | 
					
						
							| 
									
										
										
										
											2020-04-29 15:52:17 -07:00
										 |  |  |     const scriptInfo = this.getScriptInfo(fileName); | 
					
						
							| 
									
										
										
										
											2020-08-21 10:35:49 -07:00
										 |  |  |     return this.overwriteScriptInfo(scriptInfo, newText); | 
					
						
							| 
									
										
										
										
											2020-04-29 15:52:17 -07:00
										 |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-08-21 10:35:49 -07:00
										 |  |  |   /** | 
					
						
							|  |  |  |    * Overwrite an inline template defined in `fileName` and return the entire | 
					
						
							|  |  |  |    * content of the source file (not just the template). If a cursor is present | 
					
						
							|  |  |  |    * in `newTemplate`, it will be removed and the position of the cursor in the | 
					
						
							|  |  |  |    * source file will be returned. | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   overwriteInlineTemplate(fileName: string, newTemplate: string): OverwriteResult { | 
					
						
							| 
									
										
										
										
											2020-04-30 15:48:20 -07:00
										 |  |  |     const scriptInfo = this.getScriptInfo(fileName); | 
					
						
							|  |  |  |     const snapshot = scriptInfo.getSnapshot(); | 
					
						
							| 
									
										
										
										
											2020-08-21 10:35:49 -07:00
										 |  |  |     const originalText = snapshot.getText(0, snapshot.getLength()); | 
					
						
							|  |  |  |     const {position, text} = | 
					
						
							|  |  |  |         replaceOnce(originalText, /template: `([\s\S]+?)`/, `template: \`${newTemplate}\``); | 
					
						
							|  |  |  |     if (position === -1) { | 
					
						
							|  |  |  |       throw new Error(`${fileName} does not contain a component with template`); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |     return this.overwriteScriptInfo(scriptInfo, text); | 
					
						
							| 
									
										
										
										
											2020-04-30 15:48:20 -07:00
										 |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-11-04 12:28:37 -08:00
										 |  |  |   reset(): void { | 
					
						
							| 
									
										
										
										
											2020-04-29 15:52:17 -07:00
										 |  |  |     if (this.overwritten.size === 0) { | 
					
						
							|  |  |  |       return; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |     for (const fileName of this.overwritten) { | 
					
						
							|  |  |  |       const scriptInfo = this.getScriptInfo(fileName); | 
					
						
							|  |  |  |       const reloaded = scriptInfo.reloadFromFile(); | 
					
						
							|  |  |  |       if (!reloaded) { | 
					
						
							|  |  |  |         throw new Error(`Failed to reload ${scriptInfo.fileName}`); | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |     this.overwritten.clear(); | 
					
						
							| 
									
										
										
										
											2020-10-09 16:46:55 -07:00
										 |  |  |     // updateGraph() will clear the internal dirty flag.
 | 
					
						
							|  |  |  |     this.project.updateGraph(); | 
					
						
							| 
									
										
										
										
											2020-04-29 15:52:17 -07:00
										 |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   getScriptInfo(fileName: string): ts.server.ScriptInfo { | 
					
						
							|  |  |  |     const scriptInfo = this.ps.getScriptInfo(fileName); | 
					
						
							| 
									
										
										
										
											2020-09-30 10:59:38 -07:00
										 |  |  |     if (scriptInfo) { | 
					
						
							|  |  |  |       return scriptInfo; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |     // There is no script info for external template, so create one.
 | 
					
						
							|  |  |  |     // But we need to make sure it's not a TS file.
 | 
					
						
							|  |  |  |     if (isTypeScriptFile(fileName)) { | 
					
						
							| 
									
										
										
										
											2020-04-29 15:52:17 -07:00
										 |  |  |       throw new Error(`No existing script info for ${fileName}`); | 
					
						
							|  |  |  |     } | 
					
						
							| 
									
										
										
										
											2020-09-30 10:59:38 -07:00
										 |  |  |     const newScriptInfo = this.ps.getOrCreateScriptInfoForNormalizedPath( | 
					
						
							|  |  |  |         ts.server.toNormalizedPath(fileName), | 
					
						
							| 
									
										
										
										
											2020-12-16 11:15:14 -08:00
										 |  |  |         true,                   // openedByClient
 | 
					
						
							|  |  |  |         '',                     // fileContent
 | 
					
						
							|  |  |  |         ts.ScriptKind.Unknown,  // scriptKind
 | 
					
						
							| 
									
										
										
										
											2020-09-30 10:59:38 -07:00
										 |  |  |     ); | 
					
						
							|  |  |  |     if (!newScriptInfo) { | 
					
						
							|  |  |  |       throw new Error(`Failed to create new script info for ${fileName}`); | 
					
						
							|  |  |  |     } | 
					
						
							| 
									
										
										
										
											2020-10-09 16:46:55 -07:00
										 |  |  |     newScriptInfo.attachToProject(this.project); | 
					
						
							| 
									
										
										
										
											2020-09-30 10:59:38 -07:00
										 |  |  |     return newScriptInfo; | 
					
						
							| 
									
										
										
										
											2020-04-29 15:52:17 -07:00
										 |  |  |   } | 
					
						
							| 
									
										
										
										
											2020-04-30 15:48:20 -07:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-08-21 10:35:49 -07:00
										 |  |  |   /** | 
					
						
							|  |  |  |    * Remove the cursor from `newText`, then replace `scriptInfo` with the new | 
					
						
							|  |  |  |    * content and return the position of the cursor. | 
					
						
							|  |  |  |    * @param scriptInfo | 
					
						
							|  |  |  |    * @param newText Text that possibly contains a cursor | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   private overwriteScriptInfo(scriptInfo: ts.server.ScriptInfo, newText: string): OverwriteResult { | 
					
						
							|  |  |  |     const result = replaceOnce(newText, /¦/, ''); | 
					
						
							| 
									
										
										
										
											2020-04-30 15:48:20 -07:00
										 |  |  |     const snapshot = scriptInfo.getSnapshot(); | 
					
						
							| 
									
										
										
										
											2020-08-21 10:35:49 -07:00
										 |  |  |     scriptInfo.editContent(0, snapshot.getLength(), result.text); | 
					
						
							| 
									
										
										
										
											2020-04-30 15:48:20 -07:00
										 |  |  |     this.overwritten.add(scriptInfo.fileName); | 
					
						
							| 
									
										
										
										
											2020-08-21 10:35:49 -07:00
										 |  |  |     return result; | 
					
						
							| 
									
										
										
										
											2020-04-30 15:48:20 -07:00
										 |  |  |   } | 
					
						
							| 
									
										
										
										
											2020-04-29 15:52:17 -07:00
										 |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-08-21 10:35:49 -07:00
										 |  |  | /** | 
					
						
							|  |  |  |  * Replace at most one occurence that matches `regex` in the specified | 
					
						
							|  |  |  |  * `searchText` with the specified `replaceText`. Throw an error if there is | 
					
						
							|  |  |  |  * more than one occurrence. | 
					
						
							|  |  |  |  */ | 
					
						
							|  |  |  | function replaceOnce(searchText: string, regex: RegExp, replaceText: string): OverwriteResult { | 
					
						
							|  |  |  |   regex = new RegExp(regex.source, regex.flags + 'g' /* global */); | 
					
						
							|  |  |  |   let position = -1; | 
					
						
							|  |  |  |   const text = searchText.replace(regex, (...args) => { | 
					
						
							|  |  |  |     if (position !== -1) { | 
					
						
							|  |  |  |       throw new Error(`${regex} matches more than one occurrence in text: ${searchText}`); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |     position = args[args.length - 2];  // second last argument is always the index
 | 
					
						
							|  |  |  |     return replaceText; | 
					
						
							|  |  |  |   }); | 
					
						
							|  |  |  |   return {position, text}; | 
					
						
							| 
									
										
										
										
											2020-04-30 15:48:20 -07:00
										 |  |  | } |