diff --git a/packages/language-service/ivy/BUILD.bazel b/packages/language-service/ivy/BUILD.bazel index 1849b649f7..f71c4b7141 100644 --- a/packages/language-service/ivy/BUILD.bazel +++ b/packages/language-service/ivy/BUILD.bazel @@ -1,12 +1,13 @@ load("//tools:defaults.bzl", "ts_library") -package(default_visibility = ["//visibility:public"]) +package(default_visibility = ["//packages/language-service:__subpackages__"]) ts_library( name = "ivy", srcs = glob(["*.ts"]), deps = [ "//packages/compiler-cli", + "//packages/language-service/ivy/compiler", "@npm//typescript", ], ) diff --git a/packages/language-service/ivy/compiler/BUILD.bazel b/packages/language-service/ivy/compiler/BUILD.bazel new file mode 100644 index 0000000000..6cf1cdde6b --- /dev/null +++ b/packages/language-service/ivy/compiler/BUILD.bazel @@ -0,0 +1,15 @@ +load("//tools:defaults.bzl", "ts_library") + +package(default_visibility = ["//packages/language-service/ivy:__pkg__"]) + +ts_library( + name = "compiler", + srcs = glob(["*.ts"]), + deps = [ + "//packages/compiler-cli", + "//packages/compiler-cli/src/ngtsc/core", + "//packages/compiler-cli/src/ngtsc/file_system", + "//packages/compiler-cli/src/ngtsc/typecheck", + "@npm//typescript", + ], +) diff --git a/packages/language-service/ivy/compiler/README.md b/packages/language-service/ivy/compiler/README.md new file mode 100644 index 0000000000..2945bc7dac --- /dev/null +++ b/packages/language-service/ivy/compiler/README.md @@ -0,0 +1,2 @@ +All files in this directory are temporary. This is created to simulate the final +form of the Ivy compiler that supports language service. diff --git a/packages/language-service/ivy/compiler/compiler.ts b/packages/language-service/ivy/compiler/compiler.ts new file mode 100644 index 0000000000..200bb82384 --- /dev/null +++ b/packages/language-service/ivy/compiler/compiler.ts @@ -0,0 +1,120 @@ + +/** + * @license + * Copyright Google Inc. 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 {CompilerOptions} from '@angular/compiler-cli'; +import {NgCompiler, NgCompilerHost} from '@angular/compiler-cli/src/ngtsc/core'; +import {AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; +import {TypeCheckingProgramStrategy, UpdateMode} from '@angular/compiler-cli/src/ngtsc/typecheck'; +import * as ts from 'typescript/lib/tsserverlibrary'; + +import {makeCompilerHostFromProject} from './compiler_host'; + +export class Compiler { + private tsCompilerHost: ts.CompilerHost; + private lastKnownProgram: ts.Program; + private compiler: NgCompiler; + private readonly strategy: TypeCheckingProgramStrategy; + + constructor(private readonly project: ts.server.Project, private options: CompilerOptions) { + this.tsCompilerHost = makeCompilerHostFromProject(project); + const ngCompilerHost = NgCompilerHost.wrap( + this.tsCompilerHost, + project.getRootFiles(), // input files + options, + null, // old program + ); + this.strategy = createTypeCheckingProgramStrategy(project); + this.lastKnownProgram = this.strategy.getProgram(); + this.compiler = new NgCompiler(ngCompilerHost, options, this.lastKnownProgram, this.strategy); + } + + setCompilerOptions(options: CompilerOptions) { + this.options = options; + } + + analyze(): ts.Program|undefined { + const inputFiles = this.project.getRootFiles(); + const ngCompilerHost = + NgCompilerHost.wrap(this.tsCompilerHost, inputFiles, this.options, this.lastKnownProgram); + const program = this.strategy.getProgram(); + this.compiler = + new NgCompiler(ngCompilerHost, this.options, program, this.strategy, this.lastKnownProgram); + try { + // This is the only way to force the compiler to update the typecheck file + // in the program. We have to do try-catch because the compiler immediately + // throws if it fails to parse any template in the entire program! + const d = this.compiler.getDiagnostics(); + if (d.length) { + // There could be global compilation errors. It's useful to print them + // out in development. + console.error(d.map(d => ts.flattenDiagnosticMessageText(d.messageText, '\n'))); + } + } catch (e) { + console.error('Failed to analyze program', e.message); + return; + } + this.lastKnownProgram = this.compiler.getNextProgram(); + return this.lastKnownProgram; + } + + getDiagnostics(sourceFile: ts.SourceFile): ts.Diagnostic[] { + return this.compiler.getDiagnostics(sourceFile); + } +} + +function createTypeCheckingProgramStrategy(project: ts.server.Project): + TypeCheckingProgramStrategy { + return { + getProgram(): ts.Program { + const program = project.getLanguageService().getProgram(); + if (!program) { + throw new Error('Language service does not have a program!'); + } + return program; + }, + updateFiles(contents: Map, updateMode: UpdateMode) { + if (updateMode !== UpdateMode.Complete) { + throw new Error(`Incremental update mode is currently not supported`); + } + for (const [fileName, newText] of contents) { + const scriptInfo = getOrCreateTypeCheckScriptInfo(project, fileName); + const snapshot = scriptInfo.getSnapshot(); + const length = snapshot.getLength(); + scriptInfo.editContent(0, length, newText); + } + }, + }; +} + +function getOrCreateTypeCheckScriptInfo( + project: ts.server.Project, tcf: string): ts.server.ScriptInfo { + // First check if there is already a ScriptInfo for the tcf + const {projectService} = project; + let scriptInfo = projectService.getScriptInfo(tcf); + if (!scriptInfo) { + // ScriptInfo needs to be opened by client to be able to set its user-defined + // content. We must also provide file content, otherwise the service will + // attempt to fetch the content from disk and fail. + scriptInfo = projectService.getOrCreateScriptInfoForNormalizedPath( + ts.server.toNormalizedPath(tcf), + true, // openedByClient + '', // fileContent + ts.ScriptKind.TS, // scriptKind + ); + if (!scriptInfo) { + throw new Error(`Failed to create script info for ${tcf}`); + } + } + // Add ScriptInfo to project if it's missing. A ScriptInfo needs to be part of + // the project so that it becomes part of the program. + if (!project.containsScriptInfo(scriptInfo)) { + project.addRoot(scriptInfo); + } + return scriptInfo; +} diff --git a/packages/language-service/ivy/compiler/compiler_host.ts b/packages/language-service/ivy/compiler/compiler_host.ts new file mode 100644 index 0000000000..0dc0cd6db4 --- /dev/null +++ b/packages/language-service/ivy/compiler/compiler_host.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright Google Inc. 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'; + +export function makeCompilerHostFromProject(project: ts.server.Project): ts.CompilerHost { + const compilerHost: ts.CompilerHost = { + fileExists(fileName: string): boolean { + return project.fileExists(fileName); + }, + readFile(fileName: string): string | + undefined { + return project.readFile(fileName); + }, + directoryExists(directoryName: string): boolean { + return project.directoryExists(directoryName); + }, + getCurrentDirectory(): string { + return project.getCurrentDirectory(); + }, + getDirectories(path: string): string[] { + return project.getDirectories(path); + }, + getSourceFile( + fileName: string, languageVersion: ts.ScriptTarget, onError?: (message: string) => void, + shouldCreateNewSourceFile?: boolean): ts.SourceFile | + undefined { + const path = project.projectService.toPath(fileName); + return project.getSourceFile(path); + }, + getSourceFileByPath( + fileName: string, path: ts.Path, languageVersion: ts.ScriptTarget, + onError?: (message: string) => void, shouldCreateNewSourceFile?: boolean): ts.SourceFile | + undefined { + return project.getSourceFile(path); + }, + getCancellationToken(): ts.CancellationToken { + return { + isCancellationRequested() { + return project.getCancellationToken().isCancellationRequested(); + }, + throwIfCancellationRequested() { + if (this.isCancellationRequested()) { + throw new ts.OperationCanceledException(); + } + }, + }; + }, + getDefaultLibFileName(options: ts.CompilerOptions): string { + return project.getDefaultLibFileName(); + }, + writeFile( + fileName: string, data: string, writeByteOrderMark: boolean, + onError?: (message: string) => void, sourceFiles?: readonly ts.SourceFile[]) { + return project.writeFile(fileName, data); + }, + getCanonicalFileName(fileName: string): string { + return project.projectService.toCanonicalFileName(fileName); + }, + useCaseSensitiveFileNames(): boolean { + return project.useCaseSensitiveFileNames(); + }, + getNewLine(): string { + return project.getNewLine(); + }, + readDirectory( + rootDir: string, extensions: readonly string[], excludes: readonly string[]|undefined, + includes: readonly string[], depth?: number): string[] { + return project.readDirectory(rootDir, extensions, excludes, includes, depth); + }, + resolveModuleNames( + moduleNames: string[], containingFile: string, reusedNames: string[]|undefined, + redirectedReference: ts.ResolvedProjectReference|undefined, options: ts.CompilerOptions): + (ts.ResolvedModule | undefined)[] { + return project.resolveModuleNames( + moduleNames, containingFile, reusedNames, redirectedReference); + }, + resolveTypeReferenceDirectives( + typeReferenceDirectiveNames: string[], containingFile: string, + redirectedReference: ts.ResolvedProjectReference|undefined, options: ts.CompilerOptions): + (ts.ResolvedTypeReferenceDirective | undefined)[] { + return project.resolveTypeReferenceDirectives( + typeReferenceDirectiveNames, containingFile, redirectedReference); + }, + }; + + if (project.trace) { + compilerHost.trace = function trace(s: string) { + project.trace!(s); + }; + } + if (project.realpath) { + compilerHost.realpath = function realpath(path: string): string { + return project.realpath!(path); + }; + } + return compilerHost; +} diff --git a/packages/language-service/ivy/language_service.ts b/packages/language-service/ivy/language_service.ts index b9d258352a..cd57a8c36b 100644 --- a/packages/language-service/ivy/language_service.ts +++ b/packages/language-service/ivy/language_service.ts @@ -8,17 +8,28 @@ import {CompilerOptions, createNgCompilerOptions} from '@angular/compiler-cli'; import * as ts from 'typescript/lib/tsserverlibrary'; +import {Compiler} from './compiler/compiler'; export class LanguageService { private options: CompilerOptions; + private readonly compiler: Compiler; constructor(project: ts.server.Project, private readonly tsLS: ts.LanguageService) { this.options = parseNgCompilerOptions(project); this.watchConfigFile(project); + this.compiler = new Compiler(project, this.options); } getSemanticDiagnostics(fileName: string): ts.Diagnostic[] { - return []; + const program = this.compiler.analyze(); + if (!program) { + return []; + } + const sourceFile = program.getSourceFile(fileName); + if (!sourceFile) { + return []; + } + return this.compiler.getDiagnostics(sourceFile); } private watchConfigFile(project: ts.server.Project) { @@ -35,6 +46,7 @@ export class LanguageService { project.log(`Config file changed: ${fileName}`); if (eventKind === ts.FileWatcherEventKind.Changed) { this.options = parseNgCompilerOptions(project); + this.compiler.setCompilerOptions(this.options); } }); } diff --git a/packages/language-service/ivy/test/BUILD.bazel b/packages/language-service/ivy/test/BUILD.bazel index 938725080a..7d539e9e6b 100644 --- a/packages/language-service/ivy/test/BUILD.bazel +++ b/packages/language-service/ivy/test/BUILD.bazel @@ -23,6 +23,9 @@ jasmine_node_test( "//packages/forms", "//packages/language-service/test:project", ], + tags = [ + "ivy-only", + ], deps = [ ":test_lib", ], diff --git a/packages/language-service/ivy/test/diagnostic_spec.ts b/packages/language-service/ivy/test/diagnostic_spec.ts new file mode 100644 index 0000000000..29df671de7 --- /dev/null +++ b/packages/language-service/ivy/test/diagnostic_spec.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright Google Inc. 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 {LanguageService} from '../language_service'; + +import {APP_COMPONENT, setup} from './mock_host'; + +describe('diagnostic', () => { + const {project, service, tsLS} = setup(); + const ngLS = new LanguageService(project, tsLS); + + beforeEach(() => { + service.reset(); + }); + + it('should not produce error for AppComponent', () => { + const diags = ngLS.getSemanticDiagnostics(APP_COMPONENT); + expect(diags).toEqual([]); + }); + + it('should report member does not exist', () => { + const content = service.overwriteInlineTemplate(APP_COMPONENT, '{{ nope }}'); + const diags = ngLS.getSemanticDiagnostics(APP_COMPONENT); + expect(diags.length).toBe(1); + const {category, file, start, length, messageText} = diags[0]; + expect(category).toBe(ts.DiagnosticCategory.Error); + expect(file?.fileName).toBe(APP_COMPONENT); + expect(content.substring(start!, start! + length!)).toBe('nope'); + expect(messageText).toBe(`Property 'nope' does not exist on type 'AppComponent'.`); + }); +}); diff --git a/packages/language-service/ivy/test/mock_host.ts b/packages/language-service/ivy/test/mock_host.ts index 3c322eda21..25165025aa 100644 --- a/packages/language-service/ivy/test/mock_host.ts +++ b/packages/language-service/ivy/test/mock_host.ts @@ -35,6 +35,7 @@ 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() {} @@ -44,9 +45,14 @@ export const host: ts.server.ServerHost = { ...ts.sys, readFile(absPath: string, encoding?: string): string | undefined { - // TODO: Need to remove all annotations in templates like we do in - // MockTypescriptHost - return ts.sys.readFile(absPath, encoding); + const content = ts.sys.readFile(absPath, encoding); + if (content === undefined) { + return undefined; + } + if (absPath === APP_COMPONENT || absPath === PARSING_CASES || absPath === TEST_TEMPLATE) { + return removeReferenceMarkers(removeLocationMarkers(content)); + } + return content; }, watchFile(path: string, callback: ts.FileWatcherCallback): ts.FileWatcher { return NOOP_FILE_WATCHER; @@ -109,16 +115,20 @@ class MockService { overwrite(fileName: string, newText: string): string { const scriptInfo = this.getScriptInfo(fileName); - this.overwritten.add(scriptInfo.fileName); - const snapshot = scriptInfo.getSnapshot(); - scriptInfo.editContent(0, snapshot.getLength(), preprocess(newText)); - const sameProgram = this.project.updateGraph(); // clear the dirty flag - if (sameProgram) { - throw new Error('Project should have updated program after overwrite'); - } + this.overwriteScriptInfo(scriptInfo, preprocess(newText)); return newText; } + overwriteInlineTemplate(fileName: string, newTemplate: string): string { + const scriptInfo = this.getScriptInfo(fileName); + const snapshot = scriptInfo.getSnapshot(); + const originalContent = snapshot.getText(0, snapshot.getLength()); + const newContent = + originalContent.replace(/template: `([\s\S]+)`/, `template: \`${newTemplate}\``); + this.overwriteScriptInfo(scriptInfo, preprocess(newContent)); + return newContent; + } + reset() { if (this.overwritten.size === 0) { return; @@ -130,10 +140,6 @@ class MockService { throw new Error(`Failed to reload ${scriptInfo.fileName}`); } } - const sameProgram = this.project.updateGraph(); - if (sameProgram) { - throw new Error('Project should have updated program after reset'); - } this.overwritten.clear(); } @@ -144,9 +150,26 @@ class MockService { } return scriptInfo; } + + private overwriteScriptInfo(scriptInfo: ts.server.ScriptInfo, newText: string) { + const snapshot = scriptInfo.getSnapshot(); + scriptInfo.editContent(0, snapshot.getLength(), newText); + this.overwritten.add(scriptInfo.fileName); + } } const REGEX_CURSOR = /¦/g; function preprocess(text: string): string { return text.replace(REGEX_CURSOR, ''); } + +const REF_MARKER = /«(((\w|\-)+)|([^ᐱ]*ᐱ(\w+)ᐱ.[^»]*))»/g; +const LOC_MARKER = /\~\{(\w+(-\w+)*)\}/g; + +function removeReferenceMarkers(value: string): string { + return value.replace(REF_MARKER, ''); +} + +function removeLocationMarkers(value: string): string { + return value.replace(LOC_MARKER, ''); +}