diff --git a/packages/language-service/ivy/test/env.ts b/packages/language-service/ivy/test/env.ts index 9da1e74836..73b88e6638 100644 --- a/packages/language-service/ivy/test/env.ts +++ b/packages/language-service/ivy/test/env.ts @@ -18,6 +18,8 @@ import {LanguageService} from '../language_service'; import {MockServerHost} from './mock_host'; +// TODO(alxhub): replace this environment with //packages/language-service/ivy/testing + function writeTsconfig( fs: FileSystem, entryFiles: AbsoluteFsPath[], options: TestableOptions): void { fs.writeFile( diff --git a/packages/language-service/ivy/testing/BUILD.bazel b/packages/language-service/ivy/testing/BUILD.bazel new file mode 100644 index 0000000000..d01f85dd8e --- /dev/null +++ b/packages/language-service/ivy/testing/BUILD.bazel @@ -0,0 +1,19 @@ +load("//tools:defaults.bzl", "ts_library") + +package(default_visibility = ["//packages/language-service:__subpackages__"]) + +ts_library( + name = "testing", + testonly = True, + srcs = glob(["**/*.ts"]), + deps = [ + "//packages/compiler", + "//packages/compiler-cli/src/ngtsc/core:api", + "//packages/compiler-cli/src/ngtsc/file_system", + "//packages/compiler-cli/src/ngtsc/file_system/testing", + "//packages/compiler-cli/src/ngtsc/testing", + "//packages/compiler-cli/src/ngtsc/typecheck/api", + "//packages/language-service/ivy", + "@npm//typescript", + ], +) diff --git a/packages/language-service/ivy/testing/index.ts b/packages/language-service/ivy/testing/index.ts new file mode 100644 index 0000000000..b3e62e6019 --- /dev/null +++ b/packages/language-service/ivy/testing/index.ts @@ -0,0 +1,12 @@ +/** + * @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 + */ + +export * from './src/buffer'; +export * from './src/env'; +export * from './src/project'; +export * from './src/util'; diff --git a/packages/language-service/ivy/testing/src/buffer.ts b/packages/language-service/ivy/testing/src/buffer.ts new file mode 100644 index 0000000000..2e3cc7302b --- /dev/null +++ b/packages/language-service/ivy/testing/src/buffer.ts @@ -0,0 +1,64 @@ +/** + * @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 * as ts from 'typescript/lib/tsserverlibrary'; + +import {Project} from './project'; +import {extractCursorInfo} from './util'; + +/** + * A file that is currently open in the `ts.Project`, with a cursor position. + */ +export class OpenBuffer { + private _cursor: number = 0; + + constructor( + private project: Project, private projectFileName: string, + private scriptInfo: ts.server.ScriptInfo) {} + + get cursor(): number { + return this._cursor; + } + + get contents(): string { + const snapshot = this.scriptInfo.getSnapshot(); + return snapshot.getText(0, snapshot.getLength()); + } + + set contents(newContents: string) { + const snapshot = this.scriptInfo.getSnapshot(); + this.scriptInfo.editContent(0, snapshot.getLength(), newContents); + // If the cursor goes beyond the new length of the buffer, clamp it to the end of the buffer. + if (this._cursor > newContents.length) { + this._cursor = newContents.length; + } + } + + /** + * Find a snippet of text within the given buffer and position the cursor within it. + * + * @param snippetWithCursor a snippet of text which contains the '¦' symbol, representing where + * the cursor should be placed within the snippet when located in the larger buffer. + */ + moveCursorToText(snippetWithCursor: string): void { + const {text: snippet, cursor} = extractCursorInfo(snippetWithCursor); + const snippetIndex = this.contents.indexOf(snippet); + if (snippetIndex === -1) { + throw new Error(`Snippet ${snippet} not found in ${this.projectFileName}`); + } + this._cursor = snippetIndex + cursor; + } + + /** + * Execute the `getDefinitionAndBoundSpan` operation in the Language Service at the cursor + * location in this buffer. + */ + getDefinitionAndBoundSpan(): ts.DefinitionInfoAndBoundSpan|undefined { + return this.project.ngLS.getDefinitionAndBoundSpan(this.scriptInfo.fileName, this._cursor); + } +} diff --git a/packages/language-service/ivy/testing/src/env.ts b/packages/language-service/ivy/testing/src/env.ts new file mode 100644 index 0000000000..1a69f3c302 --- /dev/null +++ b/packages/language-service/ivy/testing/src/env.ts @@ -0,0 +1,87 @@ +/** + * @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 {getFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system'; +import {MockFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; +import {loadStandardTestFiles} from '@angular/compiler-cli/src/ngtsc/testing'; +import * as ts from 'typescript/lib/tsserverlibrary'; + +import {MockServerHost} from './host'; +import {Project, ProjectFiles} from './project'; + +/** + * Testing environment for the Angular Language Service, which creates an in-memory tsserver + * instance that backs a Language Service to emulate an IDE that uses the LS. + */ +export class LanguageServiceTestEnv { + static setup(): LanguageServiceTestEnv { + const fs = getFileSystem(); + if (!(fs instanceof MockFileSystem)) { + throw new Error(`LanguageServiceTestEnvironment only works with a mock filesystem`); + } + fs.init(loadStandardTestFiles({ + fakeCore: true, + fakeCommon: true, + })); + + const host = new MockServerHost(fs); + + const projectService = new ts.server.ProjectService({ + logger, + cancellationToken: ts.server.nullCancellationToken, + host, + typingsInstaller: ts.server.nullTypingsInstaller, + + useInferredProjectPerProjectRoot: true, + useSingleInferredProject: true, + }); + + return new LanguageServiceTestEnv(host, projectService); + } + + private projects = new Map(); + + constructor(private host: MockServerHost, private projectService: ts.server.ProjectService) {} + + addProject(name: string, files: ProjectFiles): Project { + if (this.projects.has(name)) { + throw new Error(`Project ${name} is already defined`); + } + + const project = Project.initialize(name, this.projectService, files); + this.projects.set(name, project); + return project; + } + + getTextFromTsSpan(fileName: string, span: ts.TextSpan): string|null { + const scriptInfo = this.projectService.getScriptInfo(fileName); + if (scriptInfo === undefined) { + return null; + } + return scriptInfo.getSnapshot().getText(span.start, span.start + span.length); + } +} + +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; + }, +}; diff --git a/packages/language-service/ivy/testing/src/host.ts b/packages/language-service/ivy/testing/src/host.ts new file mode 100644 index 0000000000..51fdea238c --- /dev/null +++ b/packages/language-service/ivy/testing/src/host.ts @@ -0,0 +1,118 @@ +/** + * @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 {absoluteFrom} from '@angular/compiler-cli/src/ngtsc/file_system'; +import {MockFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; + +import * as ts from 'typescript/lib/tsserverlibrary'; + +const NOOP_FILE_WATCHER: ts.FileWatcher = { + close() {} +}; + +export class MockServerHost implements ts.server.ServerHost { + constructor(private fs: MockFileSystem) {} + + get newLine(): string { + return '\n'; + } + + get useCaseSensitiveFileNames(): boolean { + return this.fs.isCaseSensitive(); + } + + readFile(path: string, encoding?: string): string|undefined { + return this.fs.readFile(absoluteFrom(path)); + } + + resolvePath(path: string): string { + return this.fs.resolve(path); + } + + fileExists(path: string): boolean { + const absPath = absoluteFrom(path); + return this.fs.exists(absPath) && this.fs.lstat(absPath).isFile(); + } + + directoryExists(path: string): boolean { + const absPath = absoluteFrom(path); + return this.fs.exists(absPath) && this.fs.lstat(absPath).isDirectory(); + } + + createDirectory(path: string): void { + this.fs.ensureDir(absoluteFrom(path)); + } + + getExecutingFilePath(): string { + // This is load-bearing, as TypeScript uses the result of this call to locate the directory in + // which it expects to find .d.ts files for the "standard libraries" - DOM, ES2015, etc. + return '/node_modules/typescript/lib/tsserver.js'; + } + + getCurrentDirectory(): string { + return '/'; + } + + createHash(data: string): string { + return ts.sys.createHash!(data); + } + + get args(): string[] { + throw new Error('Property not implemented.'); + } + + watchFile( + path: string, callback: ts.FileWatcherCallback, pollingInterval?: number, + options?: ts.WatchOptions): ts.FileWatcher { + return NOOP_FILE_WATCHER; + } + + watchDirectory( + path: string, callback: ts.DirectoryWatcherCallback, recursive?: boolean, + options?: ts.WatchOptions): ts.FileWatcher { + return NOOP_FILE_WATCHER; + } + + setTimeout(callback: (...args: any[]) => void, ms: number, ...args: any[]) { + throw new Error('Method not implemented.'); + } + + clearTimeout(timeoutId: any): void { + throw new Error('Method not implemented.'); + } + + setImmediate(callback: (...args: any[]) => void, ...args: any[]) { + throw new Error('Method not implemented.'); + } + + clearImmediate(timeoutId: any): void { + throw new Error('Method not implemented.'); + } + + write(s: string): void { + throw new Error('Method not implemented.'); + } + + writeFile(path: string, data: string, writeByteOrderMark?: boolean): void { + throw new Error('Method not implemented.'); + } + + getDirectories(path: string): string[] { + throw new Error('Method not implemented.'); + } + + readDirectory( + path: string, extensions?: readonly string[], exclude?: readonly string[], + include?: readonly string[], depth?: number): string[] { + throw new Error('Method not implemented.'); + } + + exit(exitCode?: number): void { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/language-service/ivy/testing/src/project.ts b/packages/language-service/ivy/testing/src/project.ts new file mode 100644 index 0000000000..0a40ed6a47 --- /dev/null +++ b/packages/language-service/ivy/testing/src/project.ts @@ -0,0 +1,122 @@ +/** + * @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 {StrictTemplateOptions} from '@angular/compiler-cli/src/ngtsc/core/api'; +import {absoluteFrom, AbsoluteFsPath, FileSystem, getFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system'; +import * as ts from 'typescript/lib/tsserverlibrary'; +import {LanguageService} from '../../language_service'; +import {OpenBuffer} from './buffer'; + +export type ProjectFiles = { + [fileName: string]: string; +}; + +function writeTsconfig( + fs: FileSystem, tsConfigPath: AbsoluteFsPath, entryFiles: AbsoluteFsPath[], + options: StrictTemplateOptions): void { + fs.writeFile( + tsConfigPath, + JSON.stringify( + { + compilerOptions: { + strict: true, + experimentalDecorators: true, + moduleResolution: 'node', + target: 'es2015', + rootDir: '.', + lib: [ + 'dom', + 'es2015', + ], + }, + files: entryFiles, + angularCompilerOptions: { + strictTemplates: true, + ...options, + } + }, + null, 2)); +} + + +export class Project { + private tsProject: ts.server.Project; + private tsLS: ts.LanguageService; + readonly ngLS: LanguageService; + private buffers = new Map(); + + static initialize(name: string, projectService: ts.server.ProjectService, files: ProjectFiles): + Project { + const fs = getFileSystem(); + const tsConfigPath = absoluteFrom(`/${name}/tsconfig.json`); + + const entryFiles: AbsoluteFsPath[] = []; + for (const projectFilePath of Object.keys(files)) { + const contents = files[projectFilePath]; + const filePath = absoluteFrom(`/${name}/${projectFilePath}`); + const dirPath = fs.dirname(filePath); + fs.ensureDir(dirPath); + fs.writeFile(filePath, contents); + if (projectFilePath.endsWith('.ts')) { + entryFiles.push(filePath); + } + } + + writeTsconfig(fs, tsConfigPath, entryFiles, {}); + + // Ensure the project is live in the ProjectService. + projectService.openClientFile(entryFiles[0]); + projectService.closeClientFile(entryFiles[0]); + + return new Project(name, projectService, tsConfigPath); + } + + constructor( + readonly name: string, private projectService: ts.server.ProjectService, + private tsConfigPath: AbsoluteFsPath) { + // LS for project + const tsProject = projectService.findProject(tsConfigPath); + if (tsProject === undefined) { + throw new Error(`Failed to create project for ${tsConfigPath}`); + } + + this.tsProject = tsProject; + + // The following operation forces a ts.Program to be created. + this.tsLS = tsProject.getLanguageService(); + this.ngLS = new LanguageService(tsProject, this.tsLS); + } + + openFile(projectFileName: string): OpenBuffer { + if (!this.buffers.has(projectFileName)) { + const fileName = absoluteFrom(`/${this.name}/${projectFileName}`); + this.projectService.openClientFile(fileName); + + const scriptInfo = this.tsProject.getScriptInfo(fileName); + if (scriptInfo === undefined) { + throw new Error( + `Unable to open ScriptInfo for ${projectFileName} in project ${this.tsConfigPath}`); + } + this.buffers.set(projectFileName, new OpenBuffer(this, projectFileName, scriptInfo)); + } + + return this.buffers.get(projectFileName)!; + } + + getDiagnosticsForFile(projectFileName: string): ts.Diagnostic[] { + const fileName = absoluteFrom(`/${this.name}/${projectFileName}`); + const diagnostics: ts.Diagnostic[] = []; + if (fileName.endsWith('.ts')) { + diagnostics.push(...this.tsLS.getSyntacticDiagnostics(fileName)); + diagnostics.push(...this.tsLS.getSemanticDiagnostics(fileName)); + } + + diagnostics.push(...this.ngLS.getSemanticDiagnostics(fileName)); + return diagnostics; + } +} diff --git a/packages/language-service/ivy/testing/src/util.ts b/packages/language-service/ivy/testing/src/util.ts new file mode 100644 index 0000000000..e8990cfe02 --- /dev/null +++ b/packages/language-service/ivy/testing/src/util.ts @@ -0,0 +1,51 @@ +/** + * @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 + */ + +/** + * Given a text snippet which contains exactly one cursor symbol ('¦'), extract both the offset of + * that cursor within the text as well as the text snippet without the cursor. + */ +export function extractCursorInfo(textWithCursor: string): {cursor: number, text: string} { + const cursor = textWithCursor.indexOf('¦'); + if (cursor === -1 || textWithCursor.indexOf('¦', cursor + 1) !== -1) { + throw new Error(`Expected to find exactly one cursor symbol '¦'`); + } + + return { + cursor, + text: textWithCursor.substr(0, cursor) + textWithCursor.substr(cursor + 1), + }; +} + +function last(array: T[]): T { + return array[array.length - 1]; +} + +/** + * Expect that a list of objects with a `fileName` property matches a set of expected files by only + * comparing the file names and not any path prefixes. + * + * This assertion is independent of the order of either list. + */ +export function assertFileNames(refs: Array<{fileName: string}>, expectedFileNames: string[]) { + const actualPaths = refs.map(r => r.fileName); + const actualFileNames = actualPaths.map(p => last(p.split('/'))); + expect(new Set(actualFileNames)).toEqual(new Set(expectedFileNames)); +} + +/** + * Returns whether the given `ts.Diagnostic` is of a type only produced by the Angular compiler (as + * opposed to being an upstream TypeScript diagnostic). + * + * Template type-checking diagnostics are not "ng-specific" in this sense, since they are plain + * TypeScript diagnostics that are produced from expressions in the template by way of a TCB. + */ +export function isNgSpecificDiagnostic(diag: ts.Diagnostic): boolean { + // Angular-specific diagnostics use a negative code space. + return diag.code < 0; +}