This PR adds a way for the language server to retrieve compiler options diagnostics via `languageService.getCompilerOptionsDiagnostics()`. This will be used by the language server to show a prompt in the editor if users don't have `strict` or `fullTemplateTypeCheck` turned on. Ref https://github.com/angular/vscode-ng-language-service/issues/1053 PR Close #40423
		
			
				
	
	
		
			293 lines
		
	
	
		
			9.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			293 lines
		
	
	
		
			9.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| /**
 | |
|  * @license
 | |
|  * Copyright Google LLC All Rights Reserved.
 | |
|  *
 | |
|  * 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
 | |
|  */
 | |
| 
 | |
| import {NgCompilerOptions} from '@angular/compiler-cli/src/ngtsc/core/api';
 | |
| import {join} from 'path';
 | |
| import * as ts from 'typescript/lib/tsserverlibrary';
 | |
| 
 | |
| import {isTypeScriptFile} from '../../utils';
 | |
| 
 | |
| 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');
 | |
| export const TEST_TEMPLATE = join(PROJECT_DIR, 'app', 'test.ng');
 | |
| 
 | |
| const NOOP_FILE_WATCHER: ts.FileWatcher = {
 | |
|   close() {}
 | |
| };
 | |
| 
 | |
| 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>();
 | |
| 
 | |
|   overwriteConfigFile(configFile: string, contents: {angularCompilerOptions?: NgCompilerOptions}) {
 | |
|     if (!configFile.endsWith('.json')) {
 | |
|       throw new Error(`${configFile} is not a configuration file.`);
 | |
|     }
 | |
|     this.configOverwrites.set(configFile, JSON.stringify(contents));
 | |
|     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() {
 | |
|     for (const [fileName, watcher] of this.configFileWatchers) {
 | |
|       this.configOverwrites.delete(fileName);
 | |
|       watcher.changed();
 | |
|     }
 | |
|     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');
 | |
|     },
 | |
|   };
 | |
| }
 | |
| 
 | |
| 
 | |
| /**
 | |
|  * 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() {
 | |
|   const configFileFs = new MockConfigFileFs();
 | |
|   const projectService = new ts.server.ProjectService({
 | |
|     host: createHost(configFileFs),
 | |
|     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);
 | |
|   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();
 | |
|   return {
 | |
|     service: new MockService(project, projectService),
 | |
|     project,
 | |
|     tsLS,
 | |
|     configFileFs,
 | |
|   };
 | |
| }
 | |
| 
 | |
| interface OverwriteResult {
 | |
|   /**
 | |
|    * Position of the cursor, -1 if there isn't one.
 | |
|    */
 | |
|   position: number;
 | |
|   /**
 | |
|    * Overwritten content without the cursor.
 | |
|    */
 | |
|   text: string;
 | |
| }
 | |
| 
 | |
| export class MockService {
 | |
|   private readonly overwritten = new Set<ts.server.NormalizedPath>();
 | |
| 
 | |
|   constructor(
 | |
|       private readonly project: ts.server.Project,
 | |
|       private readonly ps: ts.server.ProjectService,
 | |
|   ) {}
 | |
| 
 | |
|   /**
 | |
|    * 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 {
 | |
|     const scriptInfo = this.getScriptInfo(fileName);
 | |
|     return this.overwriteScriptInfo(scriptInfo, newText);
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * 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 {
 | |
|     const scriptInfo = this.getScriptInfo(fileName);
 | |
|     const snapshot = scriptInfo.getSnapshot();
 | |
|     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);
 | |
|   }
 | |
| 
 | |
|   reset(): void {
 | |
|     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();
 | |
|     // updateGraph() will clear the internal dirty flag.
 | |
|     this.project.updateGraph();
 | |
|   }
 | |
| 
 | |
|   getScriptInfo(fileName: string): ts.server.ScriptInfo {
 | |
|     const scriptInfo = this.ps.getScriptInfo(fileName);
 | |
|     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)) {
 | |
|       throw new Error(`No existing script info for ${fileName}`);
 | |
|     }
 | |
|     const newScriptInfo = this.ps.getOrCreateScriptInfoForNormalizedPath(
 | |
|         ts.server.toNormalizedPath(fileName),
 | |
|         true,                   // openedByClient
 | |
|         '',                     // fileContent
 | |
|         ts.ScriptKind.Unknown,  // scriptKind
 | |
|     );
 | |
|     if (!newScriptInfo) {
 | |
|       throw new Error(`Failed to create new script info for ${fileName}`);
 | |
|     }
 | |
|     newScriptInfo.attachToProject(this.project);
 | |
|     return newScriptInfo;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * 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, /¦/, '');
 | |
|     const snapshot = scriptInfo.getSnapshot();
 | |
|     scriptInfo.editContent(0, snapshot.getLength(), result.text);
 | |
|     this.overwritten.add(scriptInfo.fileName);
 | |
|     return result;
 | |
|   }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * 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};
 | |
| }
 |